@nxgt/mail-resend 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Steve Tsala
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,174 @@
1
+ # @nxgt/mail-resend
2
+
3
+ A [Resend](https://resend.com) transport for
4
+ [`@nxgt/mail`](https://github.com/softistx/nxgt-mail/tree/develop/packages/mail),
5
+ over `fetch`, with no SDK and no dependency. It throws the `MailFailure` and
6
+ `MailRefused` of its `@nxgt/mail` peer, so `instanceof` holds whichever
7
+ transport is wired, and it passes the `@nxgt/mail/conformance` suite against a
8
+ local server answering as Resend's API does.
9
+
10
+ ```ts
11
+ import { createResendMailer } from '@nxgt/mail-resend';
12
+
13
+ const mailer = createResendMailer({
14
+ apiKey: process.env.RESEND_API_KEY ?? '',
15
+ from: { name: 'Acme', address: 'noreply@acme.test' },
16
+ });
17
+
18
+ const { messageId } = await mailer.send({
19
+ to: 'ada@example.com',
20
+ subject: 'Confirm your address',
21
+ html: '<p>…</p>',
22
+ text: '…',
23
+ }); // Resend's id — or it throws
24
+ ```
25
+
26
+ > **0.x.** A minor version may still change the surface; the changelog says how.
27
+
28
+ ## Install
29
+
30
+ ```sh
31
+ bun add @nxgt/mail-resend @nxgt/mail
32
+ ```
33
+
34
+ Peers, all required:
35
+
36
+ - `@nxgt/mail` — the port and the errors. One copy in your tree, so
37
+ `error instanceof MailFailure` holds.
38
+ - `typescript` (6). Bundler resolution (`"moduleResolution": "bundler"`) is
39
+ what is supported and tested; `nodenext` is out of contract.
40
+
41
+ It runs wherever `fetch` does — Node, Bun, Deno, an edge runtime — and imports
42
+ no Node built-in.
43
+
44
+ ## Exports
45
+
46
+ | Export | What it is |
47
+ | --- | --- |
48
+ | `createResendMailer(options)` | A `Mailer` that sends each message with `POST /emails` |
49
+ | `ResendMailerOptions` | `{ apiKey, from?, baseUrl?, fetch?, timeoutMs? }` |
50
+ | `formatAddress(address)` | An `Address` as Resend reads it: bare, or `"name" <address>` with the name quoted |
51
+
52
+ ## Usage
53
+
54
+ ### Options
55
+
56
+ | Option | Type | Default | |
57
+ | --- | --- | --- | --- |
58
+ | `apiKey` | `string` | required | The Resend API key, `re_…` |
59
+ | `from` | `Address` | none | The sender of a message that names none |
60
+ | `baseUrl` | `string` | `https://api.resend.com` | Where `POST /emails` goes: a proxy, or a test server |
61
+ | `fetch` | `(url, init) => Promise<Response>` | the global `fetch` | For a proxy agent, or a test |
62
+ | `timeoutMs` | `number` | `30000` | A send taking longer fails with `MailFailure`, whatever `fetch` does with the signal. At most `2147483647` |
63
+
64
+ ```ts
65
+ import { createResendMailer } from '@nxgt/mail-resend';
66
+
67
+ const mailer = createResendMailer({ apiKey: process.env.RESEND_API_KEY ?? '', timeoutMs: 10_000 });
68
+
69
+ await mailer.send({
70
+ to: [{ name: 'Doe, John', address: 'john@example.com' }],
71
+ from: 'billing@acme.test', // required here: this mailer has no default
72
+ replyTo: 'support@acme.test', // sent as reply_to
73
+ headers: { 'List-Unsubscribe': '<https://acme.test/unsubscribe>' },
74
+ subject: 'Your invoice',
75
+ html: '<p>…</p>',
76
+ text: '…',
77
+ });
78
+ ```
79
+
80
+ - Every message is checked by `checkMessage` from `@nxgt/mail` first: the
81
+ same refusals, with the same messages, as every transport.
82
+ - A message's own `from` wins over the default.
83
+ - A name is sent as a quoted string — `"Doe, John" <john@example.com>` — so a
84
+ comma or an angle bracket in it never names another recipient.
85
+ - `messageId` is Resend's `id`, or `null` when the answer carries none.
86
+
87
+ ### Errors — a refusal or a failure
88
+
89
+ | When | Throws | `cause` |
90
+ | --- | --- | --- |
91
+ | `400`, `422` — Resend refuses the message | `MailRefused` — `send: Resend refused the message` | an `Error` with `status`, `errorName` and Resend's `detail` |
92
+ | `401`, `403`, `429`, `5xx`, any other status | `MailFailure` — `send: Resend could not take the message` | the same |
93
+ | A network error | `MailFailure` — `send: Resend could not be reached` | the `fetch` error |
94
+ | No answer within `timeoutMs` | `MailFailure` — `send: Resend did not answer within <timeoutMs> ms` | the `TimeoutError` |
95
+ | No sender, on the message or as a default | `MailRefused` — `send: from is missing — give the message a from, or createResendMailer a default one` | — |
96
+ | A bad option | `TypeError` from `createResendMailer` | — |
97
+
98
+ ```ts
99
+ import { MailFailure, MailRefused } from '@nxgt/mail';
100
+
101
+ try {
102
+ await mailer.send(message);
103
+ } catch (error) {
104
+ if (error instanceof MailRefused) {
105
+ // sending it again unchanged fails again: fix the address or the content
106
+ } else if (error instanceof MailFailure) {
107
+ // nothing is known to have been sent: a bad key, a rate limit, an outage — retry later, from a queue
108
+ }
109
+ throw error;
110
+ }
111
+ ```
112
+
113
+ A message reports a shape, never a value: never the key, an address or what
114
+ Resend said — that is on `cause.detail`. Nothing is retried. Every case is in
115
+ [Errors](docs/guide/errors.md).
116
+
117
+ ### Testing
118
+
119
+ In an application's tests, use `createMemoryMailer()` from `@nxgt/mail`. To
120
+ test this transport, see [Testing](docs/guide/testing.md): a local Bun server
121
+ answering as Resend does, `baseUrl` pointed at it, and `describeMailer`.
122
+
123
+ ## Traps
124
+
125
+ **Read the key where the process starts, and decide its absence there.**
126
+ `apiKey: process.env.RESEND_API_KEY` does not compile (`string | undefined`);
127
+ `?? ''` makes an unset variable a `TypeError` at start-up, not a failure at
128
+ the first send.
129
+
130
+ **A key read from a file keeps its line break.** A key holding whitespace is
131
+ refused at wiring; trim it.
132
+
133
+ **A `403` is a failure, not a refusal.** An invalid key or an unverified
134
+ sending domain refuses every message alike: it is the wiring that is wrong.
135
+
136
+ **A `429` is a failure.** Resend rate-limits per second; slow down or queue.
137
+ The transport does not wait and retry for you.
138
+
139
+ **A timeout does not mean nothing was sent.** After `timeoutMs`, or a
140
+ connection dropped mid-request, Resend may have accepted the e-mail: a retry
141
+ can send it twice. Weigh that before retrying.
142
+
143
+ ## Type safety, counted
144
+
145
+ **7 plausible mistakes, 7 refused** at compile time, each measured by a
146
+ `@ts-expect-error` in
147
+ [`test/types/refusals.ts`](https://github.com/softistx/nxgt-mail/blob/develop/packages/mail-resend/test/types/refusals.ts)
148
+ that fails the typecheck the moment it stops holding:
149
+
150
+ 1. No `apiKey`.
151
+ 2. An `apiKey` that may be `undefined` — `process.env.RESEND_API_KEY` as is.
152
+ 3. `timeoutMs` written as a duration (`'30s'`).
153
+ 4. A default `from` without its `address`.
154
+ 5. Resend's wire format in the options (`reply_to`): a message carries its
155
+ `replyTo`.
156
+ 6. A `retries` option: the transport tries once.
157
+ 7. `messageId` read as a `string`: it is `string | null`.
158
+
159
+ The same file holds the calls that must keep compiling — among them a `fetch`
160
+ written as a plain function.
161
+
162
+ ## Documentation
163
+
164
+ - [The guides](docs/README.md) — the options, the errors, testing.
165
+ - [Troubleshooting](docs/troubleshooting.md) — an error message, its cause and
166
+ its fix.
167
+ - [Roadmap](docs/roadmap.md) — what is next, and what is deliberately not
168
+ planned.
169
+ - [Vocabulary](https://github.com/softistx/nxgt-mail/blob/develop/docs/vocabulary.md)
170
+ — the words these pages use, defined once.
171
+
172
+ ## Licence
173
+
174
+ MIT
@@ -0,0 +1,37 @@
1
+ /**
2
+ * `@nxgt/mail-resend` — a Resend transport for `@nxgt/mail`, over `fetch`,
3
+ * with no SDK.
4
+ *
5
+ * ```ts
6
+ * import { createResendMailer } from '@nxgt/mail-resend';
7
+ *
8
+ * const mailer = createResendMailer({
9
+ * apiKey: process.env.RESEND_API_KEY ?? '',
10
+ * from: { name: 'Acme', address: 'noreply@acme.test' },
11
+ * });
12
+ * ```
13
+ *
14
+ * **A failure throws** the `MailFailure` or `MailRefused` of the `@nxgt/mail`
15
+ * peer, what Resend answered as the `cause`. Nothing is retried.
16
+ */
17
+ import { type Address, type Mailer } from '@nxgt/mail';
18
+ export interface ResendMailerOptions {
19
+ /** The API key, `re_…`. */
20
+ readonly apiKey: string;
21
+ /** The sender of a message that names none. Without it, such a message is refused. */
22
+ readonly from?: Address;
23
+ /** Default `https://api.resend.com`. */
24
+ readonly baseUrl?: string;
25
+ /** Default the global `fetch`. For a proxy, or a test. */
26
+ readonly fetch?: (url: string, init: RequestInit) => Promise<Response>;
27
+ /** How long a send may take before it fails. Default `30000`. */
28
+ readonly timeoutMs?: number;
29
+ }
30
+ /**
31
+ * An address as Resend reads it: bare, or `"name" <address>` — the name a
32
+ * quoted string, so a comma or an angle bracket in it names no one else.
33
+ */
34
+ export declare function formatAddress(address: Address): string;
35
+ /** Creates a {@link Mailer} that sends each message through Resend's API. */
36
+ export declare function createResendMailer(options: ResendMailerOptions): Mailer;
37
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +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"}
package/dist/index.js ADDED
@@ -0,0 +1,130 @@
1
+ // src/index.ts
2
+ import {
3
+ checkMessage,
4
+ MailFailure,
5
+ MailRefused
6
+ } from "@nxgt/mail";
7
+ function formatAddress(address) {
8
+ if (typeof address === "string")
9
+ return address;
10
+ const name = address.name.replace(/[\\"]/g, (char) => `\\${char}`);
11
+ return `"${name}" <${address.address}>`;
12
+ }
13
+ function resendAnswer(status, errorName, detail) {
14
+ return Object.assign(new Error(`Resend answered ${status}${errorName === null ? "" : ` ${errorName}`}`), { status, errorName, detail });
15
+ }
16
+ function beforeAbort(work, signal) {
17
+ return new Promise((resolve, reject) => {
18
+ const abort = () => reject(signal.reason);
19
+ if (signal.aborted)
20
+ return abort();
21
+ signal.addEventListener("abort", abort, { once: true });
22
+ work.then((value) => {
23
+ signal.removeEventListener("abort", abort);
24
+ resolve(value);
25
+ }, (error) => {
26
+ signal.removeEventListener("abort", abort);
27
+ reject(error);
28
+ });
29
+ });
30
+ }
31
+ async function readAnswer(response, signal) {
32
+ const body = await beforeAbort(response.json(), signal).then((value) => value, () => null);
33
+ return typeof body === "object" && body !== null ? body : {};
34
+ }
35
+ var MAX_TIMEOUT_MS = 2147483647;
36
+ var text = (value) => typeof value === "string" && value !== "" ? value : null;
37
+ function checkOptions(options) {
38
+ if (typeof options !== "object" || options === null) {
39
+ throw new TypeError("createResendMailer: options must be an object, as { apiKey }");
40
+ }
41
+ if (typeof options.apiKey !== "string" || options.apiKey.trim() === "") {
42
+ throw new TypeError("createResendMailer: apiKey must be a Resend API key — is the environment variable set?");
43
+ }
44
+ if (/\s/.test(options.apiKey)) {
45
+ throw new TypeError("createResendMailer: apiKey holds whitespace — trim the value it was read from");
46
+ }
47
+ if (options.baseUrl !== undefined && (typeof options.baseUrl !== "string" || !/^https?:\/\/[^/]/.test(options.baseUrl))) {
48
+ throw new TypeError("createResendMailer: baseUrl must be an http: or https: URL");
49
+ }
50
+ if (options.fetch !== undefined && typeof options.fetch !== "function") {
51
+ throw new TypeError("createResendMailer: fetch must be a function");
52
+ }
53
+ if (options.timeoutMs !== undefined && !(Number.isInteger(options.timeoutMs) && options.timeoutMs > 0)) {
54
+ throw new TypeError("createResendMailer: timeoutMs must be a positive integer");
55
+ }
56
+ if (options.timeoutMs !== undefined && options.timeoutMs > MAX_TIMEOUT_MS) {
57
+ throw new TypeError(`createResendMailer: timeoutMs must be at most ${MAX_TIMEOUT_MS} — a longer timer fires at once`);
58
+ }
59
+ if (options.from !== undefined) {
60
+ try {
61
+ checkMessage({ to: options.from, subject: "", html: "", text: "" });
62
+ } catch {
63
+ throw new TypeError("createResendMailer: from must be an e-mail address, as noreply@example.com or { name, address }");
64
+ }
65
+ }
66
+ }
67
+ function createResendMailer(options) {
68
+ checkOptions(options);
69
+ const endpoint = `${(options.baseUrl ?? "https://api.resend.com").replace(/\/+$/, "")}/emails`;
70
+ const post = options.fetch ?? ((url, init) => globalThis.fetch(url, init));
71
+ const timeoutMs = options.timeoutMs ?? 30000;
72
+ return {
73
+ async send(message) {
74
+ checkMessage(message);
75
+ const sender = message.from ?? options.from;
76
+ if (sender === undefined) {
77
+ throw new MailRefused("send: from is missing — give the message a from, or createResendMailer a default one");
78
+ }
79
+ const to = Array.isArray(message.to) ? message.to : [message.to];
80
+ const body = {
81
+ from: formatAddress(sender),
82
+ to: to.map(formatAddress),
83
+ subject: message.subject,
84
+ html: message.html,
85
+ text: message.text,
86
+ ...message.replyTo === undefined ? {} : {
87
+ reply_to: formatAddress(message.replyTo)
88
+ },
89
+ ...message.headers === undefined ? {} : { headers: message.headers }
90
+ };
91
+ const signal = AbortSignal.timeout(timeoutMs);
92
+ let response;
93
+ try {
94
+ response = await beforeAbort(post(endpoint, {
95
+ method: "POST",
96
+ headers: {
97
+ authorization: `Bearer ${options.apiKey}`,
98
+ "content-type": "application/json"
99
+ },
100
+ body: JSON.stringify(body),
101
+ signal
102
+ }), signal);
103
+ } catch (error) {
104
+ if (error instanceof DOMException && error.name === "TimeoutError") {
105
+ throw new MailFailure(`send: Resend did not answer within ${timeoutMs} ms`, { cause: error });
106
+ }
107
+ throw new MailFailure("send: Resend could not be reached", {
108
+ cause: error
109
+ });
110
+ }
111
+ const answer = await readAnswer(response, signal);
112
+ if (response.ok)
113
+ return { messageId: text(answer.id) };
114
+ const cause = resendAnswer(response.status, text(answer.name), text(answer.message));
115
+ if (response.status === 400 || response.status === 422) {
116
+ throw new MailRefused("send: Resend refused the message", { cause });
117
+ }
118
+ throw new MailFailure("send: Resend could not take the message", {
119
+ cause
120
+ });
121
+ }
122
+ };
123
+ }
124
+ export {
125
+ createResendMailer,
126
+ formatAddress
127
+ };
128
+
129
+ //# debugId=900CD1C929776F6D64756E2164756E21
130
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,10 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/index.ts"],
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"
6
+ ],
7
+ "mappings": "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",
8
+ "debugId": "900CD1C929776F6D64756E2164756E21",
9
+ "names": []
10
+ }
package/docs/README.md ADDED
@@ -0,0 +1,14 @@
1
+ # @nxgt/mail-resend — documentation
2
+
3
+ The [README](../README.md) shows that it works; these pages show how, one area
4
+ at a time, with an example for every option. The words they use — e-mail,
5
+ mailer, transport, hand-over, refusal, failure — are defined once, in the
6
+ [vocabulary](https://github.com/softistx/nxgt-mail/blob/develop/docs/vocabulary.md).
7
+
8
+ | Page | Read it when |
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 |
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
+ | [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
+ | [Troubleshooting](troubleshooting.md) | You have an error message and want its cause and its fix |
14
+ | [Roadmap](roadmap.md) | You want to know what is coming, what shipped, and what is deliberately not planned |
@@ -0,0 +1,127 @@
1
+ # Errors
2
+
3
+ `send` resolves only once Resend has answered `2xx`. Otherwise it rejects with
4
+ one of the two classes of its `@nxgt/mail` peer — this package defines no
5
+ error class, so `error instanceof MailFailure` holds whichever transport the
6
+ application wires:
7
+
8
+ - **`MailRefused`** (`code: 'MAIL_REFUSED'`) — Resend refused the message
9
+ itself. Sending it again unchanged fails again.
10
+ - **`MailFailure`** (`code: 'MAIL_FAILED'`) — Resend could not take it:
11
+ unreachable, too slow, rate-limited, or the key refused. Nothing is known
12
+ to have been sent: after a timeout or a dropped connection, Resend may have
13
+ accepted it all the same.
14
+
15
+ Nothing is retried. Whether and when to retry is yours to decide, where you
16
+ can see it.
17
+
18
+ ```ts
19
+ import { MailError, type MailErrorCode, type Mailer, type MailMessage } from '@nxgt/mail';
20
+
21
+ function statusOf(code: MailErrorCode): number {
22
+ switch (code) {
23
+ case 'MAIL_FAILED':
24
+ return 503;
25
+ case 'MAIL_REFUSED':
26
+ return 422;
27
+ }
28
+ }
29
+
30
+ export async function sendOrRespond(mailer: Mailer, message: MailMessage): Promise<Response> {
31
+ try {
32
+ await mailer.send(message);
33
+ return new Response(null, { status: 202 });
34
+ } catch (error) {
35
+ if (!(error instanceof MailError)) throw error;
36
+ return Response.json({ code: error.code }, { status: statusOf(error.code) });
37
+ }
38
+ }
39
+ ```
40
+
41
+ ## Which answer is which
42
+
43
+ Resend answers an error as `{ statusCode, name, message }`:
44
+
45
+ | Resend answers | Typical `name` | Throws |
46
+ | --- | --- | --- |
47
+ | `400` | `validation_error` | `MailRefused` |
48
+ | `422` | `validation_error`, `missing_required_field` | `MailRefused` |
49
+ | `401`, `403` | `missing_api_key`, `invalid_api_key`, an unverified domain | `MailFailure` |
50
+ | `429` | `rate_limit_exceeded`, `daily_quota_exceeded` | `MailFailure` |
51
+ | `5xx` | `internal_server_error` | `MailFailure` |
52
+ | any other status | — | `MailFailure` |
53
+ | no answer: DNS, a refused connection, TLS | — | `MailFailure` — `send: Resend could not be reached` |
54
+ | no answer within `timeoutMs` | — | `MailFailure` — `send: Resend did not answer within <timeoutMs> ms` |
55
+
56
+ A `401` or `403` is a failure although it is a `4xx`: a bad key or an
57
+ unverified domain refuses every message alike. It is the wiring that is
58
+ wrong, not the message.
59
+
60
+ ## What `cause` holds
61
+
62
+ For an answer, `cause` is a plain `Error` — the transport defines no class —
63
+ carrying what Resend said:
64
+
65
+ | Property | Type | Example |
66
+ | --- | --- | --- |
67
+ | `message` | `string` | `Resend answered 422 validation_error` |
68
+ | `status` | `number` | `422` |
69
+ | `errorName` | `string \| null` | `'validation_error'`, or `null` when the body had none |
70
+ | `detail` | `string \| null` | Resend's own `message`, or `null` |
71
+
72
+ For no answer, `cause` is the `fetch` error — a `TypeError` from the runtime,
73
+ or the `TimeoutError` of the timeout.
74
+
75
+ ```ts
76
+ import { MailError } from '@nxgt/mail';
77
+
78
+ try {
79
+ await mailer.send(message);
80
+ } catch (error) {
81
+ if (error instanceof MailError) {
82
+ const cause = error.cause as { status?: number; errorName?: string | null };
83
+ logger.warn({ code: error.code, status: cause.status, resend: cause.errorName }, error.message);
84
+ }
85
+ throw error;
86
+ }
87
+ ```
88
+
89
+ Neither message holds a value: never the key, an address, a subject, or what
90
+ Resend said. Resend's own message can quote an address, so it is kept on
91
+ `detail` and never put in a `message` — log `detail` only where your logs may
92
+ hold one.
93
+
94
+ ## The messages
95
+
96
+ | `message` | Class | When |
97
+ | --- | --- | --- |
98
+ | `send: Resend refused the message` | `MailRefused` | A `400` or `422` |
99
+ | `send: Resend could not take the message` | `MailFailure` | Any other answer that is not `2xx` |
100
+ | `send: Resend could not be reached` | `MailFailure` | `fetch` threw |
101
+ | `send: Resend did not answer within <timeoutMs> ms` | `MailFailure` | The timeout aborted the request |
102
+ | `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 |
103
+ | `send: …` from `checkMessage` | `MailRefused` | A message no transport hands over — see [`@nxgt/mail`'s troubleshooting](https://github.com/softistx/nxgt-mail/blob/develop/packages/mail/docs/troubleshooting.md#sending) |
104
+
105
+ ## Wiring — a `TypeError`
106
+
107
+ A bad option is a mistake in how the application was put together, thrown
108
+ when `createResendMailer` is called, never at the first send. No message
109
+ prints the value:
110
+
111
+ | `message` | When |
112
+ | --- | --- |
113
+ | `createResendMailer: options must be an object, as { apiKey }` | `createResendMailer()`, or the key passed on its own |
114
+ | `createResendMailer: apiKey must be a Resend API key — is the environment variable set?` | No `apiKey`, an empty or a blank one |
115
+ | `createResendMailer: apiKey holds whitespace — trim the value it was read from` | A key with a space or a line break in it |
116
+ | `createResendMailer: baseUrl must be an http: or https: URL` | `baseUrl` without its scheme, or not a string |
117
+ | `createResendMailer: fetch must be a function` | `fetch` that is not a function |
118
+ | `createResendMailer: timeoutMs must be a positive integer` | `0`, a negative number, a fraction |
119
+ | `createResendMailer: timeoutMs must be at most 2147483647 — a longer timer fires at once` | A `timeoutMs` above 2³¹ − 1 ms, about 24.8 days |
120
+ | `createResendMailer: from must be an e-mail address, as noreply@example.com or { name, address }` | A default `from` that is not an address — `'Acme <noreply@acme.test>'` included |
121
+
122
+ ## See also
123
+
124
+ - [Troubleshooting](../troubleshooting.md) — each message, its cause and its
125
+ fix.
126
+ - [`@nxgt/mail` — sending](https://github.com/softistx/nxgt-mail/blob/develop/packages/mail/docs/guide/sending.md)
127
+ — the errors, from the caller's side.
@@ -0,0 +1,214 @@
1
+ # Setting up
2
+
3
+ `createResendMailer` sends each message with one `POST /emails` to Resend's
4
+ API, over `fetch`: no SDK, no dependency, no Node built-in. It adds the
5
+ `Mailer` contract of `@nxgt/mail` — the same refusals as every transport, the
6
+ two errors, no retry.
7
+
8
+ ```ts
9
+ import { createResendMailer } from '@nxgt/mail-resend';
10
+
11
+ export const mailer = createResendMailer({
12
+ apiKey: process.env.RESEND_API_KEY ?? '',
13
+ from: { name: 'Acme', address: 'noreply@acme.test' },
14
+ });
15
+ ```
16
+
17
+ ## The signature
18
+
19
+ ```ts
20
+ function createResendMailer(options: ResendMailerOptions): Mailer;
21
+
22
+ interface ResendMailerOptions {
23
+ readonly apiKey: string;
24
+ readonly from?: Address;
25
+ readonly baseUrl?: string;
26
+ readonly fetch?: (url: string, init: RequestInit) => Promise<Response>;
27
+ readonly timeoutMs?: number;
28
+ }
29
+ ```
30
+
31
+ | Option | Type | Default | Effect |
32
+ | --- | --- | --- | --- |
33
+ | `apiKey` | `string` | required | Sent as `Authorization: Bearer …`. Empty, blank or holding whitespace is a `TypeError` |
34
+ | `from` | `Address` | none | The sender of a message that has no `from`. Without it, such a message is refused with `MailRefused` |
35
+ | `baseUrl` | `string` | `https://api.resend.com` | An `http:` or `https:` URL; `/emails` is appended, a trailing `/` dropped |
36
+ | `fetch` | `(url, init) => Promise<Response>` | the global `fetch` | Every request goes through it |
37
+ | `timeoutMs` | `number` | `30000` | A positive integer, at most `2147483647`. A send that has no answer by then is aborted and fails with `MailFailure` |
38
+
39
+ Options are checked when the mailer is created, and a mistake is a bare
40
+ `TypeError` — see [Errors — wiring](errors.md#wiring--a-typeerror).
41
+
42
+ ## The key
43
+
44
+ Read it where the process starts, and decide there what an unset variable
45
+ means. `process.env.RESEND_API_KEY` is `string | undefined`, which does not
46
+ compile as `apiKey`:
47
+
48
+ ```ts
49
+ import { createResendMailer } from '@nxgt/mail-resend';
50
+
51
+ // An unset variable: a TypeError now, at start-up — never a failure at the first send.
52
+ const mailer = createResendMailer({ apiKey: process.env.RESEND_API_KEY ?? '' });
53
+ ```
54
+
55
+ A key read from a file keeps its final line break, and `fetch` would refuse
56
+ the header at every send. A key holding whitespace is refused at wiring
57
+ instead:
58
+
59
+ ```ts
60
+ import { readFileSync } from 'node:fs';
61
+ import { createResendMailer } from '@nxgt/mail-resend';
62
+
63
+ const apiKey = readFileSync('/run/secrets/resend', 'utf8').trim();
64
+ const mailer = createResendMailer({ apiKey });
65
+ ```
66
+
67
+ The key is never written in an error message.
68
+
69
+ ## The sender
70
+
71
+ A message's own `from` wins; the default is used when it has none; with
72
+ neither, the send is refused with `MailRefused` before any request:
73
+
74
+ ```ts
75
+ import { createResendMailer } from '@nxgt/mail-resend';
76
+
77
+ const mailer = createResendMailer({
78
+ apiKey: process.env.RESEND_API_KEY ?? '',
79
+ from: { name: 'Acme', address: 'noreply@acme.test' },
80
+ });
81
+
82
+ await mailer.send({ to: 'ada@example.com', subject: 'Hi', html: '<p>Hi</p>', text: 'Hi' }); // from Acme
83
+ await mailer.send({
84
+ to: 'ada@example.com',
85
+ from: 'billing@acme.test', // this one wins
86
+ subject: 'Your invoice',
87
+ html: '<p>…</p>',
88
+ text: '…',
89
+ });
90
+ ```
91
+
92
+ The sending domain must be verified in Resend; one that is not is answered
93
+ `403`, a `MailFailure`.
94
+
95
+ ## A proxy, a region, a test server — `baseUrl` and `fetch`
96
+
97
+ `baseUrl` moves every request; `fetch` replaces the function that makes it:
98
+
99
+ ```ts
100
+ import { createResendMailer } from '@nxgt/mail-resend';
101
+
102
+ // Through a gateway of your own.
103
+ const viaGateway = createResendMailer({
104
+ apiKey: process.env.RESEND_API_KEY ?? '',
105
+ baseUrl: 'https://mail-gateway.internal.example',
106
+ });
107
+
108
+ // With a fetch that logs each request's timing.
109
+ const timed = createResendMailer({
110
+ apiKey: process.env.RESEND_API_KEY ?? '',
111
+ fetch: async (url, init) => {
112
+ const started = performance.now();
113
+ try {
114
+ return await fetch(url, init);
115
+ } finally {
116
+ console.info('resend', Math.round(performance.now() - started), 'ms');
117
+ }
118
+ },
119
+ });
120
+ ```
121
+
122
+ A `fetch` you pass receives the `AbortSignal` of the timeout in `init.signal`:
123
+ pass `init` on, and the request is aborted when it fires. The timeout holds
124
+ either way — the send stops waiting for a `fetch` that ignores the signal and
125
+ fails with `MailFailure` — but only a `fetch` that passes the signal on
126
+ stops the request itself.
127
+
128
+ ## The timeout
129
+
130
+ `timeoutMs` (30 seconds by default) bounds the request: past it, the request
131
+ is aborted and the send fails with `MailFailure` —
132
+ `send: Resend did not answer within 30000 ms`. Resend may still have accepted
133
+ the message; the id is simply unknown. The bound covers the answer's body
134
+ too: a `2xx` whose body never ends answers `{ messageId: null }` once the
135
+ timeout passes. It is a timer, so at most `2147483647` ms — a longer one
136
+ would fire at once, and is refused at wiring. A send awaited in a request handler
137
+ usually wants less:
138
+
139
+ ```ts
140
+ import { createResendMailer } from '@nxgt/mail-resend';
141
+
142
+ const mailer = createResendMailer({ apiKey: process.env.RESEND_API_KEY ?? '', timeoutMs: 10_000 });
143
+ ```
144
+
145
+ ## What a message becomes
146
+
147
+ ```ts
148
+ await mailer.send({
149
+ to: ['ada@example.com', { name: 'Doe, "John"', address: 'john@example.com' }],
150
+ replyTo: 'support@acme.test',
151
+ headers: { 'List-Unsubscribe': '<https://acme.test/u>' },
152
+ subject: 'Hi',
153
+ html: '<p>Hi</p>',
154
+ text: 'Hi',
155
+ });
156
+ ```
157
+
158
+ ```http
159
+ POST /emails HTTP/1.1
160
+ Host: api.resend.com
161
+ Authorization: Bearer re_…
162
+ Content-Type: application/json
163
+
164
+ {
165
+ "from": "\"Acme\" <noreply@acme.test>",
166
+ "to": ["ada@example.com", "\"Doe, \\\"John\\\"\" <john@example.com>"],
167
+ "subject": "Hi",
168
+ "html": "<p>Hi</p>",
169
+ "text": "Hi",
170
+ "reply_to": "support@acme.test",
171
+ "headers": { "List-Unsubscribe": "<https://acme.test/u>" }
172
+ }
173
+ ```
174
+
175
+ - Every address goes through `formatAddress`: a bare address as is, a name as
176
+ a quoted string with `"` and `\` escaped — so a comma or an angle bracket in
177
+ a name never names another recipient. It is exported, for code that builds
178
+ Resend requests of its own:
179
+
180
+ ```ts
181
+ import { formatAddress } from '@nxgt/mail-resend';
182
+
183
+ formatAddress({ name: 'Doe, John', address: 'john@example.com' }); // '"Doe, John" <john@example.com>'
184
+ ```
185
+
186
+ - `replyTo` is sent as `reply_to`, Resend's name for it; `reply_to` and
187
+ `headers` are left out when the message has none.
188
+ - Before any of it, `checkMessage` from `@nxgt/mail` refuses what no transport
189
+ hands over. Its messages are listed in
190
+ [`@nxgt/mail`'s troubleshooting](https://github.com/softistx/nxgt-mail/blob/develop/packages/mail/docs/troubleshooting.md#sending).
191
+
192
+ `send` answers `{ messageId }`: Resend's `id`, or `null` when a `2xx` answer
193
+ carries none, or is not JSON — the message was accepted, the id is absent.
194
+
195
+ ## With the renderer
196
+
197
+ ```ts
198
+ import { createMailRenderer } from '@nxgt/mail/renderer';
199
+ import { createResendMailer } from '@nxgt/mail-resend';
200
+
201
+ const mails = createMailRenderer({ dir: 'dist' });
202
+ const mailer = createResendMailer({ apiKey: process.env.RESEND_API_KEY ?? '', from: 'noreply@acme.test' });
203
+
204
+ await mailer.send({
205
+ to: 'ada@example.com',
206
+ ...mails.render('verify-email', { name: 'Ada', link: 'https://app.example.com/verify?token=abc' }, { locale: 'fr' }),
207
+ });
208
+ ```
209
+
210
+ ## See also
211
+
212
+ - [Errors](errors.md) — what `send` throws, and when.
213
+ - [Testing](testing.md) — a local server answering as Resend does, and the
214
+ conformance suite.
@@ -0,0 +1,160 @@
1
+ # Testing
2
+
3
+ Two different things to test, with two different tools:
4
+
5
+ - **An application that sends e-mail.** Do not call Resend, nor fake it: wire
6
+ `createMemoryMailer()` from `@nxgt/mail` in the tests, read its outbox, and
7
+ make a send fail with `failNext()`. See
8
+ [`@nxgt/mail` — testing](https://github.com/softistx/nxgt-mail/blob/develop/packages/mail/docs/guide/testing.md).
9
+ - **This transport.** A local server answering as Resend's API does, and
10
+ `baseUrl` pointed at it: the transport's real `fetch`, real HTTP, real JSON.
11
+ That is how this package passes `@nxgt/mail/conformance`, and the rest of
12
+ this page shows it.
13
+
14
+ ## A local Resend
15
+
16
+ It checks the key, keeps what it accepted, and fails on demand **the way
17
+ Resend fails** — a `503` for an outage, a `422 validation_error` for a
18
+ refusal — so the suite proves the transport's reading of Resend's answers,
19
+ not a wrapper's:
20
+
21
+ ```ts
22
+ // local-resend.ts
23
+ import type { DeliveredMail } from '@nxgt/mail/conformance';
24
+
25
+ export const API_KEY = 're_test';
26
+
27
+ /** The bare addresses of `"name" <address>` or `address`: a quoted name names no one. */
28
+ const addressOf = (entry: string) => entry.match(/<([^<>]+)>$/)?.[1] ?? entry;
29
+
30
+ export function startResend() {
31
+ const delivered: DeliveredMail[] = [];
32
+ const faults: ('outage' | 'refusal')[] = [];
33
+ let attempts = 0;
34
+ const answer = (statusCode: number, name: string, message: string) =>
35
+ Response.json({ statusCode, name, message }, { status: statusCode });
36
+
37
+ const server = Bun.serve({
38
+ port: 0,
39
+ hostname: '127.0.0.1',
40
+ async fetch(request) {
41
+ if (request.method !== 'POST' || new URL(request.url).pathname !== '/emails') {
42
+ return answer(404, 'not_found', 'The requested endpoint does not exist.');
43
+ }
44
+ if (request.headers.get('authorization') !== `Bearer ${API_KEY}`) {
45
+ return answer(401, 'missing_api_key', 'Missing API key in the authorization header.');
46
+ }
47
+ attempts += 1; // one hand-over
48
+ const fault = faults.shift();
49
+ if (fault === 'outage') return answer(503, 'internal_server_error', 'Service unavailable.');
50
+ if (fault === 'refusal') return answer(422, 'validation_error', 'Invalid `to` field.');
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 });
54
+ return Response.json({ id: `resend-${delivered.length}` });
55
+ },
56
+ });
57
+ return {
58
+ baseUrl: `http://127.0.0.1:${server.port}`,
59
+ delivered,
60
+ faults,
61
+ attempts: () => attempts,
62
+ close: () => server.stop(true),
63
+ };
64
+ }
65
+ ```
66
+
67
+ `addressOf` above takes the address after the last `<`, which is enough for
68
+ the transport's own output. The package's spec reads each `to` entry as an
69
+ RFC 5322 address list instead — quoted strings, escapes, commas — as Resend
70
+ does, so a transport that forgot to quote a name would deliver to two
71
+ addresses there, and fail `send.hostileName`.
72
+
73
+ ## The conformance suite
74
+
75
+ One fresh server per case, stopped after it:
76
+
77
+ ```ts
78
+ import { describe, it } from 'bun:test';
79
+ import { describeMailer } from '@nxgt/mail/conformance';
80
+ import { createResendMailer } from '@nxgt/mail-resend';
81
+ import { API_KEY, startResend } from './local-resend';
82
+
83
+ describeMailer({
84
+ name: 'createResendMailer',
85
+ runner: { describe, it }, // bun test puts neither on globalThis
86
+ harness: {
87
+ async open() {
88
+ const resend = startResend();
89
+ return {
90
+ mailer: createResendMailer({ apiKey: API_KEY, baseUrl: resend.baseUrl }),
91
+ delivered: async () => [...resend.delivered],
92
+ faults: {
93
+ failNext: async (kind) => {
94
+ resend.faults.push(kind);
95
+ },
96
+ attempts: async () => resend.attempts(),
97
+ },
98
+ close: () => resend.close(),
99
+ };
100
+ },
101
+ },
102
+ });
103
+ ```
104
+
105
+ All eleven cases pass: a send answers `SentMail`, the message arrives byte for
106
+ 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
109
+ failure cases — an outage is a `MailFailure` with its `cause` and one attempt,
110
+ a refusal a `MailRefused`, and the next send goes through.
111
+
112
+ ## Beyond the suite
113
+
114
+ The package's own specs
115
+ ([`src/index.spec.ts`](https://github.com/softistx/nxgt-mail/blob/develop/packages/mail-resend/src/index.spec.ts))
116
+ add what the suite does not ask of every transport:
117
+
118
+ - a `400` is a `MailRefused`; a `403`, a `429` and a `503` are a `MailFailure`
119
+ with the status on `cause`, each tried once;
120
+ - a server that is not listening ends in `MailFailure` —
121
+ `send: Resend could not be reached` — with the `fetch` error as `cause`;
122
+ - a server that does not answer within `timeoutMs` ends in `MailFailure`, a
123
+ `TimeoutError` as `cause` — and so does a `fetch` that ignores the signal
124
+ and never settles; an answer whose body never ends answers
125
+ `{ messageId: null }` once the timeout passes;
126
+ - no message — the error's nor its cause's — holds the key, a recipient's
127
+ address or what Resend said;
128
+ - the request: `POST /emails`, the bearer key, JSON, a quoted name,
129
+ `reply_to` and `headers`;
130
+ - a `2xx` with no id, or with a body that is not JSON, answers
131
+ `{ messageId: null }`;
132
+ - every `TypeError` at wiring, a `timeoutMs` above `2147483647` included.
133
+
134
+ A `fetch` of your own needs no server at all, when a test only needs to see
135
+ the request:
136
+
137
+ ```ts
138
+ import { expect, test } from 'bun:test';
139
+ import { sampleMessage } from '@nxgt/mail/conformance';
140
+ import { createResendMailer } from '@nxgt/mail-resend';
141
+
142
+ test('posts the message to /emails', async () => {
143
+ const requests: { url: string; body: unknown }[] = [];
144
+ const mailer = createResendMailer({
145
+ apiKey: 're_test',
146
+ fetch: async (url, init) => {
147
+ requests.push({ url, body: JSON.parse(String(init.body)) });
148
+ return Response.json({ id: 'email-1' });
149
+ },
150
+ });
151
+ expect(await mailer.send(sampleMessage)).toEqual({ messageId: 'email-1' });
152
+ expect(requests[0]?.url).toBe('https://api.resend.com/emails');
153
+ });
154
+ ```
155
+
156
+ ## See also
157
+
158
+ - [`@nxgt/mail` — writing a transport](https://github.com/softistx/nxgt-mail/blob/develop/packages/mail/docs/guide/transports.md)
159
+ — the contract, the harness and every case.
160
+ - [Errors](errors.md) — the mapping these tests pin down.
@@ -0,0 +1,46 @@
1
+ # Roadmap
2
+
3
+ Where `@nxgt/mail-resend` is heading. A direction, not a commitment: there are
4
+ no dates here, and the version something shipped in is the only number.
5
+
6
+ ## Now
7
+
8
+ Nothing between releases.
9
+
10
+ ## Next
11
+
12
+ Nothing yet.
13
+
14
+ ## Later
15
+
16
+ - **An idempotency key per send** — Resend's `Idempotency-Key` header, so a
17
+ retry the caller decides cannot send the same e-mail twice.
18
+ - **Tags** — Resend's `tags`, to group sends in its dashboard.
19
+
20
+ ## Not planned
21
+
22
+ - **The Resend SDK** — one request does not need it, and without it the
23
+ transport has no dependency.
24
+ - **Silent retries**, including on a `429` — the transport tries once. A
25
+ retry is the caller's decision, made where it can be seen; a hidden one can
26
+ send the same e-mail twice.
27
+ - **A transport's own error class** — it throws `@nxgt/mail`'s `MailFailure`
28
+ 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.
31
+
32
+ ## Shipped
33
+
34
+ The last ten, newest first, each with the version it came in. Everything
35
+ before is in the [CHANGELOG](../CHANGELOG.md).
36
+
37
+ - **A Resend transport over `fetch`, v0.1.0** — `createResendMailer({ apiKey, from })`:
38
+ one `POST /emails` per message, no SDK, no dependency, no Node built-in, so
39
+ it runs on an edge runtime too. Each message is checked as every transport
40
+ checks it, and a name is sent quoted so it names one recipient.
41
+ - **Errors you can act on, v0.1.0** — a `400` or `422` is a `MailRefused`; a bad key,
42
+ a rate limit, an outage, a network error or a timeout is a `MailFailure`,
43
+ what Resend answered on `cause`. The classes are `@nxgt/mail`'s, so
44
+ `instanceof` holds.
45
+ - **Proven against Resend's API shape, v0.1.0** — the `@nxgt/mail/conformance` suite
46
+ passes against a local server answering as Resend does.
@@ -0,0 +1,252 @@
1
+ # Troubleshooting `@nxgt/mail-resend`
2
+
3
+ Each entry is headed by the message you see. Search this page for the words of
4
+ your message.
5
+
6
+ How the messages are shaped:
7
+
8
+ - **A message names where the problem is, never the value.** Never the key,
9
+ an address, a subject or what Resend said: Resend's answer is on the
10
+ error's `cause` — `status`, `errorName`, and its own message as `detail`.
11
+ - **Every message starts with the call you wrote**: `send: …` or
12
+ `createResendMailer: …`.
13
+ - **A `TypeError` is a wiring mistake**, thrown by `createResendMailer` when
14
+ the application starts. Fix the code; no handler should answer one.
15
+ - **A `MailError` is a refusal at call time**: a `MailFailure`
16
+ (`MAIL_FAILED`) or a `MailRefused` (`MAIL_REFUSED`), the classes of the
17
+ `@nxgt/mail` peer.
18
+
19
+ A `send: …` message not on this page comes from `checkMessage` in
20
+ `@nxgt/mail` — a message no transport hands over. See
21
+ [its troubleshooting](https://github.com/softistx/nxgt-mail/blob/develop/packages/mail/docs/troubleshooting.md#sending).
22
+
23
+ ## Index
24
+
25
+ **Sending**
26
+ - [`send: Resend refused the message`](#send-resend-refused-the-message)
27
+ - [`send: Resend could not take the message`](#send-resend-could-not-take-the-message)
28
+ - [`send: Resend could not be reached`](#send-resend-could-not-be-reached)
29
+ - [`send: Resend did not answer within <timeoutMs> ms`](#send-resend-did-not-answer-within-timeoutms-ms)
30
+ - [`send: from is missing — give the message a from, or createResendMailer a default one`](#send-from-is-missing--give-the-message-a-from-or-createresendmailer-a-default-one)
31
+ - [`Resend answered <status> <name>`](#resend-answered-status-name)
32
+
33
+ **Wiring**
34
+ - [`createResendMailer: options must be an object, as { apiKey }`](#createresendmailer-options-must-be-an-object-as--apikey-)
35
+ - [`createResendMailer: apiKey must be a Resend API key — is the environment variable set?`](#createresendmailer-apikey-must-be-a-resend-api-key--is-the-environment-variable-set)
36
+ - [`createResendMailer: apiKey holds whitespace — trim the value it was read from`](#createresendmailer-apikey-holds-whitespace--trim-the-value-it-was-read-from)
37
+ - [`createResendMailer: baseUrl must be an http: or https: URL`](#createresendmailer-baseurl-must-be-an-http-or-https-url)
38
+ - [`createResendMailer: fetch must be a function`](#createresendmailer-fetch-must-be-a-function)
39
+ - [`createResendMailer: timeoutMs must be a positive integer`](#createresendmailer-timeoutms-must-be-a-positive-integer)
40
+ - [`createResendMailer: timeoutMs must be at most 2147483647 — a longer timer fires at once`](#createresendmailer-timeoutms-must-be-at-most-2147483647--a-longer-timer-fires-at-once)
41
+ - [`createResendMailer: from must be an e-mail address, as noreply@example.com or { name, address }`](#createresendmailer-from-must-be-an-e-mail-address-as-noreplyexamplecom-or--name-address-)
42
+
43
+ **Install and types**
44
+ - [`error instanceof MailFailure` is `false`](#error-instanceof-mailfailure-is-false)
45
+ - [`TS2322: Type 'string | undefined' is not assignable to type 'string'.`](#ts2322-type-string--undefined-is-not-assignable-to-type-string)
46
+ - [`TS2322: Type 'string | null' is not assignable to type 'string'.`](#ts2322-type-string--null-is-not-assignable-to-type-string)
47
+
48
+ ## Sending
49
+
50
+ ### `send: Resend refused the message`
51
+
52
+ A `MailRefused`, code `MAIL_REFUSED`.
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.
56
+
57
+ **Why:** Resend will refuse the same message again; retrying it unchanged is
58
+ pointless.
59
+
60
+ **Fix:** read `cause.errorName` and `cause.detail` — Resend's own words:
61
+
62
+ ```ts
63
+ import { MailRefused } from '@nxgt/mail';
64
+
65
+ try {
66
+ await mailer.send(message);
67
+ } catch (error) {
68
+ if (error instanceof MailRefused && error.cause instanceof Error) {
69
+ const { status, errorName, detail } = error.cause as Error & {
70
+ status?: number;
71
+ errorName?: string | null;
72
+ detail?: string | null;
73
+ };
74
+ console.warn(status, errorName, detail); // 422 validation_error Invalid `to` field. …
75
+ }
76
+ throw error;
77
+ }
78
+ ```
79
+
80
+ `detail` may quote an address: keep it out of logs that must not hold one.
81
+
82
+ ### `send: Resend could not take the message`
83
+
84
+ A `MailFailure`, code `MAIL_FAILED`. **Nothing is known to have been sent**:
85
+ Resend answered that it did not take the message, but a `5xx` can come from
86
+ a server that accepted it before failing.
87
+
88
+ **When:** Resend answered with a status that is neither `2xx` nor a refusal:
89
+
90
+ | `cause.status` | Usually | Fix |
91
+ | --- | --- | --- |
92
+ | `401` | No key reached Resend | Check the key the process was started with |
93
+ | `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 |
94
+ | `429` | The rate limit or the daily quota | Send less often, or from a queue that spaces the sends |
95
+ | `5xx` | Resend is failing | Retry later |
96
+
97
+ The transport does not retry: a retry is yours to decide, where you can see
98
+ it.
99
+
100
+ ### `send: Resend could not be reached`
101
+
102
+ A `MailFailure`. **Nothing is known to have been sent**: a connection that
103
+ dropped after the request left may have delivered it to Resend.
104
+
105
+ **When:** `fetch` threw before any answer: DNS, a refused connection, TLS, a
106
+ proxy in the way, or a `baseUrl` pointing nowhere. `cause` is the `fetch`
107
+ error.
108
+
109
+ **Fix:** check the network from the process's host
110
+ (`curl -I https://api.resend.com`), and `baseUrl` if you set one.
111
+
112
+ ### `send: Resend did not answer within <timeoutMs> ms`
113
+
114
+ A `MailFailure`. `cause` is the `TimeoutError` that aborted the request.
115
+
116
+ **When:** no answer within `timeoutMs` (30 seconds by default) — whatever
117
+ `fetch` is used: one that ignores the signal is no longer waited for.
118
+
119
+ **Why:** the request was aborted: Resend may still have accepted the e-mail,
120
+ but the transport cannot know, and does not say it was sent.
121
+
122
+ **Fix:** a slow network or a slow proxy — raise `timeoutMs`, or retry later.
123
+ Before retrying, weigh that the first send may have gone through.
124
+
125
+ ### `send: from is missing — give the message a from, or createResendMailer a default one`
126
+
127
+ A `MailRefused`, thrown before any request.
128
+
129
+ **When:** the message has no `from`, and the mailer was created without one.
130
+
131
+ **Fix:** give the mailer a default sender, or the message its own:
132
+
133
+ ```ts
134
+ import { createResendMailer } from '@nxgt/mail-resend';
135
+
136
+ const mailer = createResendMailer({
137
+ apiKey: process.env.RESEND_API_KEY ?? '',
138
+ from: { name: 'Acme', address: 'noreply@acme.test' },
139
+ });
140
+ ```
141
+
142
+ ### `Resend answered <status> <name>`
143
+
144
+ The message of the `cause` of a `MailRefused` or a `MailFailure` — for
145
+ example `Resend answered 422 validation_error`, or `Resend answered 503` when
146
+ the body named no error. See the entry of the error it is the cause of,
147
+ above; `cause.detail` holds Resend's own message.
148
+
149
+ ## Wiring
150
+
151
+ ### `createResendMailer: options must be an object, as { apiKey }`
152
+
153
+ A `TypeError`. `createResendMailer` was called with nothing, or with the key
154
+ itself:
155
+
156
+ ```ts
157
+ createResendMailer(process.env.RESEND_API_KEY ?? ''); // ✗
158
+ createResendMailer({ apiKey: process.env.RESEND_API_KEY ?? '' }); // ✓
159
+ ```
160
+
161
+ ### `createResendMailer: apiKey must be a Resend API key — is the environment variable set?`
162
+
163
+ A `TypeError`. `apiKey` is missing, empty or blank — most often an environment
164
+ variable that is not set where the process runs. The value is never printed.
165
+
166
+ **Fix:** set `RESEND_API_KEY` for that process (a deployment's secrets, a
167
+ `.env` the process actually loads).
168
+
169
+ ### `createResendMailer: apiKey holds whitespace — trim the value it was read from`
170
+
171
+ A `TypeError`. The key holds a space or a line break — a key read from a file
172
+ or a secret mount keeps its final line break. Sent as is, every request would
173
+ fail.
174
+
175
+ ```ts
176
+ import { readFileSync } from 'node:fs';
177
+ import { createResendMailer } from '@nxgt/mail-resend';
178
+
179
+ const mailer = createResendMailer({ apiKey: readFileSync('/run/secrets/resend', 'utf8').trim() });
180
+ ```
181
+
182
+ ### `createResendMailer: baseUrl must be an http: or https: URL`
183
+
184
+ A `TypeError`. `baseUrl` is not a string, or has no scheme:
185
+
186
+ ```ts
187
+ createResendMailer({ apiKey, baseUrl: 'api.resend.com' }); // ✗
188
+ createResendMailer({ apiKey, baseUrl: 'https://api.resend.com' }); // ✓ — the default
189
+ ```
190
+
191
+ ### `createResendMailer: fetch must be a function`
192
+
193
+ A `TypeError`. `fetch` was given something that is not a function — a URL, or
194
+ an agent meant for another option. Pass a function `(url, init) =>
195
+ Promise<Response>`, or leave it out for the global `fetch`.
196
+
197
+ ### `createResendMailer: timeoutMs must be a positive integer`
198
+
199
+ A `TypeError`. `timeoutMs` is `0`, negative, a fraction or not a number. It is
200
+ milliseconds: `10_000` for ten seconds.
201
+
202
+ ### `createResendMailer: timeoutMs must be at most 2147483647 — a longer timer fires at once`
203
+
204
+ A `TypeError`. `timeoutMs` is above 2³¹ − 1 milliseconds, about 24.8 days —
205
+ often a value in microseconds, or a duration meant as "never". A timer that
206
+ long fires at once, and every send would time out.
207
+
208
+ **Fix:** a timeout in milliseconds, far below the bound; leave it out for the
209
+ default, 30 seconds:
210
+
211
+ ```ts
212
+ createResendMailer({ apiKey, timeoutMs: Number.MAX_SAFE_INTEGER }); // ✗
213
+ createResendMailer({ apiKey, timeoutMs: 60_000 }); // ✓ — one minute
214
+ ```
215
+
216
+ ### `createResendMailer: from must be an e-mail address, as noreply@example.com or { name, address }`
217
+
218
+ A `TypeError`. The default `from` is not an address. Most often, a name
219
+ written inside the string:
220
+
221
+ ```ts
222
+ createResendMailer({ apiKey, from: 'Acme <noreply@acme.test>' }); // ✗
223
+ createResendMailer({ apiKey, from: { name: 'Acme', address: 'noreply@acme.test' } }); // ✓
224
+ ```
225
+
226
+ ## Install and types
227
+
228
+ ### `error instanceof MailFailure` is `false`
229
+
230
+ Two copies of `@nxgt/mail` are installed, and the transport throws the other
231
+ one's class. `@nxgt/mail` is a **peer** of this package: list it in your own
232
+ `package.json`, in a range this package accepts, and install again. `bun pm ls
233
+ @nxgt/mail` (or `npm ls @nxgt/mail`) should show one version.
234
+
235
+ ### `TS2322: Type 'string | undefined' is not assignable to type 'string'.`
236
+
237
+ On `apiKey: process.env.RESEND_API_KEY`. Decide what an unset variable means
238
+ where you read it; `?? ''` makes it a `TypeError` at start-up:
239
+
240
+ ```ts
241
+ const mailer = createResendMailer({ apiKey: process.env.RESEND_API_KEY ?? '' });
242
+ ```
243
+
244
+ ### `TS2322: Type 'string | null' is not assignable to type 'string'.`
245
+
246
+ `messageId` is `string | null`: an answer may carry no id, and an absence is
247
+ `null`. Decide what an absent id means where you read it:
248
+
249
+ ```ts
250
+ const { messageId } = await mailer.send(message);
251
+ const reference = messageId ?? 'none';
252
+ ```
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@nxgt/mail-resend",
3
+ "version": "0.1.0",
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
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "files": [
10
+ "dist",
11
+ "docs",
12
+ "README.md",
13
+ "package.json",
14
+ "LICENSE"
15
+ ],
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "import": "./dist/index.js",
20
+ "default": "./dist/index.js"
21
+ },
22
+ "./package.json": "./package.json"
23
+ },
24
+ "keywords": [
25
+ "email",
26
+ "transactional",
27
+ "mailer",
28
+ "resend",
29
+ "typescript"
30
+ ],
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/softistx/nxgt-mail.git",
34
+ "directory": "packages/mail-resend"
35
+ },
36
+ "publishConfig": {
37
+ "registry": "https://registry.npmjs.org",
38
+ "access": "public"
39
+ },
40
+ "scripts": {
41
+ "build": "bun run ../../build.ts",
42
+ "test": "bun test src",
43
+ "typecheck": "tsc --noEmit"
44
+ },
45
+ "devDependencies": {
46
+ "@nxgt/mail": "0.1.0",
47
+ "@types/bun": "^1.4.2"
48
+ },
49
+ "peerDependencies": {
50
+ "@nxgt/mail": "^0.1.0",
51
+ "typescript": "^6.0.3"
52
+ }
53
+ }