@marlinjai/mail-sdk 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -81,6 +81,122 @@ package has not wrapped yet, or to pass a raw `AbortSignal`.
81
81
  const workspace = await mail.request('workspace.get');
82
82
  ```
83
83
 
84
+ ## Recipe: a confirmation letter after a form
85
+
86
+ The pattern for any one-to-one mail your backend sends in answer to something a
87
+ person did (a contact form, a signup for updates, a booking request): a
88
+ **letter**, which is a mailing with no topic and one recipient
89
+ (`docs/public/mail-contract.md`, "Letters"). It takes three calls, each with an
90
+ idempotency key derived from **your own** key for the submission, so a retried
91
+ request sends one letter, never two.
92
+
93
+ ```ts
94
+ import { createMailClient, MailApiError } from '@marlinjai/mail-sdk';
95
+
96
+ const mail = createMailClient({ baseUrl: process.env.MAIL_SERVICE_URL!, apiKey: process.env.MAIL_SERVICE_API_KEY! });
97
+
98
+ /**
99
+ * submissionKey: a stable id you already store for the form submission.
100
+ * locale: the language the person used on your site ('fr', 'de', ...).
101
+ * reason: the key the form's drop-down sent ('venue', 'press', ...), never a sentence.
102
+ */
103
+ export async function sendConfirmation(submissionKey: string, email: string, name: string, locale: string, reason: string) {
104
+ const mailing = await mail.mailings.create(
105
+ {
106
+ template_id: process.env.MAIL_TEMPLATE_CONTACT_CONFIRMATION!,
107
+ provider_id: process.env.MAIL_PROVIDER_ID!,
108
+ // No `topic`: that is what makes it a letter.
109
+ send_locale: locale,
110
+ metadata: { kind: 'contact_confirmation', submission: submissionKey },
111
+ },
112
+ { idempotencyKey: `${submissionKey}:create` },
113
+ );
114
+ await mail.mailings.addRecipients(
115
+ mailing.id,
116
+ { recipients: [{ email, merge: { first_name: name, reason } }] },
117
+ { idempotencyKey: `${submissionKey}:recipients` },
118
+ );
119
+ await mail.mailings.send(mailing.id, { idempotencyKey: `${submissionKey}:send` });
120
+ return mailing.id;
121
+ }
122
+ ```
123
+
124
+ What each piece does, and why:
125
+
126
+ - **No `contacts.upsert` first.** Adding a recipient by email creates the
127
+ contact when there is none (subscribed to nothing, since a letter has no
128
+ topic) and never changes one that exists. A form usually takes an address
129
+ without proof that the person owns it, so a submission should not be able to
130
+ rename someone, change their language or subscribe them to anything; leaving
131
+ the upsert out guarantees that. Per-letter values (the name as typed, a
132
+ gathering or product name) go in the recipient's `merge`, which fills the
133
+ template's `{{first_name}}` and other fields for this letter only.
134
+ - **The language.** `send_locale` picks the template's **ready** version in that
135
+ language, and the template's main language when there is none; subject and
136
+ preheader come from the same version. Pass the language the person used on
137
+ your site, validated against the ones you support.
138
+ - **Choices, not prose.** When the letter's wording depends on what the person
139
+ did (which reason they picked, whether they filled an optional field, whether
140
+ a name is known), the template holds the sentences as **insertions**, in every
141
+ language, and you send only the value that chooses: `reason: 'venue'`,
142
+ `about: true`, the name (`docs/public/mail-contract.md`, "Insertions"). Never
143
+ compute a sentence in your code and pass it as a merge value: it would be in
144
+ one language, unreviewed, inside a letter in another. Offer the choices as a
145
+ fixed list (a drop-down, never free text) built from the template's keys,
146
+ and check your data against them before sending:
147
+
148
+ ```ts
149
+ import { insertionInputs } from '@marlinjai/mail-sdk';
150
+
151
+ const template = await mail.templates.get(process.env.MAIL_TEMPLATE_CONTACT_CONFIRMATION!);
152
+ const accepted = insertionInputs(template.insertions).reason?.keys ?? [];
153
+ // ['venue', 'expert', 'partnership', 'press']: anything else gets the "otherwise" text.
154
+ ```
155
+
156
+ A value no choice takes still sends (with the `otherwise` text) and is
157
+ recorded as a miss (`matched: false` in `insertions` on `message.sent`, and in
158
+ `mailings.languages`), so a drift between your form and the template shows up
159
+ instead of passing silently.
160
+ - **The provider** (`provider_id`, required) is the sending account the
161
+ workspace has set up: find its id with `mail.providers.list()` or on the
162
+ dashboard's Providers page, and keep it in configuration next to the
163
+ template ids. A letter uses the same provider as any other mailing.
164
+ - **Subject and preheader** come from the template. `mailings.create` refuses a
165
+ `subject` that differs from the template's (`subject_from_template`); pass one
166
+ only for a template that has none.
167
+ - **Idempotency.** A replay with the same key and the same body within 24 hours
168
+ answers with the first response and changes nothing, so the create returns
169
+ the first mailing's id and the send does not send again. Derive the keys from
170
+ your submission key (not from a random value per attempt), keep the body of
171
+ each call identical across attempts, and retry within the 24 hours. The same
172
+ key with a different body is `idempotency_key_reused`: that is a bug in the
173
+ caller, not something to retry. Refusals are kept too: every answer below
174
+ 500 (a `validation_failed`, an `unknown_provider`) is stored against its key
175
+ and replayed, so retrying the same key after fixing the cause gets the old
176
+ refusal back. When a retry changes the body (a provider id looked up again,
177
+ say), give that call a key of its own, for example
178
+ `${submissionKey}:create:${providerId}`: a refused call created nothing, so
179
+ a new key cannot duplicate a letter. Only a 5xx releases the key.
180
+ - **Consent.** A letter reaches anyone not blocked on every topic (an
181
+ all-topics unsubscribe, a hard bounce, a complaint). A blocked person's
182
+ recipient ends `skipped` with `skip_reason: "suppressed"`. The letter still
183
+ needs `{{unsubscribe_url}}` in the template; it opens the hosted page with
184
+ every topic and "unsubscribe from everything" first.
185
+ - **Knowing what happened.** `send` answers once the letter is queued, not
186
+ delivered. The `message.sent` and `message.failed` webhooks carry
187
+ `mailing_id` and your metadata as `mailing_metadata`, so the receiver can
188
+ file the outcome against the submission without a lookup (see [A webhook receiver](#a-webhook-receiver)).
189
+ - **Bound it yourself.** Idempotency stops a retry, not someone submitting the
190
+ form again and again with fresh submissions to flood one inbox. Count the
191
+ letters you sent to an address recently and stop at a small number.
192
+ - **Never let the letter fail the form.** Send it after the submission is
193
+ safely stored, catch `MailApiError` and network errors, record them, and tell
194
+ the person their submission worked.
195
+
196
+ Testing against a fake: implement the same replay rule (same key and body,
197
+ same answer), `addRecipients` idempotent on the address, and at most one
198
+ recipient on a letter, or the tests prove less than the real service does.
199
+
84
200
  ## The dashboard variant: server-only, per signed-in person
85
201
 
86
202
  The mail service's own dashboard (`apps/dashboard`) signs people in through