@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 +116 -0
- package/dist/index.d.mts +283 -99
- package/dist/index.d.ts +283 -99
- package/package.json +2 -2
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
|