@nxgt/mail-resend 0.2.0 → 0.3.0

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
@@ -33,8 +33,8 @@ bun add @nxgt/mail-resend @nxgt/mail
33
33
 
34
34
  Peers, all required:
35
35
 
36
- - `@nxgt/mail` — the port, the errors and the checks: `^0.2`, the version
37
- with attachments. One copy in your tree, so `error instanceof MailFailure`
36
+ - `@nxgt/mail` — the port, the errors and the checks: `^0.3`, the version
37
+ with attachments and `idempotencyKey`. One copy in your tree, so `error instanceof MailFailure`
38
38
  holds.
39
39
  - `typescript` (6). Bundler resolution (`"moduleResolution": "bundler"`) is
40
40
  what is supported and tested; `nodenext` is out of contract.
@@ -109,12 +109,43 @@ base64** — a third larger than the files — and refuses more (`MailRefused`).
109
109
  **A large or sensitive file is a signed link in the template**, not an
110
110
  attachment. See [Setting up — what a message becomes](docs/guide/setup.md#what-a-message-becomes).
111
111
 
112
+ ### Idempotency — a retry that delivers once
113
+
114
+ A message's `idempotencyKey` is sent as Resend's `Idempotency-Key` header.
115
+ Resend keeps a key for **24 hours**: a retry within them answers the first
116
+ send's id, and delivers nothing more.
117
+
118
+ ```ts
119
+ import { MailFailure } from '@nxgt/mail';
120
+
121
+ const receipt = {
122
+ to: 'ada@example.com',
123
+ subject: 'Your receipt',
124
+ html: '<p>Thank you for your order.</p>',
125
+ text: 'Thank you for your order.',
126
+ idempotencyKey: 'order-42/receipt', // from what the e-mail is about, never the time or a random value
127
+ };
128
+
129
+ const sent = await mailer.send(receipt).catch(async (error: unknown) => {
130
+ if (!(error instanceof MailFailure)) throw error;
131
+ return mailer.send(receipt); // a timeout may have delivered it: the key keeps it to one e-mail
132
+ });
133
+ ```
134
+
135
+ The same key with a **different** message is refused by Resend
136
+ (`409 invalid_idempotent_request`, a `MailRefused`): a key names one e-mail.
137
+ The same key while its first send is **still in progress**
138
+ (`409 concurrent_idempotent_requests`) is a `MailFailure`: retry later. Past
139
+ 24 hours, the key is forgotten and a retry delivers again. The key never
140
+ reaches the e-mail. See
141
+ [Setting up — the idempotency key](docs/guide/setup.md#the-idempotency-key).
142
+
112
143
  ### Errors — a refusal or a failure
113
144
 
114
145
  | When | Throws | `cause` |
115
146
  | --- | --- | --- |
116
- | `400`, `422` — Resend refuses the message, an attachment over the size limit included; `413` — a request too large for what sits in front of the API | `MailRefused` — `send: Resend refused the message` | an `Error` with `status`, `errorName` and Resend's `detail` |
117
- | `401`, `403`, `429`, `5xx`, any other status | `MailFailure` — `send: Resend could not take the message` | the same |
147
+ | `400`, `422` — Resend refuses the message, an attachment over the size limit included; `413` — a request too large for what sits in front of the API; `409 invalid_idempotent_request` — the `idempotencyKey` already used for a different message | `MailRefused` — `send: Resend refused the message` | an `Error` with `status`, `errorName` and Resend's `detail` |
148
+ | `401`, `403`, `429`, `5xx`, `409 concurrent_idempotent_requests` — the same key's first send still in progress — any other status | `MailFailure` — `send: Resend could not take the message` | the same |
118
149
  | A network error | `MailFailure` — `send: Resend could not be reached` | the `fetch` error |
119
150
  | No answer within `timeoutMs` | `MailFailure` — `send: Resend did not answer within <timeoutMs> ms` | the `TimeoutError` |
120
151
  | No sender, on the message or as a default | `MailRefused` — `send: from is missing — give the message a from, or createResendMailer a default one` | — |
@@ -168,7 +199,8 @@ The transport does not wait and retry for you.
168
199
 
169
200
  **A timeout does not mean nothing was sent.** After `timeoutMs`, or a
170
201
  connection dropped mid-request, Resend may have accepted the e-mail: a retry
171
- can send it twice. Weigh that before retrying.
202
+ without a key can send it twice. Set `idempotencyKey: 'order-42/receipt'`,
203
+ and retry within Resend's 24 hours.
172
204
 
173
205
  ## Type safety, counted
174
206
 
package/dist/index.d.ts CHANGED
@@ -12,7 +12,9 @@
12
12
  * ```
13
13
  *
14
14
  * **A failure throws** the `MailFailure` or `MailRefused` of the `@nxgt/mail`
15
- * peer, what Resend answered as the `cause`. Nothing is retried.
15
+ * peer, what Resend answered as the `cause`. Nothing is retried; a message's
16
+ * `idempotencyKey` is sent as Resend's `Idempotency-Key`, so a retry the
17
+ * caller makes delivers once.
16
18
  */
17
19
  import { type Address, type Mailer } from '@nxgt/mail';
18
20
  export interface ResendMailerOptions {
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,EACN,KAAK,OAAO,EAEZ,KAAK,MAAM,EAKX,MAAM,YAAY,CAAC;AAEpB,MAAM,WAAW,mBAAmB;IACnC,2BAA2B;IAC3B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,sFAAsF;IACtF,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,CAAC;IACxB,wCAAwC;IACxC,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B,0DAA0D;IAC1D,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,WAAW,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;IACvE,iEAAiE;IACjE,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;CAC5B;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM,CAItD;AAyID,6EAA6E;AAC7E,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,mBAAmB,GAAG,MAAM,CA+FvE"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,EACN,KAAK,OAAO,EAEZ,KAAK,MAAM,EAKX,MAAM,YAAY,CAAC;AAEpB,MAAM,WAAW,mBAAmB;IACnC,2BAA2B;IAC3B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,sFAAsF;IACtF,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,CAAC;IACxB,wCAAwC;IACxC,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B,0DAA0D;IAC1D,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,WAAW,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;IACvE,iEAAiE;IACjE,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;CAC5B;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM,CAItD;AA4KD,6EAA6E;AAC7E,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,mBAAmB,GAAG,MAAM,CA6EvE"}
package/dist/index.js CHANGED
@@ -39,6 +39,27 @@ function base64Of(bytes) {
39
39
  }
40
40
  return btoa(binary);
41
41
  }
42
+ function bodyOf(message, sender) {
43
+ const to = Array.isArray(message.to) ? message.to : [message.to];
44
+ return {
45
+ from: formatAddress(sender),
46
+ to: to.map(formatAddress),
47
+ subject: message.subject,
48
+ html: message.html,
49
+ text: message.text,
50
+ ...message.replyTo === undefined ? {} : {
51
+ reply_to: formatAddress(message.replyTo)
52
+ },
53
+ ...message.headers === undefined || Object.keys(message.headers).length === 0 ? {} : { headers: message.headers },
54
+ ...message.attachments === undefined || message.attachments.length === 0 ? {} : {
55
+ attachments: message.attachments.map((attachment) => ({
56
+ filename: attachment.filename,
57
+ content: base64Of(attachment.content),
58
+ content_type: attachment.contentType
59
+ }))
60
+ }
61
+ };
62
+ }
42
63
  var MAX_TIMEOUT_MS = 2147483647;
43
64
  var text = (value) => typeof value === "string" && value !== "" ? value : null;
44
65
  function checkOptions(options) {
@@ -83,25 +104,7 @@ function createResendMailer(options) {
83
104
  if (sender === undefined) {
84
105
  throw new MailRefused("send: from is missing — give the message a from, or createResendMailer a default one");
85
106
  }
86
- const to = Array.isArray(message.to) ? message.to : [message.to];
87
- const body = {
88
- from: formatAddress(sender),
89
- to: to.map(formatAddress),
90
- subject: message.subject,
91
- html: message.html,
92
- text: message.text,
93
- ...message.replyTo === undefined ? {} : {
94
- reply_to: formatAddress(message.replyTo)
95
- },
96
- ...message.headers === undefined ? {} : { headers: message.headers },
97
- ...message.attachments === undefined || message.attachments.length === 0 ? {} : {
98
- attachments: message.attachments.map((attachment) => ({
99
- filename: attachment.filename,
100
- content: base64Of(attachment.content),
101
- content_type: attachment.contentType
102
- }))
103
- }
104
- };
107
+ const body = bodyOf(message, sender);
105
108
  const signal = AbortSignal.timeout(timeoutMs);
106
109
  let response;
107
110
  try {
@@ -109,7 +112,8 @@ function createResendMailer(options) {
109
112
  method: "POST",
110
113
  headers: {
111
114
  authorization: `Bearer ${options.apiKey}`,
112
- "content-type": "application/json"
115
+ "content-type": "application/json",
116
+ ...message.idempotencyKey === undefined ? {} : { "idempotency-key": message.idempotencyKey }
113
117
  },
114
118
  body: JSON.stringify(body),
115
119
  signal
@@ -126,7 +130,7 @@ function createResendMailer(options) {
126
130
  if (response.ok)
127
131
  return { messageId: text(answer.id) };
128
132
  const cause = resendAnswer(response.status, text(answer.name), text(answer.message));
129
- if (response.status === 400 || response.status === 413 || response.status === 422) {
133
+ if (response.status === 400 || response.status === 413 || response.status === 422 || response.status === 409 && cause.errorName === "invalid_idempotent_request") {
130
134
  throw new MailRefused("send: Resend refused the message", { cause });
131
135
  }
132
136
  throw new MailFailure("send: Resend could not take the message", {
@@ -140,5 +144,5 @@ export {
140
144
  formatAddress
141
145
  };
142
146
 
143
- //# debugId=DBE3D2F49402DA0764756E2164756E21
147
+ //# debugId=BC85082B780FD90964756E2164756E21
144
148
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -2,9 +2,9 @@
2
2
  "version": 3,
3
3
  "sources": ["../src/index.ts"],
4
4
  "sourcesContent": [
5
- "/**\n * `@nxgt/mail-resend` — a Resend transport for `@nxgt/mail`, over `fetch`,\n * with no SDK.\n *\n * ```ts\n * import { createResendMailer } from '@nxgt/mail-resend';\n *\n * const mailer = createResendMailer({\n * apiKey: process.env.RESEND_API_KEY ?? '',\n * from: { name: 'Acme', address: 'noreply@acme.test' },\n * });\n * ```\n *\n * **A failure throws** the `MailFailure` or `MailRefused` of the `@nxgt/mail`\n * peer, what Resend answered as the `cause`. Nothing is retried.\n */\n\nimport {\n\ttype Address,\n\tcheckMessage,\n\ttype Mailer,\n\tMailFailure,\n\ttype MailMessage,\n\tMailRefused,\n\ttype SentMail,\n} from '@nxgt/mail';\n\nexport interface ResendMailerOptions {\n\t/** The API key, `re_…`. */\n\treadonly apiKey: string;\n\t/** The sender of a message that names none. Without it, such a message is refused. */\n\treadonly from?: Address;\n\t/** Default `https://api.resend.com`. */\n\treadonly baseUrl?: string;\n\t/** Default the global `fetch`. For a proxy, or a test. */\n\treadonly fetch?: (url: string, init: RequestInit) => Promise<Response>;\n\t/** How long a send may take before it fails. Default `30000`. */\n\treadonly timeoutMs?: number;\n}\n\n/**\n * An address as Resend reads it: bare, or `\"name\" <address>` — the name a\n * quoted string, so a comma or an angle bracket in it names no one else.\n */\nexport function formatAddress(address: Address): string {\n\tif (typeof address === 'string') return address;\n\tconst name = address.name.replace(/[\\\\\"]/g, (char) => `\\\\${char}`);\n\treturn `\"${name}\" <${address.address}>`;\n}\n\n/**\n * What Resend answered, kept as the `cause`: a plain `Error` — a transport\n * defines no error class of its own — with the status, Resend's error name\n * (`validation_error`, `rate_limit_exceeded`…) and its message as `detail`.\n * Its own message holds the status and the name only: Resend's message can\n * quote an address, and a message reports a shape, never a value.\n */\nfunction resendAnswer(\n\tstatus: number,\n\terrorName: string | null,\n\tdetail: string | null,\n): Error & {\n\treadonly status: number;\n\treadonly errorName: string | null;\n\treadonly detail: string | null;\n} {\n\treturn Object.assign(\n\t\tnew Error(\n\t\t\t`Resend answered ${status}${errorName === null ? '' : ` ${errorName}`}`,\n\t\t),\n\t\t{ status, errorName, detail },\n\t);\n}\n\n/**\n * Settles with `work`, or rejects with the signal's reason once it aborts —\n * so the timeout holds even with an injected `fetch` that ignores the signal.\n */\nfunction beforeAbort<T>(work: Promise<T>, signal: AbortSignal): Promise<T> {\n\treturn new Promise<T>((resolve, reject) => {\n\t\tconst abort = () => reject(signal.reason);\n\t\tif (signal.aborted) return abort();\n\t\tsignal.addEventListener('abort', abort, { once: true });\n\t\twork.then(\n\t\t\t(value) => {\n\t\t\t\tsignal.removeEventListener('abort', abort);\n\t\t\t\tresolve(value);\n\t\t\t},\n\t\t\t(error: unknown) => {\n\t\t\t\tsignal.removeEventListener('abort', abort);\n\t\t\t\treject(error);\n\t\t\t},\n\t\t);\n\t});\n}\n\n/** The JSON body of an answer, or `{}` when it is not JSON or never ends. */\nasync function readAnswer(\n\tresponse: Response,\n\tsignal: AbortSignal,\n): Promise<Record<string, unknown>> {\n\tconst body: unknown = await beforeAbort(response.json(), signal).then(\n\t\t(value: unknown) => value,\n\t\t() => null,\n\t);\n\treturn typeof body === 'object' && body !== null\n\t\t? (body as Record<string, unknown>)\n\t\t: {};\n}\n\n/**\n * `bytes` as base64, as Resend takes an attachment's `content` — with no Node\n * built-in, so it runs on an edge runtime. Read in slices, so a large file\n * never spreads more arguments than a call takes.\n */\nfunction base64Of(bytes: Uint8Array): string {\n\tlet binary = '';\n\tfor (let start = 0; start < bytes.length; start += 0x8000) {\n\t\tbinary += String.fromCharCode(...bytes.subarray(start, start + 0x8000));\n\t}\n\treturn btoa(binary);\n}\n\n/** The largest delay a timer takes, 2³¹ − 1 ms — about 24.8 days. */\nconst MAX_TIMEOUT_MS = 2_147_483_647;\n\nconst text = (value: unknown) =>\n\ttypeof value === 'string' && value !== '' ? value : null;\n\nfunction checkOptions(options: ResendMailerOptions): void {\n\tif (typeof options !== 'object' || options === null) {\n\t\tthrow new TypeError(\n\t\t\t'createResendMailer: options must be an object, as { apiKey }',\n\t\t);\n\t}\n\tif (typeof options.apiKey !== 'string' || options.apiKey.trim() === '') {\n\t\tthrow new TypeError(\n\t\t\t'createResendMailer: apiKey must be a Resend API key — is the environment variable set?',\n\t\t);\n\t}\n\tif (/\\s/.test(options.apiKey)) {\n\t\t// A key read from a file often keeps its final line break; `fetch`\n\t\t// would then refuse the header at every send, as an outage.\n\t\tthrow new TypeError(\n\t\t\t'createResendMailer: apiKey holds whitespace — trim the value it was read from',\n\t\t);\n\t}\n\tif (\n\t\toptions.baseUrl !== undefined &&\n\t\t(typeof options.baseUrl !== 'string' ||\n\t\t\t!/^https?:\\/\\/[^/]/.test(options.baseUrl))\n\t) {\n\t\tthrow new TypeError(\n\t\t\t'createResendMailer: baseUrl must be an http: or https: URL',\n\t\t);\n\t}\n\tif (options.fetch !== undefined && typeof options.fetch !== 'function') {\n\t\tthrow new TypeError('createResendMailer: fetch must be a function');\n\t}\n\tif (\n\t\toptions.timeoutMs !== undefined &&\n\t\t!(Number.isInteger(options.timeoutMs) && options.timeoutMs > 0)\n\t) {\n\t\tthrow new TypeError(\n\t\t\t'createResendMailer: timeoutMs must be a positive integer',\n\t\t);\n\t}\n\tif (options.timeoutMs !== undefined && options.timeoutMs > MAX_TIMEOUT_MS) {\n\t\t// A timer's delay is a signed 32-bit integer: above it, the runtime\n\t\t// fires at once, and every send would time out.\n\t\tthrow new TypeError(\n\t\t\t`createResendMailer: timeoutMs must be at most ${MAX_TIMEOUT_MS} — a longer timer fires at once`,\n\t\t);\n\t}\n\tif (options.from !== undefined) {\n\t\ttry {\n\t\t\tcheckMessage({ to: options.from, subject: '', html: '', text: '' });\n\t\t} catch {\n\t\t\tthrow new TypeError(\n\t\t\t\t'createResendMailer: from must be an e-mail address, as noreply@example.com or { name, address }',\n\t\t\t);\n\t\t}\n\t}\n}\n\n/** Creates a {@link Mailer} that sends each message through Resend's API. */\nexport function createResendMailer(options: ResendMailerOptions): Mailer {\n\tcheckOptions(options);\n\tconst endpoint = `${(options.baseUrl ?? 'https://api.resend.com').replace(/\\/+$/, '')}/emails`;\n\tconst post =\n\t\toptions.fetch ??\n\t\t((url: string, init: RequestInit) => globalThis.fetch(url, init));\n\tconst timeoutMs = options.timeoutMs ?? 30_000;\n\n\treturn {\n\t\tasync send(message: MailMessage): Promise<SentMail> {\n\t\t\tcheckMessage(message);\n\t\t\tconst sender = message.from ?? options.from;\n\t\t\tif (sender === undefined) {\n\t\t\t\tthrow new MailRefused(\n\t\t\t\t\t'send: from is missing — give the message a from, or createResendMailer a default one',\n\t\t\t\t);\n\t\t\t}\n\t\t\tconst to = Array.isArray(message.to) ? message.to : [message.to];\n\t\t\tconst body = {\n\t\t\t\tfrom: formatAddress(sender),\n\t\t\t\tto: to.map(formatAddress),\n\t\t\t\tsubject: message.subject,\n\t\t\t\thtml: message.html,\n\t\t\t\ttext: message.text,\n\t\t\t\t...(message.replyTo === undefined\n\t\t\t\t\t? {}\n\t\t\t\t\t: {\n\t\t\t\t\t\t\t// biome-ignore lint/style/useNamingConvention: Resend's wire format names the field, not us.\n\t\t\t\t\t\t\treply_to: formatAddress(message.replyTo),\n\t\t\t\t\t\t}),\n\t\t\t\t...(message.headers === undefined ? {} : { headers: message.headers }),\n\t\t\t\t...(message.attachments === undefined ||\n\t\t\t\tmessage.attachments.length === 0\n\t\t\t\t\t? {}\n\t\t\t\t\t: {\n\t\t\t\t\t\t\tattachments: message.attachments.map((attachment) => ({\n\t\t\t\t\t\t\t\tfilename: attachment.filename,\n\t\t\t\t\t\t\t\tcontent: base64Of(attachment.content),\n\t\t\t\t\t\t\t\t// biome-ignore lint/style/useNamingConvention: Resend's wire format names the field, not us.\n\t\t\t\t\t\t\t\tcontent_type: attachment.contentType,\n\t\t\t\t\t\t\t})),\n\t\t\t\t\t\t}),\n\t\t\t};\n\n\t\t\tconst signal = AbortSignal.timeout(timeoutMs);\n\t\t\tlet response: Response;\n\t\t\ttry {\n\t\t\t\tresponse = await beforeAbort(\n\t\t\t\t\tpost(endpoint, {\n\t\t\t\t\t\tmethod: 'POST',\n\t\t\t\t\t\theaders: {\n\t\t\t\t\t\t\tauthorization: `Bearer ${options.apiKey}`,\n\t\t\t\t\t\t\t'content-type': 'application/json',\n\t\t\t\t\t\t},\n\t\t\t\t\t\tbody: JSON.stringify(body),\n\t\t\t\t\t\tsignal,\n\t\t\t\t\t}),\n\t\t\t\t\tsignal,\n\t\t\t\t);\n\t\t\t} catch (error) {\n\t\t\t\tif (error instanceof DOMException && error.name === 'TimeoutError') {\n\t\t\t\t\tthrow new MailFailure(\n\t\t\t\t\t\t`send: Resend did not answer within ${timeoutMs} ms`,\n\t\t\t\t\t\t{ cause: error },\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\tthrow new MailFailure('send: Resend could not be reached', {\n\t\t\t\t\tcause: error,\n\t\t\t\t});\n\t\t\t}\n\n\t\t\tconst answer = await readAnswer(response, signal);\n\t\t\tif (response.ok) return { messageId: text(answer.id) };\n\n\t\t\tconst cause = resendAnswer(\n\t\t\t\tresponse.status,\n\t\t\t\ttext(answer.name),\n\t\t\t\ttext(answer.message),\n\t\t\t);\n\t\t\t// 400 and 422 are Resend refusing the message, and 413 a request too\n\t\t\t// large to take — attachments over the limit, which sending again\n\t\t\t// cannot fix. Anything else — a key refused, a rate limit, an outage —\n\t\t\t// is Resend failing to take it.\n\t\t\tif (\n\t\t\t\tresponse.status === 400 ||\n\t\t\t\tresponse.status === 413 ||\n\t\t\t\tresponse.status === 422\n\t\t\t) {\n\t\t\t\tthrow new MailRefused('send: Resend refused the message', { cause });\n\t\t\t}\n\t\t\tthrow new MailFailure('send: Resend could not take the message', {\n\t\t\t\tcause,\n\t\t\t});\n\t\t},\n\t};\n}\n"
5
+ "/**\n * `@nxgt/mail-resend` — a Resend transport for `@nxgt/mail`, over `fetch`,\n * with no SDK.\n *\n * ```ts\n * import { createResendMailer } from '@nxgt/mail-resend';\n *\n * const mailer = createResendMailer({\n * apiKey: process.env.RESEND_API_KEY ?? '',\n * from: { name: 'Acme', address: 'noreply@acme.test' },\n * });\n * ```\n *\n * **A failure throws** the `MailFailure` or `MailRefused` of the `@nxgt/mail`\n * peer, what Resend answered as the `cause`. Nothing is retried; a message's\n * `idempotencyKey` is sent as Resend's `Idempotency-Key`, so a retry the\n * caller makes delivers once.\n */\n\nimport {\n\ttype Address,\n\tcheckMessage,\n\ttype Mailer,\n\tMailFailure,\n\ttype MailMessage,\n\tMailRefused,\n\ttype SentMail,\n} from '@nxgt/mail';\n\nexport interface ResendMailerOptions {\n\t/** The API key, `re_…`. */\n\treadonly apiKey: string;\n\t/** The sender of a message that names none. Without it, such a message is refused. */\n\treadonly from?: Address;\n\t/** Default `https://api.resend.com`. */\n\treadonly baseUrl?: string;\n\t/** Default the global `fetch`. For a proxy, or a test. */\n\treadonly fetch?: (url: string, init: RequestInit) => Promise<Response>;\n\t/** How long a send may take before it fails. Default `30000`. */\n\treadonly timeoutMs?: number;\n}\n\n/**\n * An address as Resend reads it: bare, or `\"name\" <address>` — the name a\n * quoted string, so a comma or an angle bracket in it names no one else.\n */\nexport function formatAddress(address: Address): string {\n\tif (typeof address === 'string') return address;\n\tconst name = address.name.replace(/[\\\\\"]/g, (char) => `\\\\${char}`);\n\treturn `\"${name}\" <${address.address}>`;\n}\n\n/**\n * What Resend answered, kept as the `cause`: a plain `Error` — a transport\n * defines no error class of its own — with the status, Resend's error name\n * (`validation_error`, `rate_limit_exceeded`…) and its message as `detail`.\n * Its own message holds the status and the name only: Resend's message can\n * quote an address, and a message reports a shape, never a value.\n */\nfunction resendAnswer(\n\tstatus: number,\n\terrorName: string | null,\n\tdetail: string | null,\n): Error & {\n\treadonly status: number;\n\treadonly errorName: string | null;\n\treadonly detail: string | null;\n} {\n\treturn Object.assign(\n\t\tnew Error(\n\t\t\t`Resend answered ${status}${errorName === null ? '' : ` ${errorName}`}`,\n\t\t),\n\t\t{ status, errorName, detail },\n\t);\n}\n\n/**\n * Settles with `work`, or rejects with the signal's reason once it aborts —\n * so the timeout holds even with an injected `fetch` that ignores the signal.\n */\nfunction beforeAbort<T>(work: Promise<T>, signal: AbortSignal): Promise<T> {\n\treturn new Promise<T>((resolve, reject) => {\n\t\tconst abort = () => reject(signal.reason);\n\t\tif (signal.aborted) return abort();\n\t\tsignal.addEventListener('abort', abort, { once: true });\n\t\twork.then(\n\t\t\t(value) => {\n\t\t\t\tsignal.removeEventListener('abort', abort);\n\t\t\t\tresolve(value);\n\t\t\t},\n\t\t\t(error: unknown) => {\n\t\t\t\tsignal.removeEventListener('abort', abort);\n\t\t\t\treject(error);\n\t\t\t},\n\t\t);\n\t});\n}\n\n/** The JSON body of an answer, or `{}` when it is not JSON or never ends. */\nasync function readAnswer(\n\tresponse: Response,\n\tsignal: AbortSignal,\n): Promise<Record<string, unknown>> {\n\tconst body: unknown = await beforeAbort(response.json(), signal).then(\n\t\t(value: unknown) => value,\n\t\t() => null,\n\t);\n\treturn typeof body === 'object' && body !== null\n\t\t? (body as Record<string, unknown>)\n\t\t: {};\n}\n\n/**\n * `bytes` as base64, as Resend takes an attachment's `content` — with no Node\n * built-in, so it runs on an edge runtime. Read in slices, so a large file\n * never spreads more arguments than a call takes.\n */\nfunction base64Of(bytes: Uint8Array): string {\n\tlet binary = '';\n\tfor (let start = 0; start < bytes.length; start += 0x8000) {\n\t\tbinary += String.fromCharCode(...bytes.subarray(start, start + 0x8000));\n\t}\n\treturn btoa(binary);\n}\n\n/** The JSON body of `POST /emails`: `message` in Resend's field names. */\nfunction bodyOf(\n\tmessage: MailMessage,\n\tsender: Address,\n): Record<string, unknown> {\n\tconst to = Array.isArray(message.to) ? message.to : [message.to];\n\treturn {\n\t\tfrom: formatAddress(sender),\n\t\tto: to.map(formatAddress),\n\t\tsubject: message.subject,\n\t\thtml: message.html,\n\t\ttext: message.text,\n\t\t...(message.replyTo === undefined\n\t\t\t? {}\n\t\t\t: {\n\t\t\t\t\t// biome-ignore lint/style/useNamingConvention: Resend's wire format names the field, not us.\n\t\t\t\t\treply_to: formatAddress(message.replyTo),\n\t\t\t\t}),\n\t\t...(message.headers === undefined ||\n\t\tObject.keys(message.headers).length === 0\n\t\t\t? {}\n\t\t\t: { headers: message.headers }),\n\t\t...(message.attachments === undefined || message.attachments.length === 0\n\t\t\t? {}\n\t\t\t: {\n\t\t\t\t\tattachments: message.attachments.map((attachment) => ({\n\t\t\t\t\t\tfilename: attachment.filename,\n\t\t\t\t\t\tcontent: base64Of(attachment.content),\n\t\t\t\t\t\t// biome-ignore lint/style/useNamingConvention: Resend's wire format names the field, not us.\n\t\t\t\t\t\tcontent_type: attachment.contentType,\n\t\t\t\t\t})),\n\t\t\t\t}),\n\t};\n}\n\n/** The largest delay a timer takes, 2³¹ − 1 ms — about 24.8 days. */\nconst MAX_TIMEOUT_MS = 2_147_483_647;\n\nconst text = (value: unknown) =>\n\ttypeof value === 'string' && value !== '' ? value : null;\n\nfunction checkOptions(options: ResendMailerOptions): void {\n\tif (typeof options !== 'object' || options === null) {\n\t\tthrow new TypeError(\n\t\t\t'createResendMailer: options must be an object, as { apiKey }',\n\t\t);\n\t}\n\tif (typeof options.apiKey !== 'string' || options.apiKey.trim() === '') {\n\t\tthrow new TypeError(\n\t\t\t'createResendMailer: apiKey must be a Resend API key — is the environment variable set?',\n\t\t);\n\t}\n\tif (/\\s/.test(options.apiKey)) {\n\t\t// A key read from a file often keeps its final line break; `fetch`\n\t\t// would then refuse the header at every send, as an outage.\n\t\tthrow new TypeError(\n\t\t\t'createResendMailer: apiKey holds whitespace — trim the value it was read from',\n\t\t);\n\t}\n\tif (\n\t\toptions.baseUrl !== undefined &&\n\t\t(typeof options.baseUrl !== 'string' ||\n\t\t\t!/^https?:\\/\\/[^/]/.test(options.baseUrl))\n\t) {\n\t\tthrow new TypeError(\n\t\t\t'createResendMailer: baseUrl must be an http: or https: URL',\n\t\t);\n\t}\n\tif (options.fetch !== undefined && typeof options.fetch !== 'function') {\n\t\tthrow new TypeError('createResendMailer: fetch must be a function');\n\t}\n\tif (\n\t\toptions.timeoutMs !== undefined &&\n\t\t!(Number.isInteger(options.timeoutMs) && options.timeoutMs > 0)\n\t) {\n\t\tthrow new TypeError(\n\t\t\t'createResendMailer: timeoutMs must be a positive integer',\n\t\t);\n\t}\n\tif (options.timeoutMs !== undefined && options.timeoutMs > MAX_TIMEOUT_MS) {\n\t\t// A timer's delay is a signed 32-bit integer: above it, the runtime\n\t\t// fires at once, and every send would time out.\n\t\tthrow new TypeError(\n\t\t\t`createResendMailer: timeoutMs must be at most ${MAX_TIMEOUT_MS} — a longer timer fires at once`,\n\t\t);\n\t}\n\tif (options.from !== undefined) {\n\t\ttry {\n\t\t\tcheckMessage({ to: options.from, subject: '', html: '', text: '' });\n\t\t} catch {\n\t\t\tthrow new TypeError(\n\t\t\t\t'createResendMailer: from must be an e-mail address, as noreply@example.com or { name, address }',\n\t\t\t);\n\t\t}\n\t}\n}\n\n/** Creates a {@link Mailer} that sends each message through Resend's API. */\nexport function createResendMailer(options: ResendMailerOptions): Mailer {\n\tcheckOptions(options);\n\tconst endpoint = `${(options.baseUrl ?? 'https://api.resend.com').replace(/\\/+$/, '')}/emails`;\n\tconst post =\n\t\toptions.fetch ??\n\t\t((url: string, init: RequestInit) => globalThis.fetch(url, init));\n\tconst timeoutMs = options.timeoutMs ?? 30_000;\n\n\treturn {\n\t\tasync send(message: MailMessage): Promise<SentMail> {\n\t\t\tcheckMessage(message);\n\t\t\tconst sender = message.from ?? options.from;\n\t\t\tif (sender === undefined) {\n\t\t\t\tthrow new MailRefused(\n\t\t\t\t\t'send: from is missing — give the message a from, or createResendMailer a default one',\n\t\t\t\t);\n\t\t\t}\n\t\t\tconst body = bodyOf(message, sender);\n\n\t\t\tconst signal = AbortSignal.timeout(timeoutMs);\n\t\t\tlet response: Response;\n\t\t\ttry {\n\t\t\t\tresponse = await beforeAbort(\n\t\t\t\t\tpost(endpoint, {\n\t\t\t\t\t\tmethod: 'POST',\n\t\t\t\t\t\theaders: {\n\t\t\t\t\t\t\tauthorization: `Bearer ${options.apiKey}`,\n\t\t\t\t\t\t\t'content-type': 'application/json',\n\t\t\t\t\t\t\t...(message.idempotencyKey === undefined\n\t\t\t\t\t\t\t\t? {}\n\t\t\t\t\t\t\t\t: { 'idempotency-key': message.idempotencyKey }),\n\t\t\t\t\t\t},\n\t\t\t\t\t\tbody: JSON.stringify(body),\n\t\t\t\t\t\tsignal,\n\t\t\t\t\t}),\n\t\t\t\t\tsignal,\n\t\t\t\t);\n\t\t\t} catch (error) {\n\t\t\t\tif (error instanceof DOMException && error.name === 'TimeoutError') {\n\t\t\t\t\tthrow new MailFailure(\n\t\t\t\t\t\t`send: Resend did not answer within ${timeoutMs} ms`,\n\t\t\t\t\t\t{ cause: error },\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\tthrow new MailFailure('send: Resend could not be reached', {\n\t\t\t\t\tcause: error,\n\t\t\t\t});\n\t\t\t}\n\n\t\t\tconst answer = await readAnswer(response, signal);\n\t\t\tif (response.ok) return { messageId: text(answer.id) };\n\n\t\t\tconst cause = resendAnswer(\n\t\t\t\tresponse.status,\n\t\t\t\ttext(answer.name),\n\t\t\t\ttext(answer.message),\n\t\t\t);\n\t\t\t// 400 and 422 are Resend refusing the message, 413 a request too large\n\t\t\t// to take, and a 409 invalid_idempotent_request an idempotency key\n\t\t\t// already used for another message: sending again cannot fix any of\n\t\t\t// them. Anything else — a key refused, a rate limit, an outage, a 409\n\t\t\t// for a send with the same key still in progress — is Resend failing\n\t\t\t// to take it.\n\t\t\tif (\n\t\t\t\tresponse.status === 400 ||\n\t\t\t\tresponse.status === 413 ||\n\t\t\t\tresponse.status === 422 ||\n\t\t\t\t(response.status === 409 &&\n\t\t\t\t\tcause.errorName === 'invalid_idempotent_request')\n\t\t\t) {\n\t\t\t\tthrow new MailRefused('send: Resend refused the message', { cause });\n\t\t\t}\n\t\t\tthrow new MailFailure('send: Resend could not take the message', {\n\t\t\t\tcause,\n\t\t\t});\n\t\t},\n\t};\n}\n"
6
6
  ],
7
- "mappings": "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",
8
- "debugId": "DBE3D2F49402DA0764756E2164756E21",
7
+ "mappings": "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",
8
+ "debugId": "BC85082B780FD90964756E2164756E21",
9
9
  "names": []
10
10
  }
