@nxgt/mail-resend 0.1.0 → 0.2.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,9 @@ bun add @nxgt/mail-resend @nxgt/mail
33
33
 
34
34
  Peers, all required:
35
35
 
36
- - `@nxgt/mail` — the port and the errors. One copy in your tree, so
37
- `error instanceof MailFailure` holds.
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`
38
+ holds.
38
39
  - `typescript` (6). Bundler resolution (`"moduleResolution": "bundler"`) is
39
40
  what is supported and tested; `nodenext` is out of contract.
40
41
 
@@ -84,11 +85,35 @@ await mailer.send({
84
85
  comma or an angle bracket in it never names another recipient.
85
86
  - `messageId` is Resend's `id`, or `null` when the answer carries none.
86
87
 
88
+ ### Attachments
89
+
90
+ `attachments` on the message are sent in Resend's `attachments`, each file's
91
+ bytes encoded as base64 — with no Node built-in, so it still runs on an edge
92
+ runtime:
93
+
94
+ ```ts
95
+ const pdf = new Uint8Array(await (await fetch('https://files.acme.test/invoices/42.pdf')).arrayBuffer());
96
+
97
+ await mailer.send({
98
+ to: 'ada@example.com',
99
+ subject: 'Your invoice',
100
+ html: '<p>Your invoice is attached.</p>',
101
+ text: 'Your invoice is attached.',
102
+ attachments: [{ filename: 'invoice-42.pdf', content: pdf, contentType: 'application/pdf' }],
103
+ });
104
+ ```
105
+
106
+ Resend's `path` (a URL it would fetch) is never used: an attachment is bytes
107
+ your code already holds. Resend takes at most **40 MB per e-mail, after
108
+ base64** — a third larger than the files — and refuses more (`MailRefused`).
109
+ **A large or sensitive file is a signed link in the template**, not an
110
+ attachment. See [Setting up — what a message becomes](docs/guide/setup.md#what-a-message-becomes).
111
+
87
112
  ### Errors — a refusal or a failure
88
113
 
89
114
  | When | Throws | `cause` |
90
115
  | --- | --- | --- |
91
- | `400`, `422` — Resend refuses the message | `MailRefused` — `send: Resend refused the message` | an `Error` with `status`, `errorName` and Resend's `detail` |
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` |
92
117
  | `401`, `403`, `429`, `5xx`, any other status | `MailFailure` — `send: Resend could not take the message` | the same |
93
118
  | A network error | `MailFailure` — `send: Resend could not be reached` | the `fetch` error |
94
119
  | No answer within `timeoutMs` | `MailFailure` — `send: Resend did not answer within <timeoutMs> ms` | the `TimeoutError` |
@@ -130,6 +155,11 @@ the first send.
130
155
  **A key read from a file keeps its line break.** A key holding whitespace is
131
156
  refused at wiring; trim it.
132
157
 
158
+ **Attachments count against Resend's 40 MB after base64.** A 30 MB file
159
+ is at it once encoded, before the rest of the body; Resend answers `422`
160
+ `invalid_attachment`. The whole request is also held in memory while it is
161
+ sent; past a few megabytes, send a signed link.
162
+
133
163
  **A `403` is a failure, not a refusal.** An invalid key or an unverified
134
164
  sending domain refuses every message alike: it is the wiring that is wrong.
135
165
 
@@ -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;AA4HD,6EAA6E;AAC7E,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,mBAAmB,GAAG,MAAM,CA8EvE"}
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"}
package/dist/index.js CHANGED
@@ -32,6 +32,13 @@ async function readAnswer(response, signal) {
32
32
  const body = await beforeAbort(response.json(), signal).then((value) => value, () => null);
33
33
  return typeof body === "object" && body !== null ? body : {};
34
34
  }
