ajo-kit-mail 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/dist/index.js ADDED
@@ -0,0 +1,236 @@
1
+ import { n as Undelivered, r as classify, t as Refused } from "./chunks/errors-CMUvhZvz.js";
2
+ import { n as encode, r as seal, t as domain } from "./chunks/seal-BORkLlJV.js";
3
+ import { configure as configure$1 } from "ajo-kit/mail";
4
+ //#region packages/ajo-kit-mail/src/adapter.ts
5
+ /** Configures ajo-kit-mail and returns a transport for ajo-kit's mail seam. */
6
+ function adapter(options) {
7
+ configure(options);
8
+ return async (mail) => {
9
+ const outcome = await deliver({
10
+ to: mail.to,
11
+ subject: mail.subject,
12
+ text: mail.text,
13
+ ...mail.html !== void 0 && { html: mail.html }
14
+ });
15
+ if (!outcome.ok) throw outcome.error;
16
+ };
17
+ }
18
+ //#endregion
19
+ //#region packages/ajo-kit-mail/src/index.ts
20
+ var CONCURRENCY = 4;
21
+ var TIMEOUT = 1e4;
22
+ var MAXIMUM_TIMER = 2147483647;
23
+ var CONTROL = /[\u0000-\u001f\u007f]/;
24
+ var configuration;
25
+ var production = () => process.env.NODE_ENV === "production";
26
+ var refuse = (code) => {
27
+ throw new Refused(code);
28
+ };
29
+ var duration = (value) => {
30
+ const timeout = value ?? TIMEOUT;
31
+ if (!Number.isSafeInteger(timeout) || timeout <= 0 || timeout > MAXIMUM_TIMER) refuse("invalid-config");
32
+ return timeout;
33
+ };
34
+ var concurrency = (value) => {
35
+ const limit = value ?? CONCURRENCY;
36
+ if (!Number.isSafeInteger(limit) || limit <= 0) refuse("invalid-config");
37
+ return limit;
38
+ };
39
+ var label = (value) => {
40
+ if (typeof value !== "string") return refuse("invalid-config");
41
+ return value;
42
+ };
43
+ /**
44
+ * Bounded concurrency with no resident timer: a setTimeout exists only while a
45
+ * send is genuinely waiting and is cleared on both paths. The slot is transferred
46
+ * on release rather than decremented, so the limit cannot be overshot by callers
47
+ * arriving in the same tick.
48
+ */
49
+ var admit = (limit) => {
50
+ let active = 0;
51
+ const waiting = [];
52
+ return {
53
+ acquire: (deadline) => new Promise((resolve, reject) => {
54
+ if (active < limit) {
55
+ active++;
56
+ resolve();
57
+ return;
58
+ }
59
+ const timer = setTimeout(() => {
60
+ const index = waiting.indexOf(entry);
61
+ if (index >= 0) waiting.splice(index, 1);
62
+ reject(new Undelivered("busy", true));
63
+ }, Math.max(0, deadline - Date.now()));
64
+ const entry = () => {
65
+ clearTimeout(timer);
66
+ active++;
67
+ resolve();
68
+ };
69
+ waiting.push(entry);
70
+ }),
71
+ release: () => {
72
+ active--;
73
+ waiting.shift()?.();
74
+ }
75
+ };
76
+ };
77
+ /**
78
+ * Races the transport against the envelope signal. The race rejects the caller.
79
+ * it cannot reclaim a socket a third-party transport leaked, which is why both
80
+ * shipped transports honour the signal and close in finally.
81
+ */
82
+ var bounded = (work, signal) => new Promise((resolve, reject) => {
83
+ const stop = () => reject(new Undelivered("timeout", true));
84
+ if (signal.aborted) return stop();
85
+ signal.addEventListener("abort", stop, { once: true });
86
+ work.then(resolve, reject).finally(() => signal.removeEventListener("abort", stop));
87
+ });
88
+ var sanitized = (error) => {
89
+ try {
90
+ return classify(error);
91
+ } catch {
92
+ return new Undelivered("unknown", false);
93
+ }
94
+ };
95
+ var elapsed = (started) => Math.max(0, Date.now() - started);
96
+ var receipt = (value, fallback) => {
97
+ try {
98
+ const id = value?.id;
99
+ return typeof id === "string" && id && !CONTROL.test(id) ? id : fallback;
100
+ } catch {
101
+ return fallback;
102
+ }
103
+ };
104
+ var report = (current, mail, outcome, started) => {
105
+ if (!current.observe) return;
106
+ const event = {
107
+ id: outcome.ok ? outcome.id : mail?.id ?? "unknown",
108
+ kind: mail?.kind ?? "mail",
109
+ transport: current.label,
110
+ outcome: outcome.ok ? "sent" : outcome.kind,
111
+ ...!outcome.ok && { code: outcome.code },
112
+ ...!outcome.ok && outcome.kind === "undelivered" && { retryable: outcome.retryable },
113
+ domain: mail ? domain(mail.to.address) : void 0,
114
+ ms: elapsed(started)
115
+ };
116
+ try {
117
+ current.observe(event);
118
+ } catch {}
119
+ };
120
+ var refused = (error) => ({
121
+ ok: false,
122
+ kind: "refused",
123
+ code: error.code,
124
+ error
125
+ });
126
+ var undelivered = (error) => {
127
+ const failure = sanitized(error);
128
+ return {
129
+ ok: false,
130
+ kind: "undelivered",
131
+ code: failure.code,
132
+ retryable: failure.retryable,
133
+ error: failure
134
+ };
135
+ };
136
+ var execute = async (message) => {
137
+ const started = Date.now();
138
+ const current = configuration;
139
+ if (!current) return refused(new Refused("no-transport"));
140
+ let mail;
141
+ try {
142
+ mail = seal(message, current.policy);
143
+ } catch (error) {
144
+ const outcome = refused(error instanceof Refused ? error : new Refused("invalid-config"));
145
+ report(current, void 0, outcome, started);
146
+ return outcome;
147
+ }
148
+ try {
149
+ await current.gate.acquire(mail.deadline);
150
+ } catch (error) {
151
+ const outcome = undelivered(error);
152
+ report(current, mail, outcome, started);
153
+ return outcome;
154
+ }
155
+ let outcome;
156
+ try {
157
+ if (mail.signal.aborted || mail.deadline <= Date.now()) throw new Undelivered("timeout", true);
158
+ const transport = current.transport;
159
+ outcome = {
160
+ ok: true,
161
+ id: receipt(await bounded(Promise.resolve().then(() => transport(mail)), mail.signal), mail.id),
162
+ transport: current.label
163
+ };
164
+ } catch (error) {
165
+ outcome = undelivered(error);
166
+ } finally {
167
+ current.gate.release();
168
+ }
169
+ report(current, mail, outcome, started);
170
+ return outcome;
171
+ };
172
+ /**
173
+ * Installs the transport process-wide and adapts it into ajo-kit's mail seam, so
174
+ * existing send() call sites gain validation and a deadline without being edited.
175
+ * Pure assignment: no socket, no pool, no timer, safe to re-run on every dev reload.
176
+ */
177
+ function configure(options) {
178
+ try {
179
+ if (!options || typeof options !== "object" || typeof options.transport !== "function") refuse("invalid-config");
180
+ if (options.transport.dev === true && production()) refuse("invalid-config");
181
+ if (options.transport.verify !== void 0 && typeof options.transport.verify !== "function") refuse("invalid-config");
182
+ if (options.observe !== void 0 && typeof options.observe !== "function") refuse("invalid-config");
183
+ const transport = options.transport;
184
+ configuration = {
185
+ transport,
186
+ label: label(options.label ?? transport.label ?? "mail"),
187
+ policy: {
188
+ from: options.from,
189
+ ...options.replyTo !== void 0 && { replyTo: options.replyTo },
190
+ ...options.timeout !== void 0 && { timeout: options.timeout },
191
+ ...options.limit !== void 0 && { limit: options.limit }
192
+ },
193
+ gate: admit(concurrency(options.concurrency)),
194
+ ...options.observe && { observe: options.observe }
195
+ };
196
+ configure$1(async (mail) => {
197
+ await send(mail);
198
+ });
199
+ } catch (error) {
200
+ if (error instanceof Refused) throw error;
201
+ throw new Refused("invalid-config");
202
+ }
203
+ }
204
+ /** Validates, delivers once inside the deadline, reports the outcome. Never throws. */
205
+ async function deliver(message) {
206
+ try {
207
+ return await execute(message);
208
+ } catch (error) {
209
+ return undelivered(error);
210
+ }
211
+ }
212
+ /** Delivers once and resolves to the message id or throws Refused or Undelivered. */
213
+ async function send(message) {
214
+ const outcome = await deliver(message);
215
+ if (!outcome.ok) throw outcome.error;
216
+ return outcome.id;
217
+ }
218
+ /** Runs the transport's optional credential check. Opens a connection only when called. */
219
+ async function probe(timeout) {
220
+ try {
221
+ const current = configuration;
222
+ if (!current) throw new Refused("no-transport");
223
+ const verify = current.transport.verify;
224
+ if (!verify) return { ok: true };
225
+ const signal = AbortSignal.timeout(duration(timeout ?? current.policy.timeout));
226
+ await bounded(Promise.resolve().then(() => verify.call(current.transport, signal)), signal);
227
+ return { ok: true };
228
+ } catch (error) {
229
+ return {
230
+ ok: false,
231
+ error: error instanceof Refused ? error : sanitized(error)
232
+ };
233
+ }
234
+ }
235
+ //#endregion
236
+ export { Refused, Undelivered, adapter, classify, configure, deliver, domain, encode, probe, seal, send };
package/dist/smtp.js ADDED
@@ -0,0 +1,195 @@
1
+ import { n as Undelivered, t as Refused } from "./chunks/errors-CMUvhZvz.js";
2
+ import { Socket, isIP } from "node:net";
3
+ import { connect } from "node:tls";
4
+ import { createTransport } from "nodemailer";
5
+ //#region packages/ajo-kit-mail/src/smtp.ts
6
+ var CONTROL = /[\u0000-\u001f\u007f]/;
7
+ var VERIFY_TIMEOUT = 1e4;
8
+ var configuration = (options) => {
9
+ if (!options || typeof options !== "object") throw new Refused("invalid-config");
10
+ const host = options.host;
11
+ const port = options.port ?? 587;
12
+ const implicit = options.implicit ?? false;
13
+ const user = options.user;
14
+ const pass = options.pass;
15
+ const name = options.name;
16
+ if (typeof host !== "string" || !host || host !== host.trim() || host.length > 253 || CONTROL.test(host) || /\s/.test(host) || !Number.isInteger(port) || port < 1 || port > 65535 || typeof implicit !== "boolean" || user !== void 0 && (typeof user !== "string" || !user) || pass !== void 0 && (typeof pass !== "string" || !pass) || user === void 0 !== (pass === void 0) || name !== void 0 && (typeof name !== "string" || !name || name !== name.trim() || name.length > 253 || CONTROL.test(name) || /\s/.test(name))) throw new Refused("invalid-config");
17
+ return Object.freeze({
18
+ host,
19
+ port,
20
+ implicit,
21
+ ...user !== void 0 && {
22
+ user,
23
+ pass
24
+ },
25
+ ...name !== void 0 && { name }
26
+ });
27
+ };
28
+ var connector = (config, own) => (_options, callback) => {
29
+ let settled = false;
30
+ let socket;
31
+ const finish = (error, options) => {
32
+ if (settled) return;
33
+ settled = true;
34
+ callback(error, options);
35
+ };
36
+ if (config.implicit) socket = connect({
37
+ host: config.host,
38
+ port: config.port,
39
+ ...isIP(config.host) === 0 && { servername: config.host },
40
+ rejectUnauthorized: true,
41
+ minVersion: "TLSv1.2"
42
+ }, () => finish(null, {
43
+ connection: socket,
44
+ secured: true
45
+ }));
46
+ else {
47
+ socket = new Socket();
48
+ socket.connect(config.port, config.host, () => finish(null, { connection: socket }));
49
+ }
50
+ own(socket);
51
+ socket.on("error", () => {
52
+ if (!settled) finish(Object.assign(/* @__PURE__ */ new Error("SMTP connection failed"), { code: config.implicit ? "ETLS" : "ESOCKET" }));
53
+ });
54
+ };
55
+ var settings = (config, name, timeout, own) => ({
56
+ host: config.host,
57
+ port: config.port,
58
+ secure: config.implicit,
59
+ pool: false,
60
+ requireTLS: !config.implicit,
61
+ ignoreTLS: false,
62
+ opportunisticTLS: false,
63
+ name,
64
+ ...config.user !== void 0 && { auth: {
65
+ user: config.user,
66
+ pass: config.pass
67
+ } },
68
+ connectionTimeout: timeout,
69
+ greetingTimeout: timeout,
70
+ socketTimeout: timeout,
71
+ dnsTimeout: timeout,
72
+ tls: {
73
+ rejectUnauthorized: true,
74
+ minVersion: "TLSv1.2"
75
+ },
76
+ disableFileAccess: true,
77
+ disableUrlAccess: true,
78
+ logger: false,
79
+ debug: false,
80
+ transactionLog: false,
81
+ getSocket: connector(config, own)
82
+ });
83
+ var remaining = (deadline) => {
84
+ const value = Math.ceil(deadline - Date.now());
85
+ return value > 0 ? Math.min(value, 2147483647) : 0;
86
+ };
87
+ var bounded = (work, signal, close) => new Promise((resolve, reject) => {
88
+ const stop = () => {
89
+ close();
90
+ reject(new Undelivered("timeout", true));
91
+ };
92
+ if (signal.aborted) return stop();
93
+ signal.addEventListener("abort", stop, { once: true });
94
+ work.then((value) => {
95
+ signal.removeEventListener("abort", stop);
96
+ resolve(value);
97
+ }, (error) => {
98
+ signal.removeEventListener("abort", stop);
99
+ reject(error);
100
+ });
101
+ });
102
+ /** SMTP reply-code mapping. Note the parentheses: this is where a precedence bug hides. */
103
+ var reply = (error) => {
104
+ const value = error ?? {};
105
+ const code = typeof value.code === "string" ? value.code : "";
106
+ const command = typeof value.command === "string" ? value.command : "";
107
+ const status = typeof value.responseCode === "number" ? value.responseCode : void 0;
108
+ const hint = status === void 0 ? void 0 : `smtp ${status}`;
109
+ if (code === "EAUTH" || status === 530 || status === 535) return new Undelivered("auth", false, hint);
110
+ if (code === "ETLS" || code.startsWith("ERR_TLS") || code.includes("CERT")) return new Undelivered("tls", false);
111
+ if (code === "ESOCKET" && command === "CONN") return new Undelivered("tls", false);
112
+ if (code === "ETIMEDOUT" || code === "ECONNECTION" && status === void 0) return new Undelivered("timeout", true);
113
+ if (status !== void 0 && status >= 400 && status < 500) return new Undelivered("throttled", true, hint);
114
+ if (status !== void 0 && status >= 500) return new Undelivered("rejected", false, hint);
115
+ if (code) return new Undelivered("connection", true);
116
+ return new Undelivered("unknown", false);
117
+ };
118
+ /**
119
+ * Creates a one-connection-per-message SMTP transport with mandatory verified
120
+ * TLS, deadline-derived socket timeouts and sanitized failure classifications.
121
+ */
122
+ function smtp(options) {
123
+ const config = configuration(options);
124
+ const send = async (mail) => {
125
+ const timeout = remaining(mail.deadline);
126
+ if (!timeout) throw new Undelivered("timeout", true);
127
+ let mailer;
128
+ let socket;
129
+ let closed = false;
130
+ const own = (value) => {
131
+ socket = value;
132
+ if (closed) value.destroy();
133
+ };
134
+ const close = () => {
135
+ if (closed) return;
136
+ closed = true;
137
+ socket?.destroy();
138
+ mailer?.close();
139
+ };
140
+ try {
141
+ mailer = createTransport(settings(config, config.name ?? mail.from.address.slice(mail.from.address.lastIndexOf("@") + 1), timeout, own));
142
+ const result = await bounded(mailer.sendMail({
143
+ from: mail.from,
144
+ to: mail.to,
145
+ ...mail.replyTo && { replyTo: mail.replyTo },
146
+ subject: mail.subject,
147
+ text: mail.text,
148
+ ...mail.html !== void 0 && { html: mail.html },
149
+ envelope: {
150
+ from: mail.from.address,
151
+ to: [mail.to.address]
152
+ },
153
+ disableFileAccess: true,
154
+ disableUrlAccess: true
155
+ }), mail.signal, close);
156
+ return typeof result.messageId === "string" && result.messageId ? { id: result.messageId } : void 0;
157
+ } catch (error) {
158
+ if (error instanceof Undelivered) throw error;
159
+ throw reply(error);
160
+ } finally {
161
+ close();
162
+ }
163
+ };
164
+ const verify = async (signal) => {
165
+ if (signal.aborted) throw new Undelivered("timeout", true);
166
+ let mailer;
167
+ let socket;
168
+ let closed = false;
169
+ const own = (value) => {
170
+ socket = value;
171
+ if (closed) value.destroy();
172
+ };
173
+ const close = () => {
174
+ if (closed) return;
175
+ closed = true;
176
+ socket?.destroy();
177
+ mailer?.close();
178
+ };
179
+ try {
180
+ mailer = createTransport(settings(config, config.name ?? config.host, VERIFY_TIMEOUT, own));
181
+ await bounded(mailer.verify(), signal, close);
182
+ } catch (error) {
183
+ if (error instanceof Undelivered) throw error;
184
+ throw reply(error);
185
+ } finally {
186
+ close();
187
+ }
188
+ };
189
+ return Object.assign(send, {
190
+ label: "smtp",
191
+ verify
192
+ });
193
+ }
194
+ //#endregion
195
+ export { smtp };
package/package.json ADDED
@@ -0,0 +1,77 @@
1
+ {
2
+ "name": "ajo-kit-mail",
3
+ "version": "0.1.0",
4
+ "description": "Validated mail contract and pluggable transports for ajo-kit applications",
5
+ "type": "module",
6
+ "files": [
7
+ "dist",
8
+ "src",
9
+ "README.md"
10
+ ],
11
+ "kit": {
12
+ "serverOnly": true
13
+ },
14
+ "exports": {
15
+ ".": {
16
+ "types": "./src/index.ts",
17
+ "default": "./dist/index.js",
18
+ "import": "./dist/index.js"
19
+ },
20
+ "./http": {
21
+ "types": "./src/http.ts",
22
+ "default": "./dist/http.js",
23
+ "import": "./dist/http.js"
24
+ },
25
+ "./smtp": {
26
+ "types": "./src/smtp.ts",
27
+ "default": "./dist/smtp.js",
28
+ "import": "./dist/smtp.js"
29
+ },
30
+ "./capture": {
31
+ "types": "./src/capture.ts",
32
+ "default": "./dist/capture.js",
33
+ "import": "./dist/capture.js"
34
+ }
35
+ },
36
+ "devDependencies": {
37
+ "@types/nodemailer": "^7.0.0",
38
+ "nodemailer": "^7.0.0",
39
+ "ajo-kit": "^0.1.2"
40
+ },
41
+ "peerDependencies": {
42
+ "nodemailer": "^7.0.0",
43
+ "ajo-kit": "^0.1.2"
44
+ },
45
+ "peerDependenciesMeta": {
46
+ "nodemailer": {
47
+ "optional": true
48
+ }
49
+ },
50
+ "engines": {
51
+ "node": ">=22.18.0"
52
+ },
53
+ "keywords": [
54
+ "ajo",
55
+ "email",
56
+ "mail",
57
+ "security"
58
+ ],
59
+ "author": "Cristian Falcone",
60
+ "license": "ISC",
61
+ "repository": {
62
+ "type": "git",
63
+ "url": "git+https://github.com/cristianfalcone/ajo-kit.git",
64
+ "directory": "packages/ajo-kit-mail"
65
+ },
66
+ "bugs": {
67
+ "url": "https://github.com/cristianfalcone/ajo-kit/issues"
68
+ },
69
+ "homepage": "https://github.com/cristianfalcone/ajo-kit/tree/main/packages/ajo-kit-mail#readme",
70
+ "publishConfig": {
71
+ "access": "public"
72
+ },
73
+ "scripts": {
74
+ "build": "pnpm -w exec tsx scripts/package-build.ts ajo-kit-mail",
75
+ "test": "pnpm -w exec vitest run packages/ajo-kit-mail/tests"
76
+ }
77
+ }
package/src/adapter.ts ADDED
@@ -0,0 +1,18 @@
1
+ import type { Transport as KitTransport } from 'ajo-kit/mail'
2
+ import { configure, deliver, type Options } from './index'
3
+
4
+ /** Configures ajo-kit-mail and returns a transport for ajo-kit's mail seam. */
5
+ export function adapter(options: Options): KitTransport {
6
+ configure(options)
7
+
8
+ return async mail => {
9
+ const outcome = await deliver({
10
+ to: mail.to,
11
+ subject: mail.subject,
12
+ text: mail.text,
13
+ ...(mail.html !== undefined && { html: mail.html }),
14
+ })
15
+
16
+ if (!outcome.ok) throw outcome.error
17
+ }
18
+ }
package/src/capture.ts ADDED
@@ -0,0 +1,121 @@
1
+ import { Undelivered, type DeliveryCode } from './errors'
2
+ import { domain, type Sealed } from './seal'
3
+ import type { Transport } from './index'
4
+
5
+ /** In-memory transport for tests and development. Refused in production by configure(). */
6
+ export interface Capture extends Transport {
7
+ /** Bounded ring: at most `keep` envelopes, oldest dropped. Live reset links do not accumulate. */
8
+ readonly messages: readonly Sealed[]
9
+ /** Returns the newest retained envelope. */
10
+ last(): Sealed | undefined
11
+ /** Returns the first absolute URL in the last text body that matches the optional pattern. */
12
+ link(pattern?: RegExp): string | undefined
13
+ /** Fails the next `times` deliveries with `code`, to drive the failure paths. */
14
+ fail(code: DeliveryCode, times?: number): void
15
+ /** Removes retained envelopes and any pending injected failure. */
16
+ clear(): void
17
+ }
18
+
19
+ /** Settings for an in-memory capture transport. */
20
+ export interface CaptureOptions {
21
+ /** Print id, kind and recipient domain only. The body is unreachable from the log path. */
22
+ log?: boolean
23
+ /** Retained envelopes. Default 50. */
24
+ keep?: number
25
+ }
26
+
27
+ type Failure = {
28
+ readonly code: DeliveryCode
29
+ remaining: number
30
+ }
31
+
32
+ const DELIVERY: ReadonlySet<DeliveryCode> = new Set([
33
+ 'timeout',
34
+ 'busy',
35
+ 'connection',
36
+ 'tls',
37
+ 'auth',
38
+ 'rejected',
39
+ 'throttled',
40
+ 'unavailable',
41
+ 'unknown',
42
+ ])
43
+ const RETRYABLE: ReadonlySet<DeliveryCode> = new Set([
44
+ 'timeout',
45
+ 'busy',
46
+ 'connection',
47
+ 'throttled',
48
+ 'unavailable',
49
+ ])
50
+ const LINKS = /https?:\/\/[^\s<>"'`]+/g
51
+
52
+ const count = (value: number | undefined) => {
53
+ const keep = value ?? 50
54
+ if (!Number.isSafeInteger(keep) || keep < 0) {
55
+ throw new RangeError('Capture keep must be a non-negative safe integer')
56
+ }
57
+ return keep
58
+ }
59
+
60
+ const repetitions = (value: number | undefined) => {
61
+ const times = value ?? 1
62
+ if (!Number.isSafeInteger(times) || times <= 0) {
63
+ throw new RangeError('Capture failure count must be a positive safe integer')
64
+ }
65
+ return times
66
+ }
67
+
68
+ const matches = (pattern: RegExp, value: string) => {
69
+ const lastIndex = pattern.lastIndex
70
+ pattern.lastIndex = 0
71
+
72
+ try {
73
+ return pattern.test(value)
74
+ } finally {
75
+ pattern.lastIndex = lastIndex
76
+ }
77
+ }
78
+
79
+ /** Creates a bounded in-memory transport for development and deterministic tests. */
80
+ export function capture(options: CaptureOptions = {}): Capture {
81
+ const keep = count(options.keep)
82
+ const messages: Sealed[] = []
83
+ let failure: Failure | undefined
84
+
85
+ const transport = async (mail: Sealed) => {
86
+ if (failure) {
87
+ const current = failure
88
+ current.remaining--
89
+ if (current.remaining === 0) failure = undefined
90
+ throw new Undelivered(current.code, RETRYABLE.has(current.code))
91
+ }
92
+
93
+ messages.push(mail)
94
+ if (messages.length > keep) messages.splice(0, messages.length - keep)
95
+
96
+ if (options.log) {
97
+ console.log(`[mail] ${mail.id} ${mail.kind} @${domain(mail.to.address)} sent 0ms`)
98
+ }
99
+
100
+ return { id: mail.id }
101
+ }
102
+
103
+ return Object.assign(transport, {
104
+ label: 'capture',
105
+ dev: true,
106
+ messages,
107
+ last: () => messages.at(-1),
108
+ link: (pattern?: RegExp) => {
109
+ const links = messages.at(-1)?.text.match(LINKS) ?? []
110
+ return pattern ? links.find(value => matches(pattern, value)) : links[0]
111
+ },
112
+ fail: (code: DeliveryCode, times?: number) => {
113
+ if (!DELIVERY.has(code)) throw new RangeError('Unknown capture delivery code')
114
+ failure = { code, remaining: repetitions(times) }
115
+ },
116
+ clear: () => {
117
+ messages.length = 0
118
+ failure = undefined
119
+ },
120
+ })
121
+ }