package/docs/README.md CHANGED
@@ -7,7 +7,7 @@ mailer, transport, hand-over, refusal, failure — are defined once, in the
7
7
 
8
8
  | Page | Read it when |
9
9
  | --- | --- |
10
- | [Setting up](guide/setup.md) | You are wiring `createResendMailer`: the key, the default sender, a proxy or another `baseUrl`, your own `fetch`, the timeout, and the request each message becomes |
10
+ | [Setting up](guide/setup.md) | You are wiring `createResendMailer`: the key, the default sender, a proxy or another `baseUrl`, your own `fetch`, the timeout, the request each message becomes, and the idempotency key |
11
11
  | [Errors](guide/errors.md) | You are handling what `send` throws: which Resend answers are a `MailRefused`, which a `MailFailure`, what is on `cause`, and every `TypeError` at wiring |
12
12
  | [Testing](guide/testing.md) | You are testing the transport against a local server answering as Resend does, with `describeMailer` — or an application that uses it |
13
13
  | [Troubleshooting](troubleshooting.md) | You have an error message and want its cause and its fix |
@@ -47,6 +47,8 @@ Resend answers an error as `{ statusCode, name, message }`:
47
47
  | `400` | `validation_error` | `MailRefused` |
48
48
  | `413` | — (not in Resend's reference: a request too large for what sits in front of the API; refused, as a resend would fail again) | `MailRefused` |
49
49
  | `422` | `validation_error`, `missing_required_field`, `invalid_attachment` | `MailRefused` |
50
+ | `409` | `invalid_idempotent_request` — the `idempotencyKey` was already used, within 24 hours, for a different message | `MailRefused` |
51
+ | `409` | `concurrent_idempotent_requests` — a send with the same key is still in progress | `MailFailure` |
50
52
  | `401`, `403` | `missing_api_key`, `invalid_api_key`, an unverified domain | `MailFailure` |
51
53
  | `429` | `rate_limit_exceeded`, `daily_quota_exceeded` | `MailFailure` |
52
54
  | `5xx` | `internal_server_error` | `MailFailure` |
@@ -58,6 +60,26 @@ A `401` or `403` is a failure although it is a `4xx`: a bad key or an
58
60
  unverified domain refuses every message alike. It is the wiring that is
59
61
  wrong, not the message.
60
62
 
63
+ A `409` is decided by its `name`. `invalid_idempotent_request` is a refusal:
64
+ the key names another e-mail, and sending this one again under it fails
65
+ again — give it its own key. `concurrent_idempotent_requests` is a failure:
66
+ the first send with that key has not finished, and a retry later answers
67
+ its id. See [Setting up — the idempotency key](setup.md#the-idempotency-key).
68
+
69
+ ```ts
70
+ import { MailError, MailRefused } from '@nxgt/mail';
71
+
72
+ try {
73
+ await mailer.send(message);
74
+ } catch (error) {
75
+ const cause = error instanceof MailError ? (error.cause as { status?: number; errorName?: string | null }) : undefined;
76
+ if (error instanceof MailRefused && cause?.errorName === 'invalid_idempotent_request') {
77
+ // a bug in how keys are derived: two different e-mails were given the same one
78
+ }
79
+ throw error;
80
+ }
81
+ ```
82
+
61
83
  ## What `cause` holds
62
84
 
63
85
  For an answer, `cause` is a plain `Error` — the transport defines no class —
@@ -96,8 +118,8 @@ hold one.
96
118
 
97
119
  | `message` | Class | When |
98
120
  | --- | --- | --- |
99
- | `send: Resend refused the message` | `MailRefused` | A `400`, `413` or `422` |
100
- | `send: Resend could not take the message` | `MailFailure` | Any other answer that is not `2xx` |
121
+ | `send: Resend refused the message` | `MailRefused` | A `400`, `413` or `422`, or a `409 invalid_idempotent_request` |
122
+ | `send: Resend could not take the message` | `MailFailure` | Any other answer that is not `2xx` — a `409 concurrent_idempotent_requests` included |
101
123
  | `send: Resend could not be reached` | `MailFailure` | `fetch` threw |
102
124
  | `send: Resend did not answer within <timeoutMs> ms` | `MailFailure` | The timeout aborted the request |
103
125
  | `send: from is missing — give the message a from, or createResendMailer a default one` | `MailRefused` | A message without `from`, on a mailer without a default. No request is made |
@@ -205,6 +205,70 @@ Content-Type: application/json
205
205
  `send` answers `{ messageId }`: Resend's `id`, or `null` when a `2xx` answer
206
206
  carries none, or is not JSON — the message was accepted, the id is absent.
207
207
 
208
+ ## The idempotency key
209
+
210
+ A message's `idempotencyKey` is sent as Resend's `Idempotency-Key` header —
211
+ never in the JSON body, so it never reaches the e-mail — and a message
212
+ without one sends no such header:
213
+
214
+ ```ts
215
+ await mailer.send({
216
+ to: 'ada@example.com',
217
+ subject: 'Your receipt',
218
+ html: '<p>Thank you for your order.</p>',
219
+ text: 'Thank you for your order.',
220
+ idempotencyKey: 'order-42/receipt',
221
+ });
222
+ ```
223
+
224
+ ```http
225
+ POST /emails HTTP/1.1
226
+ Host: api.resend.com
227
+ Authorization: Bearer re_…
228
+ Content-Type: application/json
229
+ Idempotency-Key: order-42/receipt
230
+
231
+ { "from": "\"Acme\" <noreply@acme.test>", "to": ["ada@example.com"], "subject": "Your receipt", … }
232
+ ```
233
+
234
+ Resend remembers a key for **24 hours**. What it answers a second request
235
+ with the same key:
236
+
237
+ | The second request | Resend answers | `send` |
238
+ | --- | --- | --- |
239
+ | the same message, after the first was accepted | `200`, the first send's `id` | resolves with that `messageId`; nothing more is delivered |
240
+ | a different message — another subject, recipient, attachment | `409 invalid_idempotent_request` | rejects with `MailRefused`: sending it again fails again. Give that e-mail its own key |
241
+ | any message, while the first is still in progress | `409 concurrent_idempotent_requests` | rejects with `MailFailure`: retry later, with the same key |
242
+ | any message, more than 24 hours later | a new send | resolves with a new id: the e-mail is delivered again |
243
+
244
+ Derive the key from what the e-mail is about — `order-42/receipt`,
245
+ `user-7/welcome` — never from the time or a random value, or every retry
246
+ carries a new key. `checkMessage` refuses a key that is not 1 to 256 visible
247
+ ASCII characters, before any request. The transport itself retries nothing:
248
+ the key is what makes **your** retry safe.
249
+
250
+ A job that retries after a failure, with the key it was queued with:
251
+
252
+ ```ts
253
+ import { MailFailure, type MailMessage } from '@nxgt/mail';
254
+ import { createResendMailer } from '@nxgt/mail-resend';
255
+
256
+ const mailer = createResendMailer({ apiKey: process.env.RESEND_API_KEY ?? '', from: 'noreply@acme.test' });
257
+
258
+ // Yours: the queue that runs a job again later — well within 24 hours.
259
+ declare function retryIn(seconds: number): Promise<void>;
260
+
261
+ export async function sendReceiptJob(order: { id: string; email: string }, rendered: Omit<MailMessage, 'to'>): Promise<void> {
262
+ try {
263
+ await mailer.send({ ...rendered, to: order.email, idempotencyKey: `order-${order.id}/receipt` });
264
+ } catch (error) {
265
+ // A timeout, a 5xx, a 409 still in progress: the same key, later, delivers at most once.
266
+ if (error instanceof MailFailure) return retryIn(60);
267
+ throw error; // MailRefused: fix the message, or its key
268
+ }
269
+ }
270
+ ```
271
+
208
272
  ## With the renderer
209
273
 
210
274
  ```ts
@@ -135,6 +135,13 @@ add what the suite does not ask of every transport:
135
135
 
136
136
  - a `400` and a `413` are a `MailRefused`; a `403`, a `429` and a `503` are a `MailFailure`
137
137
  with the status on `cause`, each tried once;
138
+ - a `409 invalid_idempotent_request` — a key sent again with another
139
+ message — is a `MailRefused`, and a `409 concurrent_idempotent_requests` a
140
+ `MailFailure`, both with `status` and `errorName` on `cause`;
141
+ - `idempotencyKey` is sent as the `Idempotency-Key` header, never in the
142
+ body, and no header is sent without one; a retry with the same key
143
+ answers the first id and delivers once, as the local server keeps keys as
144
+ Resend does;
138
145
  - a server that is not listening ends in `MailFailure` —
139
146
  `send: Resend could not be reached` — with the `fetch` error as `cause`;
140
147
  - a server that does not answer within `timeoutMs` ends in `MailFailure`, a
package/docs/roadmap.md CHANGED
@@ -5,12 +5,13 @@ no dates here, and the version something shipped in is the only number.
5
5
 
6
6
  ## Now
7
7
 
8
- - **Attachments** — the `attachments` of a message are sent in Resend's
9
- `attachments`, each as `{ filename, content, content_type }` with the bytes
10
- in base64, encoded with no Node built-in so the transport still runs on an
11
- edge runtime. Resend's `path` (a URL it would fetch) is never used. A
12
- request too large (`413`) is a `MailRefused`, as a `400` or `422` is. Needs
13
- `@nxgt/mail` 0.2. Built, not yet published.
8
+ - **An idempotency key per send** — a message's `idempotencyKey` is sent as
9
+ Resend's `Idempotency-Key` header, so a retry the caller decides, within
10
+ Resend's 24 hours, answers the first send's id and cannot send the same
11
+ e-mail twice. A `409 invalid_idempotent_request` (the key already used for
12
+ another message) is a `MailRefused`; a `409 concurrent_idempotent_requests`
13
+ (the same key still in progress) stays a `MailFailure`. Needs `@nxgt/mail`
14
+ 0.3. Built, not yet published.
14
15
 
15
16
  ## Next
16
17
 
@@ -18,8 +19,6 @@ Nothing yet.
18
19
 
19
20
  ## Later
20
21
 
21
- - **An idempotency key per send** — Resend's `Idempotency-Key` header, so a
22
- retry the caller decides cannot send the same e-mail twice.
23
22
  - **Tags** — Resend's `tags`, to group sends in its dashboard.
24
23
 
25
24
  ## Not planned
@@ -39,6 +38,12 @@ Nothing yet.
39
38
  The last ten, newest first, each with the version it came in. Everything
40
39
  before is in the [CHANGELOG](../CHANGELOG.md).
41
40
 
41
+ - **Attachments, v0.2.0** — the `attachments` of a message are sent in Resend's
42
+ `attachments`, each as `{ filename, content, content_type }` with the bytes
43
+ in base64, encoded with no Node built-in so the transport still runs on an
44
+ edge runtime. Resend's `path` (a URL it would fetch) is never used. A
45
+ request too large (`413`) is a `MailRefused`, as a `400` or `422` is. Needs
46
+ `@nxgt/mail` 0.2.
42
47
  - **A Resend transport over `fetch`, v0.1.0** — `createResendMailer({ apiKey, from })`:
43
48
  one `POST /emails` per message, no SDK, no dependency, no Node built-in, so
44
49
  it runs on an edge runtime too. Each message is checked as every transport
@@ -55,10 +55,15 @@ A `MailRefused`, code `MAIL_REFUSED`.
55
55
  an address in a form it refuses, a header it does not allow, a subject too
56
56
  long, an attachment it will not carry (a `422` `invalid_attachment`, over
57
57
  40 MB once encoded in base64 included, a third larger than the files) — or a
58
- `413`, a request too large for what sits in front of the API.
58
+ `413`, a request too large for what sits in front of the API. Or a `409`
59
+ `invalid_idempotent_request`: the message's `idempotencyKey` was already used,
60
+ within Resend's 24 hours, for a different message — a key per user rather
61
+ than per e-mail, or a template, a variable or a recipient that changed
62
+ between two attempts at the same e-mail.
59
63
 
60
64
  **Why:** Resend will refuse the same message again; retrying it unchanged is
61
- pointless.
65
+ pointless. For a `409`, the first message with that key was taken; this one
66
+ was not, and will not be under that key.
62
67
 
63
68
  **Fix:** read `cause.errorName` and `cause.detail` — Resend's own words:
64
69
 
@@ -75,6 +80,10 @@ try {
75
80
  detail?: string | null;
76
81
  };
77
82
  console.warn(status, errorName, detail); // 422 validation_error Invalid `to` field. …
83
+ if (status === 409 && errorName === 'invalid_idempotent_request') {
84
+ // The key already named another message: it is not one key per e-mail.
85
+ console.warn('idempotencyKey reused for a different message');
86
+ }
78
87
  }
79
88
  throw error;
80
89
  }
@@ -86,6 +95,18 @@ For a message too large (`cause.status` `413`, or a `422` whose `detail`
86
95
  names the size), sending it again fails again: send the file as a signed
87
96
  link in the template instead of an attachment.
88
97
 
98
+ For an `invalid_idempotent_request`, make the key name one e-mail — derived
99
+ from what it is about, the same on every attempt at it — and render that
100
+ e-mail the same way on every attempt:
101
+
102
+ ```ts
103
+ await mailer.send({ ...message, idempotencyKey: `user-${user.id}` }); // ✗ every e-mail to that user
104
+ await mailer.send({ ...message, idempotencyKey: `order-${order.id}/receipt` }); // ✓ this e-mail
105
+ ```
106
+
107
+ A message that was meant to be different — a corrected receipt — is a new
108
+ e-mail: give it a new key (`order-42/receipt-2`).
109
+
89
110
  ### `send: Resend could not take the message`
90
111
 
91
112
  A `MailFailure`, code `MAIL_FAILED`. **Nothing is known to have been sent**:
@@ -98,11 +119,30 @@ a server that accepted it before failing.
98
119
  | --- | --- | --- |
99
120
  | `401` | No key reached Resend | Check the key the process was started with |
100
121
  | `403` | An invalid or revoked key; a sending domain not verified; a test key sending to someone else than the account's owner | Check the key, and verify the `from` domain in Resend |
122
+ | `409` `concurrent_idempotent_requests` | A send with the same `idempotencyKey` is still in progress — a retry, or a second worker, that started before the first attempt finished | Retry later, with the same key: once the first finishes, the retry answers its id |
101
123
  | `429` | The rate limit or the daily quota | Send less often, or from a queue that spaces the sends |
102
124
  | `5xx` | Resend is failing | Retry later |
103
125
 
104
126
  The transport does not retry: a retry is yours to decide, where you can see
105
- it.
127
+ it. With an `idempotencyKey`, a retry of the same message within 24 hours is
128
+ safe: if the first attempt went through, Resend answers its id and delivers
129
+ nothing more.
130
+
131
+ ```ts
132
+ import { MailFailure } from '@nxgt/mail';
133
+
134
+ try {
135
+ await mailer.send({ ...message, idempotencyKey: `order-${order.id}/receipt` });
136
+ } catch (error) {
137
+ const errorName = error instanceof MailFailure
138
+ ? (error.cause as { errorName?: string | null } | undefined)?.errorName
139
+ : undefined;
140
+ if (errorName === 'concurrent_idempotent_requests') {
141
+ // The first attempt is still running: retry later, with the same key.
142
+ }
143
+ throw error;
144
+ }
145
+ ```
106
146
 
107
147
  ### `send: Resend could not be reached`
108
148
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxgt/mail-resend",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "A Resend transport for @nxgt/mail over fetch, with no SDK: it throws the MailFailure and MailRefused of its @nxgt/mail peer, and passes the conformance suite.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -43,11 +43,11 @@
43
43
  "typecheck": "tsc --noEmit"
44
44
  },
45
45
  "devDependencies": {
46
- "@nxgt/mail": "0.2.0",
46
+ "@nxgt/mail": "0.3.0",
47
47
  "@types/bun": "^1.4.2"
48
48
  },
49
49
  "peerDependencies": {
50
- "@nxgt/mail": "^0.2.0",
50
+ "@nxgt/mail": "^0.3.0",
51
51
  "typescript": "^6.0.3"
52
52
  }
53
53
  }