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/LICENSE +15 -0
- package/README.md +170 -0
- package/dist/capture.js +82 -0
- package/dist/chunks/errors-CMUvhZvz.js +97 -0
- package/dist/chunks/seal-BORkLlJV.js +117 -0
- package/dist/http.js +65 -0
- package/dist/index.js +236 -0
- package/dist/smtp.js +195 -0
- package/package.json +77 -0
- package/src/adapter.ts +18 -0
- package/src/capture.ts +121 -0
- package/src/errors.ts +164 -0
- package/src/http.ts +98 -0
- package/src/index.ts +384 -0
- package/src/seal.ts +242 -0
- package/src/smtp.ts +315 -0
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
|
+
}
|