35
+ function base64Of(bytes) {
36
+ let binary = "";
37
+ for (let start = 0;start < bytes.length; start += 32768) {
38
+ binary += String.fromCharCode(...bytes.subarray(start, start + 32768));
39
+ }
40
+ return btoa(binary);
41
+ }
35
42
  var MAX_TIMEOUT_MS = 2147483647;
36
43
  var text = (value) => typeof value === "string" && value !== "" ? value : null;
37
44
  function checkOptions(options) {
@@ -86,7 +93,14 @@ function createResendMailer(options) {
86
93
  ...message.replyTo === undefined ? {} : {
87
94
  reply_to: formatAddress(message.replyTo)
88
95
  },
89
- ...message.headers === undefined ? {} : { headers: message.headers }
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
+ }
90
104
  };
91
105
  const signal = AbortSignal.timeout(timeoutMs);
92
106
  let response;
@@ -112,7 +126,7 @@ function createResendMailer(options) {
112
126
  if (response.ok)
113
127
  return { messageId: text(answer.id) };
114
128
  const cause = resendAnswer(response.status, text(answer.name), text(answer.message));
115
- if (response.status === 400 || response.status === 422) {
129
+ if (response.status === 400 || response.status === 413 || response.status === 422) {
116
130
  throw new MailRefused("send: Resend refused the message", { cause });
117
131
  }
118
132
  throw new MailFailure("send: Resend could not take the message", {
@@ -126,5 +140,5 @@ export {
126
140
  formatAddress
127
141
  };
128
142
 
129
- //# debugId=900CD1C929776F6D64756E2164756E21
143
+ //# debugId=DBE3D2F49402DA0764756E2164756E21
130
144
  //# 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/** 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};\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; anything else — a key\n\t\t\t// refused, a rate limit, an outage — is Resend failing to take it.\n\t\t\tif (response.status === 400 || response.status === 422) {\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.\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"
6
6
  ],
7
- "mappings": "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",
8
- "debugId": "900CD1C929776F6D64756E2164756E21",
7
+ "mappings": "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",
8
+ "debugId": "DBE3D2F49402DA0764756E2164756E21",
9
9
  "names": []
10
10
  }
@@ -45,7 +45,8 @@ Resend answers an error as `{ statusCode, name, message }`:
45
45
  | Resend answers | Typical `name` | Throws |
46
46
  | --- | --- | --- |
47
47
  | `400` | `validation_error` | `MailRefused` |
48
- | `422` | `validation_error`, `missing_required_field` | `MailRefused` |
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
+ | `422` | `validation_error`, `missing_required_field`, `invalid_attachment` | `MailRefused` |
49
50
  | `401`, `403` | `missing_api_key`, `invalid_api_key`, an unverified domain | `MailFailure` |
50
51
  | `429` | `rate_limit_exceeded`, `daily_quota_exceeded` | `MailFailure` |
51
52
  | `5xx` | `internal_server_error` | `MailFailure` |
@@ -95,7 +96,7 @@ hold one.
95
96
 
96
97
  | `message` | Class | When |
97
98
  | --- | --- | --- |
98
- | `send: Resend refused the message` | `MailRefused` | A `400` or `422` |
99
+ | `send: Resend refused the message` | `MailRefused` | A `400`, `413` or `422` |
99
100
  | `send: Resend could not take the message` | `MailFailure` | Any other answer that is not `2xx` |
100
101
  | `send: Resend could not be reached` | `MailFailure` | `fetch` threw |
101
102
  | `send: Resend did not answer within <timeoutMs> ms` | `MailFailure` | The timeout aborted the request |
@@ -152,6 +152,7 @@ await mailer.send({
152
152
  subject: 'Hi',
153
153
  html: '<p>Hi</p>',
154
154
  text: 'Hi',
155
+ attachments: [{ filename: 'hello.txt', content: new TextEncoder().encode('Hello'), contentType: 'text/plain' }],
155
156
  });
156
157
  ```
157
158
 
@@ -168,7 +169,10 @@ Content-Type: application/json
168
169
  "html": "<p>Hi</p>",
169
170
  "text": "Hi",
170
171
  "reply_to": "support@acme.test",
171
- "headers": { "List-Unsubscribe": "<https://acme.test/u>" }
172
+ "headers": { "List-Unsubscribe": "<https://acme.test/u>" },
173
+ "attachments": [
174
+ { "filename": "hello.txt", "content": "SGVsbG8=", "content_type": "text/plain" }
175
+ ]
172
176
  }
173
177
  ```
174
178
 
@@ -185,6 +189,15 @@ Content-Type: application/json
185
189
 
186
190
  - `replyTo` is sent as `reply_to`, Resend's name for it; `reply_to` and
187
191
  `headers` are left out when the message has none.
192
+ - Each attachment is sent as `{ filename, content, content_type }`: its bytes
193
+ as base64, encoded in slices with `btoa` — no `Buffer`, so it runs on an
194
+ edge runtime — and its type as `content_type`, Resend's name for it.
195
+ Resend's `path`, a URL it would fetch, is never used. `attachments` is left
196
+ out when the list is empty.
197
+ - Resend takes at most 40 MB per e-mail **after** base64, which makes a file
198
+ a third larger; over it, the answer is a `4xx` and `send` throws
199
+ `MailRefused`. A large or sensitive file is a signed link in the template
200
+ instead.
188
201
  - Before any of it, `checkMessage` from `@nxgt/mail` refuses what no transport
189
202
  hands over. Its messages are listed in
190
203
  [`@nxgt/mail`'s troubleshooting](https://github.com/softistx/nxgt-mail/blob/develop/packages/mail/docs/troubleshooting.md#sending).
@@ -49,8 +49,25 @@ export function startResend() {
49
49
  if (fault === 'outage') return answer(503, 'internal_server_error', 'Service unavailable.');
50
50
  if (fault === 'refusal') return answer(422, 'validation_error', 'Invalid `to` field.');
51
51
 
52
- const body = (await request.json()) as { to: string[]; subject: string; html: string; text: string };
53
- delivered.push({ to: body.to.map(addressOf), subject: body.subject, html: body.html, text: body.text });
52
+ const body = (await request.json()) as {
53
+ to: string[];
54
+ subject: string;
55
+ html: string;
56
+ text: string;
57
+ attachments?: { filename: string; content: string; content_type: string }[];
58
+ };
59
+ delivered.push({
60
+ to: body.to.map(addressOf),
61
+ subject: body.subject,
62
+ html: body.html,
63
+ text: body.text,
64
+ // As Resend reads them: the content is base64, decoded back to bytes.
65
+ attachments: (body.attachments ?? []).map((file) => ({
66
+ filename: file.filename,
67
+ content: Uint8Array.from(atob(file.content), (char) => char.charCodeAt(0)),
68
+ contentType: file.content_type,
69
+ })),
70
+ });
54
71
  return Response.json({ id: `resend-${delivered.length}` });
55
72
  },
56
73
  });
@@ -102,10 +119,11 @@ describeMailer({
102
119
  });
103
120
  ```
104
121
 
105
- All eleven cases pass: a send answers `SentMail`, the message arrives byte for
122
+ All thirteen cases pass: a send answers `SentMail`, the message arrives byte for
106
123
  byte (accents, an emoji, `&amp;` in a link), every recipient is delivered to,
107
- a hostile name reaches only its own address, the refusals — a `Bcc` among the
108
- custom headers included — and the three
124
+ a hostile name reaches only its own address, an attachment arrives byte for
125
+ byte with its name and type, the refusals — a `Bcc` among the custom headers
126
+ and an attachment named with a path included — and the three
109
127
  failure cases — an outage is a `MailFailure` with its `cause` and one attempt,
110
128
  a refusal a `MailRefused`, and the next send goes through.
111
129
 
@@ -115,7 +133,7 @@ The package's own specs
115
133
  ([`src/index.spec.ts`](https://github.com/softistx/nxgt-mail/blob/develop/packages/mail-resend/src/index.spec.ts))
116
134
  add what the suite does not ask of every transport:
117
135
 
118
- - a `400` is a `MailRefused`; a `403`, a `429` and a `503` are a `MailFailure`
136
+ - a `400` and a `413` are a `MailRefused`; a `403`, a `429` and a `503` are a `MailFailure`
119
137
  with the status on `cause`, each tried once;
120
138
  - a server that is not listening ends in `MailFailure` —
121
139
  `send: Resend could not be reached` — with the `fetch` error as `cause`;
@@ -127,6 +145,9 @@ add what the suite does not ask of every transport:
127
145
  address or what Resend said;
128
146
  - the request: `POST /emails`, the bearer key, JSON, a quoted name,
129
147
  `reply_to` and `headers`;
148
+ - each attachment as `{ filename, content, content_type }`, the content
149
+ base64 — a file larger than one slice of the encoder included — and no
150
+ `attachments` for an empty list;
130
151
  - a `2xx` with no id, or with a body that is not JSON, answers
131
152
  `{ messageId: null }`;
132
153
  - every `TypeError` at wiring, a `timeoutMs` above `2147483647` included.
package/docs/roadmap.md CHANGED
@@ -5,7 +5,12 @@ no dates here, and the version something shipped in is the only number.
5
5
 
6
6
  ## Now
7
7
 
8
- Nothing between releases.
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.
9
14
 
10
15
  ## Next
11
16
 
@@ -26,8 +31,8 @@ Nothing yet.
26
31
  send the same e-mail twice.
27
32
  - **A transport's own error class** — it throws `@nxgt/mail`'s `MailFailure`
28
33
  and `MailRefused`, so `instanceof` holds whichever transport you wire.
29
- - **Scheduling, batch sending and attachments** — a message is three strings
30
- sent now; the port has no room for more.
34
+ - **Scheduling and batch sending** — a message is sent now, one per request;
35
+ the port has no room for more.
31
36
 
32
37
  ## Shipped
33
38
 
@@ -51,8 +51,11 @@ A `send: …` message not on this page comes from `checkMessage` in
51
51
 
52
52
  A `MailRefused`, code `MAIL_REFUSED`.
53
53
 
54
- **When:** Resend answered `400` or `422`: a field it does not accept — an
55
- address in a form it refuses, a header it does not allow, a subject too long.
54
+ **When:** Resend answered `400`, `413` or `422`: a field it does not accept —
55
+ an address in a form it refuses, a header it does not allow, a subject too
56
+ long, an attachment it will not carry (a `422` `invalid_attachment`, over
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.
56
59
 
57
60
  **Why:** Resend will refuse the same message again; retrying it unchanged is
58
61
  pointless.
@@ -79,6 +82,10 @@ try {
79
82
 
80
83
  `detail` may quote an address: keep it out of logs that must not hold one.
81
84
 
85
+ For a message too large (`cause.status` `413`, or a `422` whose `detail`
86
+ names the size), sending it again fails again: send the file as a signed
87
+ link in the template instead of an attachment.
88
+
82
89
  ### `send: Resend could not take the message`
83
90
 
84
91
  A `MailFailure`, code `MAIL_FAILED`. **Nothing is known to have been sent**:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxgt/mail-resend",
3
- "version": "0.1.0",
3
+ "version": "0.2.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.1.0",
46
+ "@nxgt/mail": "0.2.0",
47
47
  "@types/bun": "^1.4.2"
48
48
  },
49
49
  "peerDependencies": {
50
- "@nxgt/mail": "^0.1.0",
50
+ "@nxgt/mail": "^0.2.0",
51
51
  "typescript": "^6.0.3"
52
52
  }
53
53
  }