@krizaka/mail 0.1.2 → 0.1.4
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/{chunk-YDSARDFP.js → chunk-XBNBPYT5.js} +29 -11
- package/dist/chunk-XBNBPYT5.js.map +1 -0
- package/dist/index.js +104 -18
- package/dist/index.js.map +1 -1
- package/dist/settings.js +6 -2
- package/dist/settings.js.map +1 -1
- package/dist/testkit.js +17 -3
- package/dist/testkit.js.map +1 -1
- package/package.json +5 -5
- package/dist/chunk-YDSARDFP.js.map +0 -1
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
// src/port.ts
|
|
2
2
|
var SUPPRESSION_REASON = { bounce: "bounce", complaint: "complaint", unsubscribed: "unsubscribed" };
|
|
3
|
-
var DELIVERY_STATUS = {
|
|
3
|
+
var DELIVERY_STATUS = {
|
|
4
|
+
sent: "sent",
|
|
5
|
+
failed: "failed",
|
|
6
|
+
suppressed: "suppressed",
|
|
7
|
+
duplicate: "duplicate"
|
|
8
|
+
};
|
|
4
9
|
var MAIL_CODE = {
|
|
5
10
|
badAddress: "MAIL_BAD_ADDRESS",
|
|
6
11
|
badMessage: "MAIL_BAD_MESSAGE",
|
|
@@ -20,7 +25,12 @@ var MailError = class extends Error {
|
|
|
20
25
|
// src/rules.ts
|
|
21
26
|
import { createHash } from "crypto";
|
|
22
27
|
var MAIL_BOUNDS = { retentionDays: [1, 3650] };
|
|
23
|
-
var MAIL_DEFAULTS = {
|
|
28
|
+
var MAIL_DEFAULTS = {
|
|
29
|
+
provider: "log",
|
|
30
|
+
from: "Krizaka <no-reply@krizaka.com>",
|
|
31
|
+
replyTo: "",
|
|
32
|
+
retentionDays: 90
|
|
33
|
+
};
|
|
24
34
|
var ADDRESS = /^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/;
|
|
25
35
|
var TOKEN = /^[a-z][a-z0-9_.-]{0,39}$/;
|
|
26
36
|
var MAX_ADDRESS = 254;
|
|
@@ -42,21 +52,29 @@ function bareAddress(from) {
|
|
|
42
52
|
}
|
|
43
53
|
var oneLine = (value) => !/[\r\n]/.test(value);
|
|
44
54
|
function checkAddress(address, what = "address") {
|
|
45
|
-
if (typeof address !== "string" || address.length > MAX_ADDRESS || !oneLine(address) || !ADDRESS.test(bareAddress(address)))
|
|
55
|
+
if (typeof address !== "string" || address.length > MAX_ADDRESS || !oneLine(address) || !ADDRESS.test(bareAddress(address)))
|
|
56
|
+
throw new MailError(MAIL_CODE.badAddress, `${what} is not a valid address`);
|
|
46
57
|
}
|
|
47
58
|
function checkMessage(message) {
|
|
48
59
|
checkAddress(message.to, "to");
|
|
49
60
|
if (message.replyTo !== void 0 && message.replyTo !== "") checkAddress(message.replyTo, "replyTo");
|
|
50
|
-
if (typeof message.subject !== "string" || message.subject.trim() === "" || message.subject.length > MAX_SUBJECT || !oneLine(message.subject))
|
|
51
|
-
|
|
52
|
-
if (message.
|
|
53
|
-
|
|
54
|
-
if (typeof message.
|
|
55
|
-
|
|
61
|
+
if (typeof message.subject !== "string" || message.subject.trim() === "" || message.subject.length > MAX_SUBJECT || !oneLine(message.subject))
|
|
62
|
+
throw new MailError(MAIL_CODE.badMessage, "subject must be one line of 1 to 200 characters");
|
|
63
|
+
if (typeof message.text !== "string" || message.text.trim() === "" || message.text.length > MAX_BODY)
|
|
64
|
+
throw new MailError(MAIL_CODE.badMessage, "text is required");
|
|
65
|
+
if (message.html !== void 0 && (typeof message.html !== "string" || message.html.length > MAX_BODY))
|
|
66
|
+
throw new MailError(MAIL_CODE.badMessage, "html is too long");
|
|
67
|
+
if (!TOKEN.test(message.category) || !TOKEN.test(message.template))
|
|
68
|
+
throw new MailError(MAIL_CODE.badMessage, "category and template are lower-case tokens");
|
|
69
|
+
if (typeof message.locale !== "string" || !/^[a-z]{2}(-[A-Z]{2})?$/.test(message.locale))
|
|
70
|
+
throw new MailError(MAIL_CODE.badMessage, "locale is a language tag like fr or en-CA");
|
|
71
|
+
if (typeof message.idempotencyKey !== "string" || message.idempotencyKey === "" || message.idempotencyKey.length > MAX_KEY)
|
|
72
|
+
throw new MailError(MAIL_CODE.badMessage, "idempotencyKey is required (1 to 200 characters)");
|
|
56
73
|
}
|
|
57
74
|
function checkSettings(s) {
|
|
58
75
|
const [min, max] = MAIL_BOUNDS.retentionDays;
|
|
59
|
-
if (!Number.isInteger(s.retentionDays) || s.retentionDays < min || s.retentionDays > max)
|
|
76
|
+
if (!Number.isInteger(s.retentionDays) || s.retentionDays < min || s.retentionDays > max)
|
|
77
|
+
throw new MailError(MAIL_CODE.badSettings, `retentionDays must be ${min} to ${max}`);
|
|
60
78
|
checkAddress(s.from, "from");
|
|
61
79
|
if (s.replyTo !== "") checkAddress(s.replyTo, "replyTo");
|
|
62
80
|
return s;
|
|
@@ -77,4 +95,4 @@ export {
|
|
|
77
95
|
checkMessage,
|
|
78
96
|
checkSettings
|
|
79
97
|
};
|
|
80
|
-
//# sourceMappingURL=chunk-
|
|
98
|
+
//# sourceMappingURL=chunk-XBNBPYT5.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/port.ts","../src/rules.ts"],"sourcesContent":["/**\n * The ports of « mail »: a message queued in the caller's transaction and delivered after the commit, once per idempotency key,\n * never to a suppressed address. Generic: no vendor SDK (providers are adapters over an injected `fetch`), no environment variable\n * (keys come in as parameters), no wall clock, no database client. The module takes mail ALREADY RENDERED: templates are\n * `@krizaka/email`'s (a UI package this one never imports), and its three ports are satisfied structurally by `emailPorts`.\n */\nexport type { Clock, Query } from \"@krizaka/db\";\n\n/** Why an address is never mailed again. The same values as `@krizaka/email`'s `SuppressionReason`. */\nexport const SUPPRESSION_REASON = { bounce: \"bounce\", complaint: \"complaint\", unsubscribed: \"unsubscribed\" } as const;\nexport type SuppressionReason = (typeof SUPPRESSION_REASON)[keyof typeof SUPPRESSION_REASON];\n\n/** The mail a product queues. */\nexport interface MailMessage {\n to: string;\n subject: string;\n text: string;\n html?: string;\n /** What kind of mail it is (`transactional`, `notification`, `digest`…): lower-case token, kept in the delivery log. */\n category: string;\n /** The template that rendered it, for the log (never the body). */\n template: string;\n locale: string;\n /** One key sends once: queueing it twice is one message, and delivering it twice sends one mail. */\n idempotencyKey: string;\n /** Overrides `mail.replyTo` for this message. */\n replyTo?: string;\n}\n\n/** A mail the provider receives: the message with the sender the settings give. */\nexport interface OutgoingMail extends MailMessage {\n from: string;\n}\n\n/** What the provider answered. */\nexport interface Sent {\n messageId: string | null;\n}\n\n/** One provider of mail (an adapter over `fetch`). It resolves on acceptance and throws on any failure: the outbox retries. */\nexport interface MailProvider {\n readonly name: string;\n send(mail: OutgoingMail): Promise<Sent>;\n}\n\nexport const DELIVERY_STATUS = {\n sent: \"sent\",\n failed: \"failed\",\n suppressed: \"suppressed\",\n duplicate: \"duplicate\",\n} as const;\nexport type DeliveryStatus = (typeof DELIVERY_STATUS)[keyof typeof DELIVERY_STATUS];\n\n/** One line of the delivery log: what, to whom (a hash, never the address), how it went. The shape of `@krizaka/email`'s `DeliveryLogEntry`. */\nexport interface DeliveryEntry {\n at?: string;\n template: string;\n category: string;\n locale: string;\n to: string;\n status: DeliveryStatus;\n messageId?: string | undefined;\n idempotencyKey?: string | undefined;\n detail?: string | undefined;\n}\n\n/** A line to write: the instant is a `Date`. */\nexport type DeliveryInput = Omit<DeliveryEntry, \"at\"> & { at: Date };\n\n/** A line as read back. */\nexport interface DeliveryRecord extends DeliveryEntry {\n id: number;\n at: string;\n}\n\n/** Where the module keeps its state. Every adapter runs `mailStoreContract` (`@krizaka/mail/testkit`). Addresses arrive hashed. */\nexport interface MailStore {\n record(entry: DeliveryInput): Promise<void>;\n recent(limit: number): Promise<DeliveryRecord[]>;\n isSuppressed(addressHash: string): Promise<boolean>;\n /** `false` when the address was already suppressed (the first reason stays). */\n suppress(addressHash: string, reason: SuppressionReason, now: Date): Promise<boolean>;\n /** Lifts a suppression (the person re-subscribed, or it was a mistake). */\n unsuppress(addressHash: string): Promise<boolean>;\n hasKey(key: string): Promise<boolean>;\n /** `true` the first time a key is claimed. */\n claimKey(key: string, now: Date): Promise<boolean>;\n releaseKey(key: string): Promise<void>;\n /** Removes the keys and log lines older than `before` (suppressions are kept for good). Returns how many. */\n purgeBefore(before: Date): Promise<number>;\n}\n\nexport interface MailSettings {\n provider: \"log\" | \"mailgun\" | \"resend\";\n /** The sender, `Name <address>` or an address. */\n from: string;\n /** Where replies go; empty for none. */\n replyTo: string;\n /** Days the delivery log and the idempotency keys are kept. */\n retentionDays: number;\n}\n\nexport const MAIL_CODE = {\n badAddress: \"MAIL_BAD_ADDRESS\",\n badMessage: \"MAIL_BAD_MESSAGE\",\n badSettings: \"MAIL_BAD_SETTINGS\",\n provider: \"MAIL_PROVIDER_FAILED\",\n badWebhook: \"MAIL_BAD_WEBHOOK\",\n} as const;\nexport type MailCode = (typeof MAIL_CODE)[keyof typeof MAIL_CODE];\n\nexport class MailError extends Error {\n constructor(\n readonly code: MailCode,\n message: string,\n ) {\n super(message);\n this.name = \"MailError\";\n }\n}\n","import { createHash } from \"node:crypto\";\n\nimport { MAIL_CODE, MailError, type MailMessage, type MailSettings } from \"./port\";\n\nexport const MAIL_BOUNDS = { retentionDays: [1, 3650] } as const;\nexport const MAIL_DEFAULTS: MailSettings = {\n provider: \"log\",\n from: \"Krizaka <no-reply@krizaka.com>\",\n replyTo: \"\",\n retentionDays: 90,\n};\n\nconst ADDRESS = /^[^\\s@<>]+@[^\\s@<>]+\\.[^\\s@<>]+$/;\nconst TOKEN = /^[a-z][a-z0-9_.-]{0,39}$/;\n// @fixed: RFC 5321's longest forward path\nconst MAX_ADDRESS = 254;\n// @fixed: a subject is one short line; a body past this is not a transactional mail\nconst MAX_SUBJECT = 200;\nconst MAX_BODY = 500_000;\nconst MAX_KEY = 200;\n\nexport const normalizeAddress = (email: string) => email.trim().toLowerCase();\n\n/** The full SHA-256 of an address: suppressions and the log never hold the address itself (Loi 25). */\nexport const hashAddress = (email: string) => createHash(\"sha256\").update(normalizeAddress(email)).digest(\"hex\");\n\n/** A stable UUID (version 5 shaped) from a seed: the same key queued twice is one outbox message. */\nexport function uuidOf(seed: string): string {\n const h = createHash(\"sha256\").update(`krizaka.mail:${seed}`).digest();\n h[6] = (h[6]! & 0x0f) | 0x50;\n h[8] = (h[8]! & 0x3f) | 0x80;\n const x = h.subarray(0, 16).toString(\"hex\");\n return `${x.slice(0, 8)}-${x.slice(8, 12)}-${x.slice(12, 16)}-${x.slice(16, 20)}-${x.slice(20, 32)}`;\n}\n\n/** The bare address of `Name <address>` or `address`. */\nexport function bareAddress(from: string): string {\n const match = /<([^<>]+)>\\s*$/.exec(from);\n return (match?.[1] ?? from).trim();\n}\n\nconst oneLine = (value: string) => !/[\\r\\n]/.test(value);\n\n/** @throws MailError when the address is not one mailbox on one line (no header injection). */\nexport function checkAddress(address: string, what = \"address\"): void {\n if (\n typeof address !== \"string\" ||\n address.length > MAX_ADDRESS ||\n !oneLine(address) ||\n !ADDRESS.test(bareAddress(address))\n )\n throw new MailError(MAIL_CODE.badAddress, `${what} is not a valid address`);\n}\n\n/** @throws MailError on the first thing wrong with a message. */\nexport function checkMessage(message: MailMessage): void {\n checkAddress(message.to, \"to\");\n if (message.replyTo !== undefined && message.replyTo !== \"\") checkAddress(message.replyTo, \"replyTo\");\n if (\n typeof message.subject !== \"string\" ||\n message.subject.trim() === \"\" ||\n message.subject.length > MAX_SUBJECT ||\n !oneLine(message.subject)\n )\n throw new MailError(MAIL_CODE.badMessage, \"subject must be one line of 1 to 200 characters\");\n if (typeof message.text !== \"string\" || message.text.trim() === \"\" || message.text.length > MAX_BODY)\n throw new MailError(MAIL_CODE.badMessage, \"text is required\");\n if (message.html !== undefined && (typeof message.html !== \"string\" || message.html.length > MAX_BODY))\n throw new MailError(MAIL_CODE.badMessage, \"html is too long\");\n if (!TOKEN.test(message.category) || !TOKEN.test(message.template))\n throw new MailError(MAIL_CODE.badMessage, \"category and template are lower-case tokens\");\n if (typeof message.locale !== \"string\" || !/^[a-z]{2}(-[A-Z]{2})?$/.test(message.locale))\n throw new MailError(MAIL_CODE.badMessage, \"locale is a language tag like fr or en-CA\");\n if (\n typeof message.idempotencyKey !== \"string\" ||\n message.idempotencyKey === \"\" ||\n message.idempotencyKey.length > MAX_KEY\n )\n throw new MailError(MAIL_CODE.badMessage, \"idempotencyKey is required (1 to 200 characters)\");\n}\n\n/** @throws MailError when the settings are out of bounds. */\nexport function checkSettings(s: MailSettings): MailSettings {\n const [min, max] = MAIL_BOUNDS.retentionDays;\n if (!Number.isInteger(s.retentionDays) || s.retentionDays < min || s.retentionDays > max)\n throw new MailError(MAIL_CODE.badSettings, `retentionDays must be ${min} to ${max}`);\n checkAddress(s.from, \"from\");\n if (s.replyTo !== \"\") checkAddress(s.replyTo, \"replyTo\");\n return s;\n}\n"],"mappings":"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","names":[]}
|
package/dist/index.js
CHANGED
|
@@ -12,12 +12,14 @@ import {
|
|
|
12
12
|
hashAddress,
|
|
13
13
|
normalizeAddress,
|
|
14
14
|
uuidOf
|
|
15
|
-
} from "./chunk-
|
|
15
|
+
} from "./chunk-XBNBPYT5.js";
|
|
16
16
|
|
|
17
17
|
// src/email-ports.ts
|
|
18
18
|
function emailPorts(store, clock) {
|
|
19
19
|
return {
|
|
20
|
-
log: {
|
|
20
|
+
log: {
|
|
21
|
+
record: (entry) => store.record({ ...entry, at: entry.at ? new Date(entry.at) : clock.now() })
|
|
22
|
+
},
|
|
21
23
|
suppression: {
|
|
22
24
|
has: (email) => store.isSuppressed(hashAddress(email)),
|
|
23
25
|
add: async (email, reason2) => {
|
|
@@ -44,7 +46,12 @@ function createMail(deps) {
|
|
|
44
46
|
async queue(tx, message) {
|
|
45
47
|
checkMessage(message);
|
|
46
48
|
const id = uuidOf(message.idempotencyKey);
|
|
47
|
-
const { appended } = await deps.outbox.append(tx, {
|
|
49
|
+
const { appended } = await deps.outbox.append(tx, {
|
|
50
|
+
topic: MAIL_TOPIC,
|
|
51
|
+
type: MAIL_EVENT.requested,
|
|
52
|
+
payload: message,
|
|
53
|
+
id
|
|
54
|
+
});
|
|
48
55
|
return { id, queued: appended };
|
|
49
56
|
},
|
|
50
57
|
async deliver(queued) {
|
|
@@ -52,7 +59,13 @@ function createMail(deps) {
|
|
|
52
59
|
const message = queued.payload;
|
|
53
60
|
checkMessage(message);
|
|
54
61
|
const hash = hashAddress(message.to);
|
|
55
|
-
const base = {
|
|
62
|
+
const base = {
|
|
63
|
+
template: message.template,
|
|
64
|
+
category: message.category,
|
|
65
|
+
locale: message.locale,
|
|
66
|
+
to: hash,
|
|
67
|
+
idempotencyKey: message.idempotencyKey
|
|
68
|
+
};
|
|
56
69
|
if (await deps.store.isSuppressed(hash)) {
|
|
57
70
|
await log({ ...base, status: DELIVERY_STATUS.suppressed });
|
|
58
71
|
return DELIVERY_STATUS.suppressed;
|
|
@@ -67,7 +80,12 @@ function createMail(deps) {
|
|
|
67
80
|
try {
|
|
68
81
|
const sent = await provider.send({ ...message, from: s.from, ...replyTo ? { replyTo } : {} });
|
|
69
82
|
await deps.store.claimKey(message.idempotencyKey, deps.clock.now());
|
|
70
|
-
await log({
|
|
83
|
+
await log({
|
|
84
|
+
...base,
|
|
85
|
+
status: DELIVERY_STATUS.sent,
|
|
86
|
+
messageId: sent.messageId ?? void 0,
|
|
87
|
+
detail: provider.name
|
|
88
|
+
});
|
|
71
89
|
return DELIVERY_STATUS.sent;
|
|
72
90
|
} catch (error) {
|
|
73
91
|
await log({ ...base, status: DELIVERY_STATUS.failed, detail: reason(error) });
|
|
@@ -143,17 +161,44 @@ function dbMailStore(query, schema = "mail") {
|
|
|
143
161
|
const s = ident(schema);
|
|
144
162
|
return {
|
|
145
163
|
async record(e) {
|
|
146
|
-
await query(
|
|
164
|
+
await query(
|
|
165
|
+
`INSERT INTO ${s}.delivery (at, template, category, locale, to_hash, status, message_id, idempotency_key, detail) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
|
|
166
|
+
[
|
|
167
|
+
e.at,
|
|
168
|
+
e.template,
|
|
169
|
+
e.category,
|
|
170
|
+
e.locale,
|
|
171
|
+
e.to,
|
|
172
|
+
e.status,
|
|
173
|
+
e.messageId ?? null,
|
|
174
|
+
e.idempotencyKey ?? null,
|
|
175
|
+
e.detail ?? null
|
|
176
|
+
]
|
|
177
|
+
);
|
|
147
178
|
},
|
|
148
179
|
async recent(limit) {
|
|
149
180
|
const rows = await query(`SELECT * FROM ${s}.delivery ORDER BY id DESC LIMIT $1`, [limit]);
|
|
150
|
-
return rows.map((r) => ({
|
|
181
|
+
return rows.map((r) => ({
|
|
182
|
+
id: Number(r.id),
|
|
183
|
+
at: r.at.toISOString(),
|
|
184
|
+
template: r.template,
|
|
185
|
+
category: r.category,
|
|
186
|
+
locale: r.locale,
|
|
187
|
+
to: r.to_hash,
|
|
188
|
+
status: r.status,
|
|
189
|
+
messageId: r.message_id ?? void 0,
|
|
190
|
+
idempotencyKey: r.idempotency_key ?? void 0,
|
|
191
|
+
detail: r.detail ?? void 0
|
|
192
|
+
}));
|
|
151
193
|
},
|
|
152
194
|
async isSuppressed(hash) {
|
|
153
195
|
return (await query(`SELECT 1 FROM ${s}.suppression WHERE address_hash = $1`, [hash])).length > 0;
|
|
154
196
|
},
|
|
155
197
|
async suppress(hash, reason2, now) {
|
|
156
|
-
return (await query(
|
|
198
|
+
return (await query(
|
|
199
|
+
`INSERT INTO ${s}.suppression (address_hash, reason, created_at) VALUES ($1,$2,$3) ON CONFLICT DO NOTHING RETURNING 1`,
|
|
200
|
+
[hash, reason2, now]
|
|
201
|
+
)).length > 0;
|
|
157
202
|
},
|
|
158
203
|
async unsuppress(hash) {
|
|
159
204
|
return (await query(`DELETE FROM ${s}.suppression WHERE address_hash = $1 RETURNING 1`, [hash])).length > 0;
|
|
@@ -162,7 +207,10 @@ function dbMailStore(query, schema = "mail") {
|
|
|
162
207
|
return (await query(`SELECT 1 FROM ${s}.idempotency WHERE key = $1`, [key])).length > 0;
|
|
163
208
|
},
|
|
164
209
|
async claimKey(key, now) {
|
|
165
|
-
return (await query(
|
|
210
|
+
return (await query(
|
|
211
|
+
`INSERT INTO ${s}.idempotency (key, created_at) VALUES ($1,$2) ON CONFLICT DO NOTHING RETURNING 1`,
|
|
212
|
+
[key, now]
|
|
213
|
+
)).length > 0;
|
|
166
214
|
},
|
|
167
215
|
async releaseKey(key) {
|
|
168
216
|
await query(`DELETE FROM ${s}.idempotency WHERE key = $1`, [key]);
|
|
@@ -200,7 +248,11 @@ function mailgunProvider(options) {
|
|
|
200
248
|
if (mail.html !== void 0) form.set("html", mail.html);
|
|
201
249
|
if (mail.replyTo) form.set("h:Reply-To", mail.replyTo);
|
|
202
250
|
form.set("v:idempotency-key", mail.idempotencyKey);
|
|
203
|
-
const res = await options.fetch(`${base}/v3/${encodeURIComponent(options.domain)}/messages`, {
|
|
251
|
+
const res = await options.fetch(`${base}/v3/${encodeURIComponent(options.domain)}/messages`, {
|
|
252
|
+
method: "POST",
|
|
253
|
+
headers: { authorization: auth, "content-type": "application/x-www-form-urlencoded" },
|
|
254
|
+
body: form.toString()
|
|
255
|
+
});
|
|
204
256
|
const body = await res.text();
|
|
205
257
|
if (!res.ok) throw failure("mailgun", res.status, body);
|
|
206
258
|
return { messageId: idOf(body, "id") };
|
|
@@ -212,8 +264,23 @@ function resendProvider(options) {
|
|
|
212
264
|
return {
|
|
213
265
|
name: "resend",
|
|
214
266
|
async send(mail) {
|
|
215
|
-
const body = {
|
|
216
|
-
|
|
267
|
+
const body = {
|
|
268
|
+
from: mail.from,
|
|
269
|
+
to: [mail.to],
|
|
270
|
+
subject: mail.subject,
|
|
271
|
+
text: mail.text,
|
|
272
|
+
...mail.html === void 0 ? {} : { html: mail.html },
|
|
273
|
+
...mail.replyTo ? { reply_to: mail.replyTo } : {}
|
|
274
|
+
};
|
|
275
|
+
const res = await options.fetch(`${base}/emails`, {
|
|
276
|
+
method: "POST",
|
|
277
|
+
headers: {
|
|
278
|
+
authorization: `Bearer ${options.apiKey}`,
|
|
279
|
+
"content-type": "application/json",
|
|
280
|
+
"idempotency-key": mail.idempotencyKey
|
|
281
|
+
},
|
|
282
|
+
body: JSON.stringify(body)
|
|
283
|
+
});
|
|
217
284
|
const answer = await res.text();
|
|
218
285
|
if (!res.ok) throw failure("resend", res.status, answer);
|
|
219
286
|
return { messageId: idOf(answer, "id") };
|
|
@@ -224,7 +291,17 @@ function logProvider(write) {
|
|
|
224
291
|
return {
|
|
225
292
|
name: "log",
|
|
226
293
|
async send(mail) {
|
|
227
|
-
write(
|
|
294
|
+
write(
|
|
295
|
+
JSON.stringify({
|
|
296
|
+
event: "mail.log",
|
|
297
|
+
from: bareAddress(mail.from),
|
|
298
|
+
template: mail.template,
|
|
299
|
+
category: mail.category,
|
|
300
|
+
locale: mail.locale,
|
|
301
|
+
subject: mail.subject,
|
|
302
|
+
idempotencyKey: mail.idempotencyKey
|
|
303
|
+
})
|
|
304
|
+
);
|
|
228
305
|
return { messageId: null };
|
|
229
306
|
}
|
|
230
307
|
};
|
|
@@ -232,9 +309,15 @@ function logProvider(write) {
|
|
|
232
309
|
function providerFor(kind, keys, fetch, write) {
|
|
233
310
|
if (kind === "log") return logProvider(write);
|
|
234
311
|
if (!keys.apiKey) throw new MailError(MAIL_CODE.badSettings, `mail.provider is ${kind} but its API key is not set`);
|
|
235
|
-
if (kind === "resend")
|
|
312
|
+
if (kind === "resend")
|
|
313
|
+
return resendProvider({ apiKey: keys.apiKey, fetch, ...keys.apiUrl ? { apiUrl: keys.apiUrl } : {} });
|
|
236
314
|
if (!keys.domain) throw new MailError(MAIL_CODE.badSettings, "mail.provider is mailgun but its domain is not set");
|
|
237
|
-
return mailgunProvider({
|
|
315
|
+
return mailgunProvider({
|
|
316
|
+
apiKey: keys.apiKey,
|
|
317
|
+
domain: keys.domain,
|
|
318
|
+
fetch,
|
|
319
|
+
...keys.apiUrl ? { apiUrl: keys.apiUrl } : {}
|
|
320
|
+
});
|
|
238
321
|
}
|
|
239
322
|
|
|
240
323
|
// src/schema.ts
|
|
@@ -295,7 +378,8 @@ function parseMailgunWebhook(rawBody, signingKey, nowSeconds) {
|
|
|
295
378
|
const timestamp = text(sig.timestamp);
|
|
296
379
|
const expected = createHmac("sha256", signingKey).update(timestamp + text(sig.token)).digest("hex");
|
|
297
380
|
if (!equal(expected, text(sig.signature))) throw bad("the signature does not match");
|
|
298
|
-
if (!/^\d+$/.test(timestamp) || Math.abs(nowSeconds - Number(timestamp)) > TOLERANCE_SECONDS)
|
|
381
|
+
if (!/^\d+$/.test(timestamp) || Math.abs(nowSeconds - Number(timestamp)) > TOLERANCE_SECONDS)
|
|
382
|
+
throw bad("the timestamp is stale");
|
|
299
383
|
const data = record(body["event-data"]);
|
|
300
384
|
const address = text(data.recipient);
|
|
301
385
|
if (address === "") return null;
|
|
@@ -308,7 +392,8 @@ function parseMailgunWebhook(rawBody, signingKey, nowSeconds) {
|
|
|
308
392
|
function parseResendWebhook(rawBody, headers, secret, nowSeconds) {
|
|
309
393
|
const { id, timestamp, signature } = headers;
|
|
310
394
|
if (!id || !timestamp || !signature) throw bad("the signature headers are missing");
|
|
311
|
-
if (!/^\d+$/.test(timestamp) || Math.abs(nowSeconds - Number(timestamp)) > TOLERANCE_SECONDS)
|
|
395
|
+
if (!/^\d+$/.test(timestamp) || Math.abs(nowSeconds - Number(timestamp)) > TOLERANCE_SECONDS)
|
|
396
|
+
throw bad("the timestamp is stale");
|
|
312
397
|
const key = Buffer.from(secret.startsWith("whsec_") ? secret.slice(6) : secret, "base64");
|
|
313
398
|
const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest("base64");
|
|
314
399
|
const ok = signature.split(" ").some((part) => part.startsWith("v1,") && equal(part.slice(3), expected));
|
|
@@ -323,7 +408,8 @@ function parseResendWebhook(rawBody, headers, secret, nowSeconds) {
|
|
|
323
408
|
const to = Array.isArray(data.to) ? data.to.filter((a) => typeof a === "string") : [];
|
|
324
409
|
const type = text(event.type);
|
|
325
410
|
if (type === "email.complained") return to.map((address) => ({ address, reason: SUPPRESSION_REASON.complaint }));
|
|
326
|
-
if (type === "email.bounced" && text(record(data.bounce).type).toLowerCase() !== "transient")
|
|
411
|
+
if (type === "email.bounced" && text(record(data.bounce).type).toLowerCase() !== "transient")
|
|
412
|
+
return to.map((address) => ({ address, reason: SUPPRESSION_REASON.bounce }));
|
|
327
413
|
return [];
|
|
328
414
|
}
|
|
329
415
|
export {
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/email-ports.ts","../src/mail.ts","../src/memory.ts","../src/persistence.ts","../src/providers.ts","../src/schema.ts","../src/webhooks.ts"],"sourcesContent":["import type { Clock, DeliveryEntry, MailStore, SuppressionReason } from \"./port\";\nimport { hashAddress } from \"./rules\";\n\n/**\n * The three ports of `@krizaka/email` (`DeliveryLog`, `SuppressionList`, `IdempotencyStore`) over this module's store, so a product\n * that calls `sendEmail` directly writes to the same tables as the queue. Structural: this package never imports `@krizaka/email`.\n */\nexport function emailPorts(store: MailStore, clock: Clock) {\n return {\n log: { record: (entry: Omit<DeliveryEntry, \"at\"> & { at?: string }) => store.record({ ...entry, at: entry.at ? new Date(entry.at) : clock.now() }) },\n suppression: {\n has: (email: string) => store.isSuppressed(hashAddress(email)),\n add: async (email: string, reason: SuppressionReason): Promise<void> => {\n await store.suppress(hashAddress(email), reason, clock.now());\n },\n },\n idempotency: {\n claim: (key: string) => store.claimKey(key, clock.now()),\n release: (key: string) => store.releaseKey(key),\n },\n };\n}\n\n","import { subHours } from \"@krizaka/time\";\n\nimport { type Clock, DELIVERY_STATUS, type DeliveryEntry, MAIL_CODE, MailError, type MailMessage, type MailProvider, type MailSettings, type MailStore, type Query } from \"./port\";\nimport { bareAddress, checkMessage, hashAddress, uuidOf } from \"./rules\";\nimport type { Suppression } from \"./webhooks\";\n\n/** The topic the mail is queued on, and the event that names it: the outbox routes by these. */\nexport const MAIL_TOPIC = \"mail\";\nexport const MAIL_EVENT = { requested: \"evt.mail.requested.v1\" } as const;\n\n/** What the module needs of `@krizaka/outbox` (its `Outbox` satisfies it): append in the caller's transaction. */\nexport interface MailOutbox {\n append(tx: Query, message: { topic: string; type: string; payload: unknown; id: string }): Promise<{ id: string; appended: boolean }>;\n}\n\n/** The part of an outbox message the deliverer reads (`OutboxMessage` satisfies it). */\nexport interface QueuedMail {\n id: string;\n payload: unknown;\n}\n\nexport interface MailDeps {\n clock: Clock;\n outbox: MailOutbox;\n /** The settings, read at each call so the admin's change applies at once. */\n settings: MailSettings | (() => MailSettings | Promise<MailSettings>);\n /** The state of the module over a query (a pool; a transaction is never needed). */\n store: MailStore;\n /** The provider the admin chose: a function so a change of `mail.provider` applies at once. */\n provider: MailProvider | ((settings: MailSettings) => MailProvider);\n}\n\nexport interface Mail {\n /** Queues a mail INSIDE the caller's transaction: it is sent after the commit, and only if the transaction commits. `queued` is `false` when the same key was already queued. */\n queue(tx: Query, message: MailMessage): Promise<{ id: string; queued: boolean }>;\n /** Delivers one queued mail: skipped if its address is suppressed or its key was already sent, thrown (for a retry) if the provider fails. Give it to `runWorker` / `drainOnce` as `deliver`. */\n deliver(message: QueuedMail): Promise<DeliveryEntry[\"status\"]>;\n /** A provider's webhook said this address bounced, complained or unsubscribed: it is never mailed again. */\n suppress(suppression: Suppression): Promise<boolean>;\n /** Lifts a suppression. */\n unsuppress(address: string): Promise<boolean>;\n isSuppressed(address: string): Promise<boolean>;\n /** Removes the log and the keys older than the retention. */\n purge(): Promise<number>;\n}\n\nconst isMessage = (payload: unknown): payload is MailMessage => typeof payload === \"object\" && payload !== null && \"to\" in payload && \"idempotencyKey\" in payload;\nconst reason = (error: unknown) => (error instanceof Error ? error.message : String(error)).slice(0, 300);\n\n/**\n * The mail of a product.\n *\n * The guarantee is AT LEAST ONCE: a mail is sent, THEN its key is claimed, so a crash in between sends it again rather than never\n * (a duplicate reset mail beats a lost one). A second delivery after the claim is a no-op.\n * @param deps the clock, the outbox, the settings, the store and the provider\n * @returns the mail functions\n */\nexport function createMail(deps: MailDeps): Mail {\n const settings = async () => (typeof deps.settings === \"function\" ? await deps.settings() : deps.settings);\n const log = (entry: Omit<DeliveryEntry, \"at\">) => deps.store.record({ ...entry, at: deps.clock.now() });\n return {\n async queue(tx, message) {\n checkMessage(message);\n const id = uuidOf(message.idempotencyKey);\n const { appended } = await deps.outbox.append(tx, { topic: MAIL_TOPIC, type: MAIL_EVENT.requested, payload: message, id });\n return { id, queued: appended };\n },\n async deliver(queued) {\n if (!isMessage(queued.payload)) throw new MailError(MAIL_CODE.badMessage, `message ${queued.id} is not a mail`);\n const message = queued.payload;\n checkMessage(message);\n const hash = hashAddress(message.to);\n const base = { template: message.template, category: message.category, locale: message.locale, to: hash, idempotencyKey: message.idempotencyKey };\n if (await deps.store.isSuppressed(hash)) {\n await log({ ...base, status: DELIVERY_STATUS.suppressed });\n return DELIVERY_STATUS.suppressed;\n }\n if (await deps.store.hasKey(message.idempotencyKey)) {\n await log({ ...base, status: DELIVERY_STATUS.duplicate });\n return DELIVERY_STATUS.duplicate;\n }\n const s = await settings();\n const provider = typeof deps.provider === \"function\" ? deps.provider(s) : deps.provider;\n const replyTo = message.replyTo || s.replyTo;\n try {\n const sent = await provider.send({ ...message, from: s.from, ...(replyTo ? { replyTo } : {}) });\n await deps.store.claimKey(message.idempotencyKey, deps.clock.now());\n await log({ ...base, status: DELIVERY_STATUS.sent, messageId: sent.messageId ?? undefined, detail: provider.name });\n return DELIVERY_STATUS.sent;\n } catch (error) {\n await log({ ...base, status: DELIVERY_STATUS.failed, detail: reason(error) });\n throw error;\n }\n },\n suppress: (s) => deps.store.suppress(hashAddress(bareAddress(s.address)), s.reason, deps.clock.now()),\n unsuppress: (address) => deps.store.unsuppress(hashAddress(bareAddress(address))),\n isSuppressed: (address) => deps.store.isSuppressed(hashAddress(bareAddress(address))),\n async purge() {\n const s = await settings();\n return deps.store.purgeBefore(subHours(deps.clock.now(), s.retentionDays * 24));\n },\n };\n}\n","import type { DeliveryRecord, MailStore, SuppressionReason } from \"./port\";\n\n/** An in-memory store, for unit tests and demos: the behaviour of the database's (the contract kit runs on both). */\nexport function memoryMailStore(): MailStore {\n const log: (DeliveryRecord & { when: Date })[] = [];\n const suppressed = new Map<string, SuppressionReason>();\n const keys = new Map<string, Date>();\n let seq = 0;\n return {\n async record({ at, ...entry }) {\n seq += 1;\n log.push({ ...entry, id: seq, at: at.toISOString(), when: at });\n },\n async recent(limit) {\n return log.slice(-limit).reverse().map(({ when: _when, ...row }) => row);\n },\n async isSuppressed(hash) {\n return suppressed.has(hash);\n },\n async suppress(hash, reason) {\n if (suppressed.has(hash)) return false;\n suppressed.set(hash, reason);\n return true;\n },\n async unsuppress(hash) {\n return suppressed.delete(hash);\n },\n async hasKey(key) {\n return keys.has(key);\n },\n async claimKey(key, now) {\n if (keys.has(key)) return false;\n keys.set(key, now);\n return true;\n },\n async releaseKey(key) {\n keys.delete(key);\n },\n async purgeBefore(before) {\n let n = 0;\n for (let i = log.length - 1; i >= 0; i -= 1) {\n if (log[i]!.when >= before) continue;\n log.splice(i, 1);\n n += 1;\n }\n for (const [key, at] of keys) {\n if (at >= before) continue;\n keys.delete(key);\n n += 1;\n }\n return n;\n },\n };\n}\n","import { ident, type Query } from \"@krizaka/db\";\n\nimport type { DeliveryRecord, MailStore, SuppressionReason } from \"./port\";\n\ninterface Row {\n id: string;\n at: Date;\n template: string;\n category: string;\n locale: string;\n to_hash: string;\n status: DeliveryRecord[\"status\"];\n message_id: string | null;\n idempotency_key: string | null;\n detail: string | null;\n}\n\n/**\n * The PostgreSQL store of « mail » over a `Query`, in schema `mail` (or the one the product renamed it to).\n * @param query a pool-level query\n * @param schema the Postgres schema, default `mail`\n */\nexport function dbMailStore(query: Query, schema = \"mail\"): MailStore {\n const s = ident(schema);\n return {\n async record(e) {\n await query(`INSERT INTO ${s}.delivery (at, template, category, locale, to_hash, status, message_id, idempotency_key, detail) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, [e.at, e.template, e.category, e.locale, e.to, e.status, e.messageId ?? null, e.idempotencyKey ?? null, e.detail ?? null]);\n },\n async recent(limit) {\n const rows = await query<Row>(`SELECT * FROM ${s}.delivery ORDER BY id DESC LIMIT $1`, [limit]);\n return rows.map((r) => ({ id: Number(r.id), at: r.at.toISOString(), template: r.template, category: r.category, locale: r.locale, to: r.to_hash, status: r.status, messageId: r.message_id ?? undefined, idempotencyKey: r.idempotency_key ?? undefined, detail: r.detail ?? undefined }));\n },\n async isSuppressed(hash) {\n return (await query(`SELECT 1 FROM ${s}.suppression WHERE address_hash = $1`, [hash])).length > 0;\n },\n async suppress(hash, reason: SuppressionReason, now) {\n return (await query(`INSERT INTO ${s}.suppression (address_hash, reason, created_at) VALUES ($1,$2,$3) ON CONFLICT DO NOTHING RETURNING 1`, [hash, reason, now])).length > 0;\n },\n async unsuppress(hash) {\n return (await query(`DELETE FROM ${s}.suppression WHERE address_hash = $1 RETURNING 1`, [hash])).length > 0;\n },\n async hasKey(key) {\n return (await query(`SELECT 1 FROM ${s}.idempotency WHERE key = $1`, [key])).length > 0;\n },\n async claimKey(key, now) {\n return (await query(`INSERT INTO ${s}.idempotency (key, created_at) VALUES ($1,$2) ON CONFLICT DO NOTHING RETURNING 1`, [key, now])).length > 0;\n },\n async releaseKey(key) {\n await query(`DELETE FROM ${s}.idempotency WHERE key = $1`, [key]);\n },\n async purgeBefore(before) {\n const logs = await query(`DELETE FROM ${s}.delivery WHERE at < $1 RETURNING 1`, [before]);\n const keys = await query(`DELETE FROM ${s}.idempotency WHERE created_at < $1 RETURNING 1`, [before]);\n return logs.length + keys.length;\n },\n };\n}\n","import { MAIL_CODE, MailError, type MailProvider, type MailSettings, type OutgoingMail } from \"./port\";\nimport { bareAddress } from \"./rules\";\n\n/** The part of `fetch` the providers use: inject the global one (`fetch`), or a fake in a test. */\nexport type FetchLike = (url: string, init: { method: \"POST\"; headers: Record<string, string>; body: string }) => Promise<{ ok: boolean; status: number; text(): Promise<string> }>;\n\n/** Trims trailing slashes without a regular expression (a long run of slashes must not be quadratic). */\nconst withoutTrailingSlashes = (url: string) => {\n let end = url.length;\n while (end > 0 && url[end - 1] === \"/\") end -= 1;\n return url.slice(0, end);\n};\n\nconst failure = (provider: string, status: number, body: string) => new MailError(MAIL_CODE.provider, `${provider} answered ${status}: ${body.slice(0, 200)}`);\n\nconst idOf = (body: string, key: string): string | null => {\n try {\n const value = (JSON.parse(body) as Record<string, unknown>)[key];\n return typeof value === \"string\" ? value : null;\n } catch {\n return null;\n }\n};\n\nexport interface MailgunOptions {\n apiKey: string;\n /** The sending domain. */\n domain: string;\n /** `https://api.mailgun.net` (default) or `https://api.eu.mailgun.net`. */\n apiUrl?: string;\n fetch: FetchLike;\n}\n\n/** Mailgun's messages API (form-encoded, basic auth). */\nexport function mailgunProvider(options: MailgunOptions): MailProvider {\n const base = withoutTrailingSlashes(options.apiUrl ?? \"https://api.mailgun.net\");\n const auth = `Basic ${Buffer.from(`api:${options.apiKey}`).toString(\"base64\")}`;\n return {\n name: \"mailgun\",\n async send(mail) {\n const form = new URLSearchParams({ from: mail.from, to: mail.to, subject: mail.subject, text: mail.text });\n if (mail.html !== undefined) form.set(\"html\", mail.html);\n if (mail.replyTo) form.set(\"h:Reply-To\", mail.replyTo);\n form.set(\"v:idempotency-key\", mail.idempotencyKey);\n const res = await options.fetch(`${base}/v3/${encodeURIComponent(options.domain)}/messages`, { method: \"POST\", headers: { authorization: auth, \"content-type\": \"application/x-www-form-urlencoded\" }, body: form.toString() });\n const body = await res.text();\n if (!res.ok) throw failure(\"mailgun\", res.status, body);\n return { messageId: idOf(body, \"id\") };\n },\n };\n}\n\nexport interface ResendOptions {\n apiKey: string;\n /** Default `https://api.resend.com`. */\n apiUrl?: string;\n fetch: FetchLike;\n}\n\n/** Resend's emails API (JSON, bearer key, its own `Idempotency-Key`). */\nexport function resendProvider(options: ResendOptions): MailProvider {\n const base = withoutTrailingSlashes(options.apiUrl ?? \"https://api.resend.com\");\n return {\n name: \"resend\",\n async send(mail) {\n const body = { from: mail.from, to: [mail.to], subject: mail.subject, text: mail.text, ...(mail.html === undefined ? {} : { html: mail.html }), ...(mail.replyTo ? { reply_to: mail.replyTo } : {}) };\n const res = await options.fetch(`${base}/emails`, { method: \"POST\", headers: { authorization: `Bearer ${options.apiKey}`, \"content-type\": \"application/json\", \"idempotency-key\": mail.idempotencyKey }, body: JSON.stringify(body) });\n const answer = await res.text();\n if (!res.ok) throw failure(\"resend\", res.status, answer);\n return { messageId: idOf(answer, \"id\") };\n },\n };\n}\n\n/** The development provider: writes one JSON line per mail (without its body) and sends nothing. */\nexport function logProvider(write: (line: string) => void): MailProvider {\n return {\n name: \"log\",\n async send(mail) {\n write(JSON.stringify({ event: \"mail.log\", from: bareAddress(mail.from), template: mail.template, category: mail.category, locale: mail.locale, subject: mail.subject, idempotencyKey: mail.idempotencyKey }));\n return { messageId: null };\n },\n };\n}\n\n/** The keys a provider needs: they stay in the environment, the product reads them and passes them here. */\nexport interface ProviderKeys {\n apiKey?: string;\n domain?: string;\n apiUrl?: string;\n}\n\n/**\n * The provider the admin chose (`mail.provider`). A provider chosen without its key is refused loudly, never replaced by `log`:\n * a reset mail must not vanish silently.\n * @throws MailError when the chosen provider lacks its key (or its domain, for Mailgun)\n */\nexport function providerFor(kind: MailSettings[\"provider\"], keys: ProviderKeys, fetch: FetchLike, write: (line: string) => void): MailProvider {\n if (kind === \"log\") return logProvider(write);\n if (!keys.apiKey) throw new MailError(MAIL_CODE.badSettings, `mail.provider is ${kind} but its API key is not set`);\n if (kind === \"resend\") return resendProvider({ apiKey: keys.apiKey, fetch, ...(keys.apiUrl ? { apiUrl: keys.apiUrl } : {}) });\n if (!keys.domain) throw new MailError(MAIL_CODE.badSettings, \"mail.provider is mailgun but its domain is not set\");\n return mailgunProvider({ apiKey: keys.apiKey, domain: keys.domain, fetch, ...(keys.apiUrl ? { apiUrl: keys.apiUrl } : {}) });\n}\n\nexport type { OutgoingMail };\n","import { defineModule } from \"@krizaka/db\";\n\n/**\n * The tables of « mail », in their own Postgres schema: `delivery` (the log, with the recipient as a hash, never a body),\n * `suppression` (the addresses never mailed again, as a SHA-256 hash: the list holds no address) and `idempotency` (the keys\n * already sent). They reference no product table. Version 1 is frozen once released: a change is version 2.\n */\nexport const mailSchema = defineModule({\n module: \"mail\",\n migrations: [\n {\n version: 1,\n name: \"delivery, suppression and idempotency\",\n sql: (c) => `\nCREATE TABLE ${c.schema}.delivery (\n id bigserial PRIMARY KEY,\n at timestamptz NOT NULL,\n template text NOT NULL,\n category text NOT NULL,\n locale text NOT NULL,\n to_hash text NOT NULL CHECK (char_length(to_hash) = 64),\n status text NOT NULL CHECK (status IN ('sent', 'failed', 'suppressed', 'duplicate')),\n message_id text,\n idempotency_key text,\n detail text\n);\nCREATE INDEX delivery_at ON ${c.schema}.delivery (at);\nCREATE TABLE ${c.schema}.suppression (\n address_hash text PRIMARY KEY CHECK (char_length(address_hash) = 64),\n reason text NOT NULL CHECK (reason IN ('bounce', 'complaint', 'unsubscribed')),\n created_at timestamptz NOT NULL\n);\nCREATE TABLE ${c.schema}.idempotency (\n key text PRIMARY KEY CHECK (char_length(key) BETWEEN 1 AND 200),\n created_at timestamptz NOT NULL\n);\nCREATE INDEX idempotency_created ON ${c.schema}.idempotency (created_at);`,\n },\n ],\n});\n","import { createHmac, timingSafeEqual } from \"node:crypto\";\n\nimport { MAIL_CODE, MailError, SUPPRESSION_REASON, type SuppressionReason } from \"./port\";\n\n/** What a provider's webhook tells us about one address. */\nexport interface Suppression {\n address: string;\n reason: SuppressionReason;\n}\n\n// @fixed: how far a webhook's timestamp may be from now, in seconds (replay protection; the providers' own recommendation is minutes)\nconst TOLERANCE_SECONDS = 300;\n\nconst equal = (a: string, b: string) => {\n const x = Buffer.from(a);\n const y = Buffer.from(b);\n return x.length === y.length && timingSafeEqual(x, y);\n};\nconst bad = (why: string) => new MailError(MAIL_CODE.badWebhook, why);\nconst record = (value: unknown): Record<string, unknown> => (typeof value === \"object\" && value !== null ? (value as Record<string, unknown>) : {});\nconst text = (value: unknown) => (typeof value === \"string\" ? value : \"\");\n\n/**\n * A Mailgun webhook (JSON body): checks the signature (HMAC-SHA256 of `timestamp + token` with the webhook signing key, hex) and\n * its age, then answers the address to suppress, or `null` for an event that is not a permanent bounce, a complaint or an unsubscribe.\n * @throws MailError when the body is not Mailgun's, the signature is wrong or the timestamp is stale\n */\nexport function parseMailgunWebhook(rawBody: string, signingKey: string, nowSeconds: number): Suppression | null {\n let body: Record<string, unknown>;\n try {\n body = record(JSON.parse(rawBody));\n } catch {\n throw bad(\"the body is not JSON\");\n }\n const sig = record(body.signature);\n const timestamp = text(sig.timestamp);\n const expected = createHmac(\"sha256\", signingKey).update(timestamp + text(sig.token)).digest(\"hex\");\n if (!equal(expected, text(sig.signature))) throw bad(\"the signature does not match\");\n if (!/^\\d+$/.test(timestamp) || Math.abs(nowSeconds - Number(timestamp)) > TOLERANCE_SECONDS) throw bad(\"the timestamp is stale\");\n const data = record(body[\"event-data\"]);\n const address = text(data.recipient);\n if (address === \"\") return null;\n const event = text(data.event);\n if (event === \"complained\") return { address, reason: SUPPRESSION_REASON.complaint };\n if (event === \"unsubscribed\") return { address, reason: SUPPRESSION_REASON.unsubscribed };\n if (event === \"failed\" && text(data.severity) === \"permanent\") return { address, reason: SUPPRESSION_REASON.bounce };\n return null;\n}\n\nexport interface SvixHeaders {\n id: string | null | undefined;\n timestamp: string | null | undefined;\n signature: string | null | undefined;\n}\n\n/**\n * A Resend webhook (signed the Svix way): HMAC-SHA256 over `id.timestamp.body` with the base64 secret that follows `whsec_`, any of\n * the space-separated `v1,<base64>` signatures matching. Answers the addresses to suppress (a hard bounce or a complaint).\n * @throws MailError when the headers are missing, the signature is wrong or the timestamp is stale\n */\nexport function parseResendWebhook(rawBody: string, headers: SvixHeaders, secret: string, nowSeconds: number): Suppression[] {\n const { id, timestamp, signature } = headers;\n if (!id || !timestamp || !signature) throw bad(\"the signature headers are missing\");\n if (!/^\\d+$/.test(timestamp) || Math.abs(nowSeconds - Number(timestamp)) > TOLERANCE_SECONDS) throw bad(\"the timestamp is stale\");\n const key = Buffer.from(secret.startsWith(\"whsec_\") ? secret.slice(6) : secret, \"base64\");\n const expected = createHmac(\"sha256\", key).update(`${id}.${timestamp}.${rawBody}`).digest(\"base64\");\n const ok = signature.split(\" \").some((part) => part.startsWith(\"v1,\") && equal(part.slice(3), expected));\n if (!ok) throw bad(\"the signature does not match\");\n let event: Record<string, unknown>;\n try {\n event = record(JSON.parse(rawBody));\n } catch {\n throw bad(\"the body is not JSON\");\n }\n const data = record(event.data);\n const to = Array.isArray(data.to) ? data.to.filter((a): a is string => typeof a === \"string\") : [];\n const type = text(event.type);\n if (type === \"email.complained\") return to.map((address) => ({ address, reason: SUPPRESSION_REASON.complaint }));\n if (type === \"email.bounced\" && text(record(data.bounce).type).toLowerCase() !== \"transient\") return to.map((address) => ({ address, reason: SUPPRESSION_REASON.bounce }));\n return [];\n}\n"],"mappings":"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","names":["reason","reason","reason"]}
|
|
1
|
+
{"version":3,"sources":["../src/email-ports.ts","../src/mail.ts","../src/memory.ts","../src/persistence.ts","../src/providers.ts","../src/schema.ts","../src/webhooks.ts"],"sourcesContent":["import type { Clock, DeliveryEntry, MailStore, SuppressionReason } from \"./port\";\nimport { hashAddress } from \"./rules\";\n\n/**\n * The three ports of `@krizaka/email` (`DeliveryLog`, `SuppressionList`, `IdempotencyStore`) over this module's store, so a product\n * that calls `sendEmail` directly writes to the same tables as the queue. Structural: this package never imports `@krizaka/email`.\n */\nexport function emailPorts(store: MailStore, clock: Clock) {\n return {\n log: {\n record: (entry: Omit<DeliveryEntry, \"at\"> & { at?: string }) =>\n store.record({ ...entry, at: entry.at ? new Date(entry.at) : clock.now() }),\n },\n suppression: {\n has: (email: string) => store.isSuppressed(hashAddress(email)),\n add: async (email: string, reason: SuppressionReason): Promise<void> => {\n await store.suppress(hashAddress(email), reason, clock.now());\n },\n },\n idempotency: {\n claim: (key: string) => store.claimKey(key, clock.now()),\n release: (key: string) => store.releaseKey(key),\n },\n };\n}\n","import { subHours } from \"@krizaka/time\";\n\nimport {\n type Clock,\n DELIVERY_STATUS,\n type DeliveryEntry,\n MAIL_CODE,\n MailError,\n type MailMessage,\n type MailProvider,\n type MailSettings,\n type MailStore,\n type Query,\n} from \"./port\";\nimport { bareAddress, checkMessage, hashAddress, uuidOf } from \"./rules\";\nimport type { Suppression } from \"./webhooks\";\n\n/** The topic the mail is queued on, and the event that names it: the outbox routes by these. */\nexport const MAIL_TOPIC = \"mail\";\nexport const MAIL_EVENT = { requested: \"evt.mail.requested.v1\" } as const;\n\n/** What the module needs of `@krizaka/outbox` (its `Outbox` satisfies it): append in the caller's transaction. */\nexport interface MailOutbox {\n append(\n tx: Query,\n message: { topic: string; type: string; payload: unknown; id: string },\n ): Promise<{ id: string; appended: boolean }>;\n}\n\n/** The part of an outbox message the deliverer reads (`OutboxMessage` satisfies it). */\nexport interface QueuedMail {\n id: string;\n payload: unknown;\n}\n\nexport interface MailDeps {\n clock: Clock;\n outbox: MailOutbox;\n /** The settings, read at each call so the admin's change applies at once. */\n settings: MailSettings | (() => MailSettings | Promise<MailSettings>);\n /** The state of the module over a query (a pool; a transaction is never needed). */\n store: MailStore;\n /** The provider the admin chose: a function so a change of `mail.provider` applies at once. */\n provider: MailProvider | ((settings: MailSettings) => MailProvider);\n}\n\nexport interface Mail {\n /** Queues a mail INSIDE the caller's transaction: it is sent after the commit, and only if the transaction commits. `queued` is `false` when the same key was already queued. */\n queue(tx: Query, message: MailMessage): Promise<{ id: string; queued: boolean }>;\n /** Delivers one queued mail: skipped if its address is suppressed or its key was already sent, thrown (for a retry) if the provider fails. Give it to `runWorker` / `drainOnce` as `deliver`. */\n deliver(message: QueuedMail): Promise<DeliveryEntry[\"status\"]>;\n /** A provider's webhook said this address bounced, complained or unsubscribed: it is never mailed again. */\n suppress(suppression: Suppression): Promise<boolean>;\n /** Lifts a suppression. */\n unsuppress(address: string): Promise<boolean>;\n isSuppressed(address: string): Promise<boolean>;\n /** Removes the log and the keys older than the retention. */\n purge(): Promise<number>;\n}\n\nconst isMessage = (payload: unknown): payload is MailMessage =>\n typeof payload === \"object\" && payload !== null && \"to\" in payload && \"idempotencyKey\" in payload;\nconst reason = (error: unknown) => (error instanceof Error ? error.message : String(error)).slice(0, 300);\n\n/**\n * The mail of a product.\n *\n * The guarantee is AT LEAST ONCE: a mail is sent, THEN its key is claimed, so a crash in between sends it again rather than never\n * (a duplicate reset mail beats a lost one). A second delivery after the claim is a no-op.\n * @param deps the clock, the outbox, the settings, the store and the provider\n * @returns the mail functions\n */\nexport function createMail(deps: MailDeps): Mail {\n const settings = async () => (typeof deps.settings === \"function\" ? await deps.settings() : deps.settings);\n const log = (entry: Omit<DeliveryEntry, \"at\">) => deps.store.record({ ...entry, at: deps.clock.now() });\n return {\n async queue(tx, message) {\n checkMessage(message);\n const id = uuidOf(message.idempotencyKey);\n const { appended } = await deps.outbox.append(tx, {\n topic: MAIL_TOPIC,\n type: MAIL_EVENT.requested,\n payload: message,\n id,\n });\n return { id, queued: appended };\n },\n async deliver(queued) {\n if (!isMessage(queued.payload)) throw new MailError(MAIL_CODE.badMessage, `message ${queued.id} is not a mail`);\n const message = queued.payload;\n checkMessage(message);\n const hash = hashAddress(message.to);\n const base = {\n template: message.template,\n category: message.category,\n locale: message.locale,\n to: hash,\n idempotencyKey: message.idempotencyKey,\n };\n if (await deps.store.isSuppressed(hash)) {\n await log({ ...base, status: DELIVERY_STATUS.suppressed });\n return DELIVERY_STATUS.suppressed;\n }\n if (await deps.store.hasKey(message.idempotencyKey)) {\n await log({ ...base, status: DELIVERY_STATUS.duplicate });\n return DELIVERY_STATUS.duplicate;\n }\n const s = await settings();\n const provider = typeof deps.provider === \"function\" ? deps.provider(s) : deps.provider;\n const replyTo = message.replyTo || s.replyTo;\n try {\n const sent = await provider.send({ ...message, from: s.from, ...(replyTo ? { replyTo } : {}) });\n await deps.store.claimKey(message.idempotencyKey, deps.clock.now());\n await log({\n ...base,\n status: DELIVERY_STATUS.sent,\n messageId: sent.messageId ?? undefined,\n detail: provider.name,\n });\n return DELIVERY_STATUS.sent;\n } catch (error) {\n await log({ ...base, status: DELIVERY_STATUS.failed, detail: reason(error) });\n throw error;\n }\n },\n suppress: (s) => deps.store.suppress(hashAddress(bareAddress(s.address)), s.reason, deps.clock.now()),\n unsuppress: (address) => deps.store.unsuppress(hashAddress(bareAddress(address))),\n isSuppressed: (address) => deps.store.isSuppressed(hashAddress(bareAddress(address))),\n async purge() {\n const s = await settings();\n return deps.store.purgeBefore(subHours(deps.clock.now(), s.retentionDays * 24));\n },\n };\n}\n","import type { DeliveryRecord, MailStore, SuppressionReason } from \"./port\";\n\n/** An in-memory store, for unit tests and demos: the behaviour of the database's (the contract kit runs on both). */\nexport function memoryMailStore(): MailStore {\n const log: (DeliveryRecord & { when: Date })[] = [];\n const suppressed = new Map<string, SuppressionReason>();\n const keys = new Map<string, Date>();\n let seq = 0;\n return {\n async record({ at, ...entry }) {\n seq += 1;\n log.push({ ...entry, id: seq, at: at.toISOString(), when: at });\n },\n async recent(limit) {\n return log\n .slice(-limit)\n .reverse()\n .map(({ when: _when, ...row }) => row);\n },\n async isSuppressed(hash) {\n return suppressed.has(hash);\n },\n async suppress(hash, reason) {\n if (suppressed.has(hash)) return false;\n suppressed.set(hash, reason);\n return true;\n },\n async unsuppress(hash) {\n return suppressed.delete(hash);\n },\n async hasKey(key) {\n return keys.has(key);\n },\n async claimKey(key, now) {\n if (keys.has(key)) return false;\n keys.set(key, now);\n return true;\n },\n async releaseKey(key) {\n keys.delete(key);\n },\n async purgeBefore(before) {\n let n = 0;\n for (let i = log.length - 1; i >= 0; i -= 1) {\n if (log[i]!.when >= before) continue;\n log.splice(i, 1);\n n += 1;\n }\n for (const [key, at] of keys) {\n if (at >= before) continue;\n keys.delete(key);\n n += 1;\n }\n return n;\n },\n };\n}\n","import { ident, type Query } from \"@krizaka/db\";\n\nimport type { DeliveryRecord, MailStore, SuppressionReason } from \"./port\";\n\ninterface Row {\n id: string;\n at: Date;\n template: string;\n category: string;\n locale: string;\n to_hash: string;\n status: DeliveryRecord[\"status\"];\n message_id: string | null;\n idempotency_key: string | null;\n detail: string | null;\n}\n\n/**\n * The PostgreSQL store of « mail » over a `Query`, in schema `mail` (or the one the product renamed it to).\n * @param query a pool-level query\n * @param schema the Postgres schema, default `mail`\n */\nexport function dbMailStore(query: Query, schema = \"mail\"): MailStore {\n const s = ident(schema);\n return {\n async record(e) {\n await query(\n `INSERT INTO ${s}.delivery (at, template, category, locale, to_hash, status, message_id, idempotency_key, detail) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,\n [\n e.at,\n e.template,\n e.category,\n e.locale,\n e.to,\n e.status,\n e.messageId ?? null,\n e.idempotencyKey ?? null,\n e.detail ?? null,\n ],\n );\n },\n async recent(limit) {\n const rows = await query<Row>(`SELECT * FROM ${s}.delivery ORDER BY id DESC LIMIT $1`, [limit]);\n return rows.map((r) => ({\n id: Number(r.id),\n at: r.at.toISOString(),\n template: r.template,\n category: r.category,\n locale: r.locale,\n to: r.to_hash,\n status: r.status,\n messageId: r.message_id ?? undefined,\n idempotencyKey: r.idempotency_key ?? undefined,\n detail: r.detail ?? undefined,\n }));\n },\n async isSuppressed(hash) {\n return (await query(`SELECT 1 FROM ${s}.suppression WHERE address_hash = $1`, [hash])).length > 0;\n },\n async suppress(hash, reason: SuppressionReason, now) {\n return (\n (\n await query(\n `INSERT INTO ${s}.suppression (address_hash, reason, created_at) VALUES ($1,$2,$3) ON CONFLICT DO NOTHING RETURNING 1`,\n [hash, reason, now],\n )\n ).length > 0\n );\n },\n async unsuppress(hash) {\n return (await query(`DELETE FROM ${s}.suppression WHERE address_hash = $1 RETURNING 1`, [hash])).length > 0;\n },\n async hasKey(key) {\n return (await query(`SELECT 1 FROM ${s}.idempotency WHERE key = $1`, [key])).length > 0;\n },\n async claimKey(key, now) {\n return (\n (\n await query(\n `INSERT INTO ${s}.idempotency (key, created_at) VALUES ($1,$2) ON CONFLICT DO NOTHING RETURNING 1`,\n [key, now],\n )\n ).length > 0\n );\n },\n async releaseKey(key) {\n await query(`DELETE FROM ${s}.idempotency WHERE key = $1`, [key]);\n },\n async purgeBefore(before) {\n const logs = await query(`DELETE FROM ${s}.delivery WHERE at < $1 RETURNING 1`, [before]);\n const keys = await query(`DELETE FROM ${s}.idempotency WHERE created_at < $1 RETURNING 1`, [before]);\n return logs.length + keys.length;\n },\n };\n}\n","import { MAIL_CODE, MailError, type MailProvider, type MailSettings, type OutgoingMail } from \"./port\";\nimport { bareAddress } from \"./rules\";\n\n/** The part of `fetch` the providers use: inject the global one (`fetch`), or a fake in a test. */\nexport type FetchLike = (\n url: string,\n init: { method: \"POST\"; headers: Record<string, string>; body: string },\n) => Promise<{ ok: boolean; status: number; text(): Promise<string> }>;\n\n/** Trims trailing slashes without a regular expression (a long run of slashes must not be quadratic). */\nconst withoutTrailingSlashes = (url: string) => {\n let end = url.length;\n while (end > 0 && url[end - 1] === \"/\") end -= 1;\n return url.slice(0, end);\n};\n\nconst failure = (provider: string, status: number, body: string) =>\n new MailError(MAIL_CODE.provider, `${provider} answered ${status}: ${body.slice(0, 200)}`);\n\nconst idOf = (body: string, key: string): string | null => {\n try {\n const value = (JSON.parse(body) as Record<string, unknown>)[key];\n return typeof value === \"string\" ? value : null;\n } catch {\n return null;\n }\n};\n\nexport interface MailgunOptions {\n apiKey: string;\n /** The sending domain. */\n domain: string;\n /** `https://api.mailgun.net` (default) or `https://api.eu.mailgun.net`. */\n apiUrl?: string;\n fetch: FetchLike;\n}\n\n/** Mailgun's messages API (form-encoded, basic auth). */\nexport function mailgunProvider(options: MailgunOptions): MailProvider {\n const base = withoutTrailingSlashes(options.apiUrl ?? \"https://api.mailgun.net\");\n const auth = `Basic ${Buffer.from(`api:${options.apiKey}`).toString(\"base64\")}`;\n return {\n name: \"mailgun\",\n async send(mail) {\n const form = new URLSearchParams({ from: mail.from, to: mail.to, subject: mail.subject, text: mail.text });\n if (mail.html !== undefined) form.set(\"html\", mail.html);\n if (mail.replyTo) form.set(\"h:Reply-To\", mail.replyTo);\n form.set(\"v:idempotency-key\", mail.idempotencyKey);\n const res = await options.fetch(`${base}/v3/${encodeURIComponent(options.domain)}/messages`, {\n method: \"POST\",\n headers: { authorization: auth, \"content-type\": \"application/x-www-form-urlencoded\" },\n body: form.toString(),\n });\n const body = await res.text();\n if (!res.ok) throw failure(\"mailgun\", res.status, body);\n return { messageId: idOf(body, \"id\") };\n },\n };\n}\n\nexport interface ResendOptions {\n apiKey: string;\n /** Default `https://api.resend.com`. */\n apiUrl?: string;\n fetch: FetchLike;\n}\n\n/** Resend's emails API (JSON, bearer key, its own `Idempotency-Key`). */\nexport function resendProvider(options: ResendOptions): MailProvider {\n const base = withoutTrailingSlashes(options.apiUrl ?? \"https://api.resend.com\");\n return {\n name: \"resend\",\n async send(mail) {\n const body = {\n from: mail.from,\n to: [mail.to],\n subject: mail.subject,\n text: mail.text,\n ...(mail.html === undefined ? {} : { html: mail.html }),\n ...(mail.replyTo ? { reply_to: mail.replyTo } : {}),\n };\n const res = await options.fetch(`${base}/emails`, {\n method: \"POST\",\n headers: {\n authorization: `Bearer ${options.apiKey}`,\n \"content-type\": \"application/json\",\n \"idempotency-key\": mail.idempotencyKey,\n },\n body: JSON.stringify(body),\n });\n const answer = await res.text();\n if (!res.ok) throw failure(\"resend\", res.status, answer);\n return { messageId: idOf(answer, \"id\") };\n },\n };\n}\n\n/** The development provider: writes one JSON line per mail (without its body) and sends nothing. */\nexport function logProvider(write: (line: string) => void): MailProvider {\n return {\n name: \"log\",\n async send(mail) {\n write(\n JSON.stringify({\n event: \"mail.log\",\n from: bareAddress(mail.from),\n template: mail.template,\n category: mail.category,\n locale: mail.locale,\n subject: mail.subject,\n idempotencyKey: mail.idempotencyKey,\n }),\n );\n return { messageId: null };\n },\n };\n}\n\n/** The keys a provider needs: they stay in the environment, the product reads them and passes them here. */\nexport interface ProviderKeys {\n apiKey?: string;\n domain?: string;\n apiUrl?: string;\n}\n\n/**\n * The provider the admin chose (`mail.provider`). A provider chosen without its key is refused loudly, never replaced by `log`:\n * a reset mail must not vanish silently.\n * @throws MailError when the chosen provider lacks its key (or its domain, for Mailgun)\n */\nexport function providerFor(\n kind: MailSettings[\"provider\"],\n keys: ProviderKeys,\n fetch: FetchLike,\n write: (line: string) => void,\n): MailProvider {\n if (kind === \"log\") return logProvider(write);\n if (!keys.apiKey) throw new MailError(MAIL_CODE.badSettings, `mail.provider is ${kind} but its API key is not set`);\n if (kind === \"resend\")\n return resendProvider({ apiKey: keys.apiKey, fetch, ...(keys.apiUrl ? { apiUrl: keys.apiUrl } : {}) });\n if (!keys.domain) throw new MailError(MAIL_CODE.badSettings, \"mail.provider is mailgun but its domain is not set\");\n return mailgunProvider({\n apiKey: keys.apiKey,\n domain: keys.domain,\n fetch,\n ...(keys.apiUrl ? { apiUrl: keys.apiUrl } : {}),\n });\n}\n\nexport type { OutgoingMail };\n","import { defineModule } from \"@krizaka/db\";\n\n/**\n * The tables of « mail », in their own Postgres schema: `delivery` (the log, with the recipient as a hash, never a body),\n * `suppression` (the addresses never mailed again, as a SHA-256 hash: the list holds no address) and `idempotency` (the keys\n * already sent). They reference no product table. Version 1 is frozen once released: a change is version 2.\n */\nexport const mailSchema = defineModule({\n module: \"mail\",\n migrations: [\n {\n version: 1,\n name: \"delivery, suppression and idempotency\",\n sql: (c) => `\nCREATE TABLE ${c.schema}.delivery (\n id bigserial PRIMARY KEY,\n at timestamptz NOT NULL,\n template text NOT NULL,\n category text NOT NULL,\n locale text NOT NULL,\n to_hash text NOT NULL CHECK (char_length(to_hash) = 64),\n status text NOT NULL CHECK (status IN ('sent', 'failed', 'suppressed', 'duplicate')),\n message_id text,\n idempotency_key text,\n detail text\n);\nCREATE INDEX delivery_at ON ${c.schema}.delivery (at);\nCREATE TABLE ${c.schema}.suppression (\n address_hash text PRIMARY KEY CHECK (char_length(address_hash) = 64),\n reason text NOT NULL CHECK (reason IN ('bounce', 'complaint', 'unsubscribed')),\n created_at timestamptz NOT NULL\n);\nCREATE TABLE ${c.schema}.idempotency (\n key text PRIMARY KEY CHECK (char_length(key) BETWEEN 1 AND 200),\n created_at timestamptz NOT NULL\n);\nCREATE INDEX idempotency_created ON ${c.schema}.idempotency (created_at);`,\n },\n ],\n});\n","import { createHmac, timingSafeEqual } from \"node:crypto\";\n\nimport { MAIL_CODE, MailError, SUPPRESSION_REASON, type SuppressionReason } from \"./port\";\n\n/** What a provider's webhook tells us about one address. */\nexport interface Suppression {\n address: string;\n reason: SuppressionReason;\n}\n\n// @fixed: how far a webhook's timestamp may be from now, in seconds (replay protection; the providers' own recommendation is minutes)\nconst TOLERANCE_SECONDS = 300;\n\nconst equal = (a: string, b: string) => {\n const x = Buffer.from(a);\n const y = Buffer.from(b);\n return x.length === y.length && timingSafeEqual(x, y);\n};\nconst bad = (why: string) => new MailError(MAIL_CODE.badWebhook, why);\nconst record = (value: unknown): Record<string, unknown> =>\n typeof value === \"object\" && value !== null ? (value as Record<string, unknown>) : {};\nconst text = (value: unknown) => (typeof value === \"string\" ? value : \"\");\n\n/**\n * A Mailgun webhook (JSON body): checks the signature (HMAC-SHA256 of `timestamp + token` with the webhook signing key, hex) and\n * its age, then answers the address to suppress, or `null` for an event that is not a permanent bounce, a complaint or an unsubscribe.\n * @throws MailError when the body is not Mailgun's, the signature is wrong or the timestamp is stale\n */\nexport function parseMailgunWebhook(rawBody: string, signingKey: string, nowSeconds: number): Suppression | null {\n let body: Record<string, unknown>;\n try {\n body = record(JSON.parse(rawBody));\n } catch {\n throw bad(\"the body is not JSON\");\n }\n const sig = record(body.signature);\n const timestamp = text(sig.timestamp);\n const expected = createHmac(\"sha256\", signingKey)\n .update(timestamp + text(sig.token))\n .digest(\"hex\");\n if (!equal(expected, text(sig.signature))) throw bad(\"the signature does not match\");\n if (!/^\\d+$/.test(timestamp) || Math.abs(nowSeconds - Number(timestamp)) > TOLERANCE_SECONDS)\n throw bad(\"the timestamp is stale\");\n const data = record(body[\"event-data\"]);\n const address = text(data.recipient);\n if (address === \"\") return null;\n const event = text(data.event);\n if (event === \"complained\") return { address, reason: SUPPRESSION_REASON.complaint };\n if (event === \"unsubscribed\") return { address, reason: SUPPRESSION_REASON.unsubscribed };\n if (event === \"failed\" && text(data.severity) === \"permanent\") return { address, reason: SUPPRESSION_REASON.bounce };\n return null;\n}\n\nexport interface SvixHeaders {\n id: string | null | undefined;\n timestamp: string | null | undefined;\n signature: string | null | undefined;\n}\n\n/**\n * A Resend webhook (signed the Svix way): HMAC-SHA256 over `id.timestamp.body` with the base64 secret that follows `whsec_`, any of\n * the space-separated `v1,<base64>` signatures matching. Answers the addresses to suppress (a hard bounce or a complaint).\n * @throws MailError when the headers are missing, the signature is wrong or the timestamp is stale\n */\nexport function parseResendWebhook(\n rawBody: string,\n headers: SvixHeaders,\n secret: string,\n nowSeconds: number,\n): Suppression[] {\n const { id, timestamp, signature } = headers;\n if (!id || !timestamp || !signature) throw bad(\"the signature headers are missing\");\n if (!/^\\d+$/.test(timestamp) || Math.abs(nowSeconds - Number(timestamp)) > TOLERANCE_SECONDS)\n throw bad(\"the timestamp is stale\");\n const key = Buffer.from(secret.startsWith(\"whsec_\") ? secret.slice(6) : secret, \"base64\");\n const expected = createHmac(\"sha256\", key).update(`${id}.${timestamp}.${rawBody}`).digest(\"base64\");\n const ok = signature.split(\" \").some((part) => part.startsWith(\"v1,\") && equal(part.slice(3), expected));\n if (!ok) throw bad(\"the signature does not match\");\n let event: Record<string, unknown>;\n try {\n event = record(JSON.parse(rawBody));\n } catch {\n throw bad(\"the body is not JSON\");\n }\n const data = record(event.data);\n const to = Array.isArray(data.to) ? data.to.filter((a): a is string => typeof a === \"string\") : [];\n const type = text(event.type);\n if (type === \"email.complained\") return to.map((address) => ({ address, reason: SUPPRESSION_REASON.complaint }));\n if (type === \"email.bounced\" && text(record(data.bounce).type).toLowerCase() !== \"transient\")\n return to.map((address) => ({ address, reason: SUPPRESSION_REASON.bounce }));\n return [];\n}\n"],"mappings":"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","names":["reason","reason","reason"]}
|
package/dist/settings.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
MAIL_BOUNDS,
|
|
3
3
|
MAIL_DEFAULTS
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-XBNBPYT5.js";
|
|
5
5
|
|
|
6
6
|
// src/settings.ts
|
|
7
7
|
import { choice, integer, text } from "@krizaka/settings";
|
|
@@ -10,7 +10,11 @@ var mailSettings = {
|
|
|
10
10
|
"mail.provider": choice("mail", PROVIDERS, MAIL_DEFAULTS.provider),
|
|
11
11
|
"mail.from": text("mail", MAIL_DEFAULTS.from),
|
|
12
12
|
"mail.replyTo": text("mail", MAIL_DEFAULTS.replyTo, { empty: true }),
|
|
13
|
-
"mail.retentionDays": integer("mail", MAIL_DEFAULTS.retentionDays, {
|
|
13
|
+
"mail.retentionDays": integer("mail", MAIL_DEFAULTS.retentionDays, {
|
|
14
|
+
min: MAIL_BOUNDS.retentionDays[0],
|
|
15
|
+
max: MAIL_BOUNDS.retentionDays[1],
|
|
16
|
+
unit: "days"
|
|
17
|
+
})
|
|
14
18
|
};
|
|
15
19
|
async function readMailSettings(read) {
|
|
16
20
|
const provider = await read("mail.provider");
|
package/dist/settings.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/settings.ts"],"sourcesContent":["import { choice, integer, type Registry, text } from \"@krizaka/settings\";\n\nimport type { MailSettings } from \"./port\";\nimport { MAIL_BOUNDS, MAIL_DEFAULTS } from \"./rules\";\n\nconst PROVIDERS = [\"log\", \"mailgun\", \"resend\"] as const;\n\n/**\n * The settings of « mail », a fragment the product adds to its registry (`@krizaka/mail/settings`): the admin picks the provider and\n * the sender without a redeploy. Secrets (the provider's key, domain and webhook signing keys) stay in the environment.\n */\nexport const mailSettings = {\n \"mail.provider\": choice(\"mail\", PROVIDERS, MAIL_DEFAULTS.provider),\n \"mail.from\": text(\"mail\", MAIL_DEFAULTS.from),\n \"mail.replyTo\": text(\"mail\", MAIL_DEFAULTS.replyTo, { empty: true }),\n \"mail.retentionDays\": integer(\"mail\", MAIL_DEFAULTS.retentionDays, {
|
|
1
|
+
{"version":3,"sources":["../src/settings.ts"],"sourcesContent":["import { choice, integer, type Registry, text } from \"@krizaka/settings\";\n\nimport type { MailSettings } from \"./port\";\nimport { MAIL_BOUNDS, MAIL_DEFAULTS } from \"./rules\";\n\nconst PROVIDERS = [\"log\", \"mailgun\", \"resend\"] as const;\n\n/**\n * The settings of « mail », a fragment the product adds to its registry (`@krizaka/mail/settings`): the admin picks the provider and\n * the sender without a redeploy. Secrets (the provider's key, domain and webhook signing keys) stay in the environment.\n */\nexport const mailSettings = {\n \"mail.provider\": choice(\"mail\", PROVIDERS, MAIL_DEFAULTS.provider),\n \"mail.from\": text(\"mail\", MAIL_DEFAULTS.from),\n \"mail.replyTo\": text(\"mail\", MAIL_DEFAULTS.replyTo, { empty: true }),\n \"mail.retentionDays\": integer(\"mail\", MAIL_DEFAULTS.retentionDays, {\n min: MAIL_BOUNDS.retentionDays[0],\n max: MAIL_BOUNDS.retentionDays[1],\n unit: \"days\",\n }),\n} as const satisfies Registry;\n\ntype Reader = (key: keyof typeof mailSettings) => string | number | undefined | Promise<string | number | undefined>;\n\n/** The module's settings as `createMail` takes them, from the product's accessor (`(key) => service.get(key, ctx)`). A key the accessor does not know falls back to the default. */\nexport async function readMailSettings(read: Reader): Promise<MailSettings> {\n const provider = await read(\"mail.provider\");\n const from = await read(\"mail.from\");\n const replyTo = await read(\"mail.replyTo\");\n const retention = await read(\"mail.retentionDays\");\n return {\n provider: PROVIDERS.find((p) => p === provider) ?? MAIL_DEFAULTS.provider,\n from: typeof from === \"string\" && from !== \"\" ? from : MAIL_DEFAULTS.from,\n replyTo: typeof replyTo === \"string\" ? replyTo : MAIL_DEFAULTS.replyTo,\n retentionDays: typeof retention === \"number\" ? retention : MAIL_DEFAULTS.retentionDays,\n };\n}\n"],"mappings":";;;;;;AAAA,SAAS,QAAQ,SAAwB,YAAY;AAKrD,IAAM,YAAY,CAAC,OAAO,WAAW,QAAQ;AAMtC,IAAM,eAAe;AAAA,EAC1B,iBAAiB,OAAO,QAAQ,WAAW,cAAc,QAAQ;AAAA,EACjE,aAAa,KAAK,QAAQ,cAAc,IAAI;AAAA,EAC5C,gBAAgB,KAAK,QAAQ,cAAc,SAAS,EAAE,OAAO,KAAK,CAAC;AAAA,EACnE,sBAAsB,QAAQ,QAAQ,cAAc,eAAe;AAAA,IACjE,KAAK,YAAY,cAAc,CAAC;AAAA,IAChC,KAAK,YAAY,cAAc,CAAC;AAAA,IAChC,MAAM;AAAA,EACR,CAAC;AACH;AAKA,eAAsB,iBAAiB,MAAqC;AAC1E,QAAM,WAAW,MAAM,KAAK,eAAe;AAC3C,QAAM,OAAO,MAAM,KAAK,WAAW;AACnC,QAAM,UAAU,MAAM,KAAK,cAAc;AACzC,QAAM,YAAY,MAAM,KAAK,oBAAoB;AACjD,SAAO;AAAA,IACL,UAAU,UAAU,KAAK,CAAC,MAAM,MAAM,QAAQ,KAAK,cAAc;AAAA,IACjE,MAAM,OAAO,SAAS,YAAY,SAAS,KAAK,OAAO,cAAc;AAAA,IACrE,SAAS,OAAO,YAAY,WAAW,UAAU,cAAc;AAAA,IAC/D,eAAe,OAAO,cAAc,WAAW,YAAY,cAAc;AAAA,EAC3E;AACF;","names":[]}
|
package/dist/testkit.js
CHANGED
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
import {
|
|
2
2
|
SUPPRESSION_REASON,
|
|
3
3
|
hashAddress
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-XBNBPYT5.js";
|
|
5
5
|
|
|
6
6
|
// src/testkit.ts
|
|
7
7
|
import { expect, it } from "vitest";
|
|
8
8
|
var T0 = /* @__PURE__ */ new Date("2026-10-26T12:00:00.000Z");
|
|
9
9
|
var day = (n) => new Date(T0.valueOf() + n * 24 * 3600 * 1e3);
|
|
10
|
-
var entry = (over = {}) => ({
|
|
10
|
+
var entry = (over = {}) => ({
|
|
11
|
+
at: T0,
|
|
12
|
+
template: "reset",
|
|
13
|
+
category: "transactional",
|
|
14
|
+
locale: "fr",
|
|
15
|
+
to: hashAddress("a@b.co"),
|
|
16
|
+
status: "sent",
|
|
17
|
+
...over
|
|
18
|
+
});
|
|
11
19
|
function mailStoreContract(name, make) {
|
|
12
20
|
it(`${name}: suppresses an address once, keeps the first reason, and lifts it`, async () => {
|
|
13
21
|
const store = await make();
|
|
@@ -40,7 +48,13 @@ function mailStoreContract(name, make) {
|
|
|
40
48
|
await store.record(entry({ status: "failed", detail: "mailgun answered 500" }));
|
|
41
49
|
await store.record(entry({ at: day(1), status: "sent", messageId: "<m1>", idempotencyKey: "k" }));
|
|
42
50
|
const [first, second] = await store.recent(10);
|
|
43
|
-
expect(first).toMatchObject({
|
|
51
|
+
expect(first).toMatchObject({
|
|
52
|
+
status: "sent",
|
|
53
|
+
messageId: "<m1>",
|
|
54
|
+
idempotencyKey: "k",
|
|
55
|
+
to: hashAddress("a@b.co"),
|
|
56
|
+
at: day(1).toISOString()
|
|
57
|
+
});
|
|
44
58
|
expect(second).toMatchObject({ status: "failed", detail: "mailgun answered 500" });
|
|
45
59
|
expect(JSON.stringify(await store.recent(10))).not.toContain("a@b.co");
|
|
46
60
|
expect(await store.recent(1)).toHaveLength(1);
|
package/dist/testkit.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/testkit.ts"],"sourcesContent":["import { expect, it } from \"vitest\";\n\nimport { type DeliveryInput, type MailStore, SUPPRESSION_REASON } from \"./port\";\nimport { hashAddress } from \"./rules\";\n\nexport const T0 = new Date(\"2026-10-26T12:00:00.000Z\");\nconst day = (n: number) => new Date(T0.valueOf() + n * 24 * 3600 * 1000);\nconst entry = (over: Partial<DeliveryInput> = {}) => ({
|
|
1
|
+
{"version":3,"sources":["../src/testkit.ts"],"sourcesContent":["import { expect, it } from \"vitest\";\n\nimport { type DeliveryInput, type MailStore, SUPPRESSION_REASON } from \"./port\";\nimport { hashAddress } from \"./rules\";\n\nexport const T0 = new Date(\"2026-10-26T12:00:00.000Z\");\nconst day = (n: number) => new Date(T0.valueOf() + n * 24 * 3600 * 1000);\nconst entry = (over: Partial<DeliveryInput> = {}) => ({\n at: T0,\n template: \"reset\",\n category: \"transactional\",\n locale: \"fr\",\n to: hashAddress(\"a@b.co\"),\n status: \"sent\" as const,\n ...over,\n});\n\n/** What every `MailStore` adapter must do: the same behaviour behind the same port. `make` gives an empty store. */\nexport function mailStoreContract(name: string, make: () => MailStore | Promise<MailStore>) {\n it(`${name}: suppresses an address once, keeps the first reason, and lifts it`, async () => {\n const store = await make();\n const h = hashAddress(\"A@B.co\");\n expect(await store.isSuppressed(h)).toBe(false);\n expect(await store.suppress(h, SUPPRESSION_REASON.bounce, T0)).toBe(true);\n expect(await store.suppress(h, SUPPRESSION_REASON.complaint, T0)).toBe(false);\n expect(await store.isSuppressed(hashAddress(\" a@b.co \"))).toBe(true);\n expect(await store.unsuppress(h)).toBe(true);\n expect(await store.unsuppress(h)).toBe(false);\n expect(await store.isSuppressed(h)).toBe(false);\n });\n\n it(`${name}: claims an idempotency key once, and gives it back`, async () => {\n const store = await make();\n expect(await store.hasKey(\"k1\")).toBe(false);\n expect(await store.claimKey(\"k1\", T0)).toBe(true);\n expect(await store.claimKey(\"k1\", T0)).toBe(false);\n expect(await store.hasKey(\"k1\")).toBe(true);\n await store.releaseKey(\"k1\");\n expect(await store.hasKey(\"k1\")).toBe(false);\n expect(await store.claimKey(\"k1\", T0)).toBe(true);\n });\n\n it(`${name}: claims a key once under concurrency`, async () => {\n const store = await make();\n const results = await Promise.all(Array.from({ length: 20 }, () => store.claimKey(\"race\", T0)));\n expect(results.filter(Boolean)).toHaveLength(1);\n });\n\n it(`${name}: logs newest first, with a hash and never an address`, async () => {\n const store = await make();\n await store.record(entry({ status: \"failed\", detail: \"mailgun answered 500\" }));\n await store.record(entry({ at: day(1), status: \"sent\", messageId: \"<m1>\", idempotencyKey: \"k\" }));\n const [first, second] = await store.recent(10);\n expect(first).toMatchObject({\n status: \"sent\",\n messageId: \"<m1>\",\n idempotencyKey: \"k\",\n to: hashAddress(\"a@b.co\"),\n at: day(1).toISOString(),\n });\n expect(second).toMatchObject({ status: \"failed\", detail: \"mailgun answered 500\" });\n expect(JSON.stringify(await store.recent(10))).not.toContain(\"a@b.co\");\n expect(await store.recent(1)).toHaveLength(1);\n });\n\n it(`${name}: purges the log and the keys before a date, and keeps the suppressions`, async () => {\n const store = await make();\n const h = hashAddress(\"x@y.co\");\n await store.record(entry());\n await store.record(entry({ at: day(60) }));\n await store.claimKey(\"old\", T0);\n await store.claimKey(\"new\", day(60));\n await store.suppress(h, SUPPRESSION_REASON.unsubscribed, T0);\n expect(await store.purgeBefore(day(30))).toBe(2);\n expect(await store.recent(10)).toHaveLength(1);\n expect(await store.hasKey(\"old\")).toBe(false);\n expect(await store.hasKey(\"new\")).toBe(true);\n expect(await store.isSuppressed(h)).toBe(true);\n });\n}\n"],"mappings":"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","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@krizaka/mail",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Mail for every Krizaka product: queued in the caller's transaction through the outbox, delivered once per idempotency key, never to a suppressed address (stored as a hash); providers (Mailgun, Resend, log) over an injected fetch; bounces and complaints from signed webhooks. No vendor SDK, no environment variable, no wall clock.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"krizaka",
|
|
@@ -57,13 +57,13 @@
|
|
|
57
57
|
"typescript": "^5.9.3",
|
|
58
58
|
"vitest": "^4.1.11",
|
|
59
59
|
"zod": "^4.6.7",
|
|
60
|
-
"@krizaka/config": "^0.
|
|
61
|
-
"@krizaka/outbox": "^0.1.
|
|
60
|
+
"@krizaka/config": "^0.21.0",
|
|
61
|
+
"@krizaka/outbox": "^0.1.5"
|
|
62
62
|
},
|
|
63
63
|
"dependencies": {
|
|
64
64
|
"@krizaka/db": "^0.3.1",
|
|
65
|
-
"@krizaka/
|
|
66
|
-
"@krizaka/
|
|
65
|
+
"@krizaka/time": "^0.2.0",
|
|
66
|
+
"@krizaka/settings": "^0.7.0"
|
|
67
67
|
},
|
|
68
68
|
"peerDependencies": {
|
|
69
69
|
"vitest": ">=4",
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/port.ts","../src/rules.ts"],"sourcesContent":["/**\n * The ports of « mail »: a message queued in the caller's transaction and delivered after the commit, once per idempotency key,\n * never to a suppressed address. Generic: no vendor SDK (providers are adapters over an injected `fetch`), no environment variable\n * (keys come in as parameters), no wall clock, no database client. The module takes mail ALREADY RENDERED: templates are\n * `@krizaka/email`'s (a UI package this one never imports), and its three ports are satisfied structurally by `emailPorts`.\n */\nexport type { Clock, Query } from \"@krizaka/db\";\n\n/** Why an address is never mailed again. The same values as `@krizaka/email`'s `SuppressionReason`. */\nexport const SUPPRESSION_REASON = { bounce: \"bounce\", complaint: \"complaint\", unsubscribed: \"unsubscribed\" } as const;\nexport type SuppressionReason = (typeof SUPPRESSION_REASON)[keyof typeof SUPPRESSION_REASON];\n\n/** The mail a product queues. */\nexport interface MailMessage {\n to: string;\n subject: string;\n text: string;\n html?: string;\n /** What kind of mail it is (`transactional`, `notification`, `digest`…): lower-case token, kept in the delivery log. */\n category: string;\n /** The template that rendered it, for the log (never the body). */\n template: string;\n locale: string;\n /** One key sends once: queueing it twice is one message, and delivering it twice sends one mail. */\n idempotencyKey: string;\n /** Overrides `mail.replyTo` for this message. */\n replyTo?: string;\n}\n\n/** A mail the provider receives: the message with the sender the settings give. */\nexport interface OutgoingMail extends MailMessage {\n from: string;\n}\n\n/** What the provider answered. */\nexport interface Sent {\n messageId: string | null;\n}\n\n/** One provider of mail (an adapter over `fetch`). It resolves on acceptance and throws on any failure: the outbox retries. */\nexport interface MailProvider {\n readonly name: string;\n send(mail: OutgoingMail): Promise<Sent>;\n}\n\nexport const DELIVERY_STATUS = { sent: \"sent\", failed: \"failed\", suppressed: \"suppressed\", duplicate: \"duplicate\" } as const;\nexport type DeliveryStatus = (typeof DELIVERY_STATUS)[keyof typeof DELIVERY_STATUS];\n\n/** One line of the delivery log: what, to whom (a hash, never the address), how it went. The shape of `@krizaka/email`'s `DeliveryLogEntry`. */\nexport interface DeliveryEntry {\n at?: string;\n template: string;\n category: string;\n locale: string;\n to: string;\n status: DeliveryStatus;\n messageId?: string | undefined;\n idempotencyKey?: string | undefined;\n detail?: string | undefined;\n}\n\n/** A line to write: the instant is a `Date`. */\nexport type DeliveryInput = Omit<DeliveryEntry, \"at\"> & { at: Date };\n\n/** A line as read back. */\nexport interface DeliveryRecord extends DeliveryEntry {\n id: number;\n at: string;\n}\n\n/** Where the module keeps its state. Every adapter runs `mailStoreContract` (`@krizaka/mail/testkit`). Addresses arrive hashed. */\nexport interface MailStore {\n record(entry: DeliveryInput): Promise<void>;\n recent(limit: number): Promise<DeliveryRecord[]>;\n isSuppressed(addressHash: string): Promise<boolean>;\n /** `false` when the address was already suppressed (the first reason stays). */\n suppress(addressHash: string, reason: SuppressionReason, now: Date): Promise<boolean>;\n /** Lifts a suppression (the person re-subscribed, or it was a mistake). */\n unsuppress(addressHash: string): Promise<boolean>;\n hasKey(key: string): Promise<boolean>;\n /** `true` the first time a key is claimed. */\n claimKey(key: string, now: Date): Promise<boolean>;\n releaseKey(key: string): Promise<void>;\n /** Removes the keys and log lines older than `before` (suppressions are kept for good). Returns how many. */\n purgeBefore(before: Date): Promise<number>;\n}\n\nexport interface MailSettings {\n provider: \"log\" | \"mailgun\" | \"resend\";\n /** The sender, `Name <address>` or an address. */\n from: string;\n /** Where replies go; empty for none. */\n replyTo: string;\n /** Days the delivery log and the idempotency keys are kept. */\n retentionDays: number;\n}\n\nexport const MAIL_CODE = {\n badAddress: \"MAIL_BAD_ADDRESS\",\n badMessage: \"MAIL_BAD_MESSAGE\",\n badSettings: \"MAIL_BAD_SETTINGS\",\n provider: \"MAIL_PROVIDER_FAILED\",\n badWebhook: \"MAIL_BAD_WEBHOOK\",\n} as const;\nexport type MailCode = (typeof MAIL_CODE)[keyof typeof MAIL_CODE];\n\nexport class MailError extends Error {\n constructor(\n readonly code: MailCode,\n message: string,\n ) {\n super(message);\n this.name = \"MailError\";\n }\n}\n","import { createHash } from \"node:crypto\";\n\nimport { MAIL_CODE, MailError, type MailMessage, type MailSettings } from \"./port\";\n\nexport const MAIL_BOUNDS = { retentionDays: [1, 3650] } as const;\nexport const MAIL_DEFAULTS: MailSettings = { provider: \"log\", from: \"Krizaka <no-reply@krizaka.com>\", replyTo: \"\", retentionDays: 90 };\n\nconst ADDRESS = /^[^\\s@<>]+@[^\\s@<>]+\\.[^\\s@<>]+$/;\nconst TOKEN = /^[a-z][a-z0-9_.-]{0,39}$/;\n// @fixed: RFC 5321's longest forward path\nconst MAX_ADDRESS = 254;\n// @fixed: a subject is one short line; a body past this is not a transactional mail\nconst MAX_SUBJECT = 200;\nconst MAX_BODY = 500_000;\nconst MAX_KEY = 200;\n\nexport const normalizeAddress = (email: string) => email.trim().toLowerCase();\n\n/** The full SHA-256 of an address: suppressions and the log never hold the address itself (Loi 25). */\nexport const hashAddress = (email: string) => createHash(\"sha256\").update(normalizeAddress(email)).digest(\"hex\");\n\n/** A stable UUID (version 5 shaped) from a seed: the same key queued twice is one outbox message. */\nexport function uuidOf(seed: string): string {\n const h = createHash(\"sha256\").update(`krizaka.mail:${seed}`).digest();\n h[6] = (h[6]! & 0x0f) | 0x50;\n h[8] = (h[8]! & 0x3f) | 0x80;\n const x = h.subarray(0, 16).toString(\"hex\");\n return `${x.slice(0, 8)}-${x.slice(8, 12)}-${x.slice(12, 16)}-${x.slice(16, 20)}-${x.slice(20, 32)}`;\n}\n\n/** The bare address of `Name <address>` or `address`. */\nexport function bareAddress(from: string): string {\n const match = /<([^<>]+)>\\s*$/.exec(from);\n return (match?.[1] ?? from).trim();\n}\n\nconst oneLine = (value: string) => !/[\\r\\n]/.test(value);\n\n/** @throws MailError when the address is not one mailbox on one line (no header injection). */\nexport function checkAddress(address: string, what = \"address\"): void {\n if (typeof address !== \"string\" || address.length > MAX_ADDRESS || !oneLine(address) || !ADDRESS.test(bareAddress(address))) throw new MailError(MAIL_CODE.badAddress, `${what} is not a valid address`);\n}\n\n/** @throws MailError on the first thing wrong with a message. */\nexport function checkMessage(message: MailMessage): void {\n checkAddress(message.to, \"to\");\n if (message.replyTo !== undefined && message.replyTo !== \"\") checkAddress(message.replyTo, \"replyTo\");\n if (typeof message.subject !== \"string\" || message.subject.trim() === \"\" || message.subject.length > MAX_SUBJECT || !oneLine(message.subject)) throw new MailError(MAIL_CODE.badMessage, \"subject must be one line of 1 to 200 characters\");\n if (typeof message.text !== \"string\" || message.text.trim() === \"\" || message.text.length > MAX_BODY) throw new MailError(MAIL_CODE.badMessage, \"text is required\");\n if (message.html !== undefined && (typeof message.html !== \"string\" || message.html.length > MAX_BODY)) throw new MailError(MAIL_CODE.badMessage, \"html is too long\");\n if (!TOKEN.test(message.category) || !TOKEN.test(message.template)) throw new MailError(MAIL_CODE.badMessage, \"category and template are lower-case tokens\");\n if (typeof message.locale !== \"string\" || !/^[a-z]{2}(-[A-Z]{2})?$/.test(message.locale)) throw new MailError(MAIL_CODE.badMessage, \"locale is a language tag like fr or en-CA\");\n if (typeof message.idempotencyKey !== \"string\" || message.idempotencyKey === \"\" || message.idempotencyKey.length > MAX_KEY) throw new MailError(MAIL_CODE.badMessage, \"idempotencyKey is required (1 to 200 characters)\");\n}\n\n/** @throws MailError when the settings are out of bounds. */\nexport function checkSettings(s: MailSettings): MailSettings {\n const [min, max] = MAIL_BOUNDS.retentionDays;\n if (!Number.isInteger(s.retentionDays) || s.retentionDays < min || s.retentionDays > max) throw new MailError(MAIL_CODE.badSettings, `retentionDays must be ${min} to ${max}`);\n checkAddress(s.from, \"from\");\n if (s.replyTo !== \"\") checkAddress(s.replyTo, \"replyTo\");\n return s;\n}\n"],"mappings":"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","names":[]}
|