@krizaka/mail 0.1.1 → 0.1.3

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.
@@ -1,6 +1,11 @@
1
1
  // src/port.ts
2
2
  var SUPPRESSION_REASON = { bounce: "bounce", complaint: "complaint", unsubscribed: "unsubscribed" };
3
- var DELIVERY_STATUS = { sent: "sent", failed: "failed", suppressed: "suppressed", duplicate: "duplicate" };
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 = { provider: "log", from: "Krizaka <no-reply@krizaka.com>", replyTo: "", retentionDays: 90 };
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))) throw new MailError(MAIL_CODE.badAddress, `${what} is not a valid 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)) throw new MailError(MAIL_CODE.badMessage, "subject must be one line of 1 to 200 characters");
51
- if (typeof message.text !== "string" || message.text.trim() === "" || message.text.length > MAX_BODY) throw new MailError(MAIL_CODE.badMessage, "text is required");
52
- if (message.html !== void 0 && (typeof message.html !== "string" || message.html.length > MAX_BODY)) throw new MailError(MAIL_CODE.badMessage, "html is too long");
53
- if (!TOKEN.test(message.category) || !TOKEN.test(message.template)) throw new MailError(MAIL_CODE.badMessage, "category and template are lower-case tokens");
54
- 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");
55
- 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)");
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) throw new MailError(MAIL_CODE.badSettings, `retentionDays must be ${min} to ${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-YDSARDFP.js.map
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-YDSARDFP.js";
15
+ } from "./chunk-XBNBPYT5.js";
16
16
 
17
17
  // src/email-ports.ts
18
18
  function emailPorts(store, clock) {
19
19
  return {
20
- log: { record: (entry) => store.record({ ...entry, at: entry.at ? new Date(entry.at) : clock.now() }) },
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, { topic: MAIL_TOPIC, type: MAIL_EVENT.requested, payload: message, id });
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 = { template: message.template, category: message.category, locale: message.locale, to: hash, idempotencyKey: message.idempotencyKey };
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({ ...base, status: DELIVERY_STATUS.sent, messageId: sent.messageId ?? void 0, detail: provider.name });
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(`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]);
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) => ({ 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 ?? void 0, idempotencyKey: r.idempotency_key ?? void 0, detail: r.detail ?? void 0 }));
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(`INSERT INTO ${s}.suppression (address_hash, reason, created_at) VALUES ($1,$2,$3) ON CONFLICT DO NOTHING RETURNING 1`, [hash, reason2, now])).length > 0;
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(`INSERT INTO ${s}.idempotency (key, created_at) VALUES ($1,$2) ON CONFLICT DO NOTHING RETURNING 1`, [key, now])).length > 0;
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`, { method: "POST", headers: { authorization: auth, "content-type": "application/x-www-form-urlencoded" }, body: form.toString() });
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 = { from: mail.from, to: [mail.to], subject: mail.subject, text: mail.text, ...mail.html === void 0 ? {} : { html: mail.html }, ...mail.replyTo ? { reply_to: mail.replyTo } : {} };
216
- 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) });
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(JSON.stringify({ event: "mail.log", from: bareAddress(mail.from), template: mail.template, category: mail.category, locale: mail.locale, subject: mail.subject, idempotencyKey: mail.idempotencyKey }));
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") return resendProvider({ apiKey: keys.apiKey, fetch, ...keys.apiUrl ? { apiUrl: keys.apiUrl } : {} });
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({ apiKey: keys.apiKey, domain: keys.domain, fetch, ...keys.apiUrl ? { apiUrl: keys.apiUrl } : {} });
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) throw bad("the timestamp is stale");
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) throw bad("the timestamp is stale");
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") return to.map((address) => ({ address, reason: SUPPRESSION_REASON.bounce }));
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-YDSARDFP.js";
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, { min: MAIL_BOUNDS.retentionDays[0], max: MAIL_BOUNDS.retentionDays[1], unit: "days" })
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");
@@ -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, { min: MAIL_BOUNDS.retentionDays[0], max: MAIL_BOUNDS.retentionDays[1], unit: \"days\" }),\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,EAAE,KAAK,YAAY,cAAc,CAAC,GAAG,KAAK,YAAY,cAAc,CAAC,GAAG,MAAM,OAAO,CAAC;AAC3J;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":[]}
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-YDSARDFP.js";
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 = {}) => ({ at: T0, template: "reset", category: "transactional", locale: "fr", to: hashAddress("a@b.co"), status: "sent", ...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({ status: "sent", messageId: "<m1>", idempotencyKey: "k", to: hashAddress("a@b.co"), at: day(1).toISOString() });
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);
@@ -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> = {}) => ({ at: T0, template: \"reset\", category: \"transactional\", locale: \"fr\", to: hashAddress(\"a@b.co\"), status: \"sent\" as const, ...over });\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({ status: \"sent\", messageId: \"<m1>\", idempotencyKey: \"k\", to: hashAddress(\"a@b.co\"), at: day(1).toISOString() });\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":[]}
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.1",
3
+ "version": "0.1.3",
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.14.1",
61
- "@krizaka/outbox": "^0.1.2"
60
+ "@krizaka/config": "^0.20.0",
61
+ "@krizaka/outbox": "^0.1.4"
62
62
  },
63
63
  "dependencies": {
64
- "@krizaka/time": "^0.1.0",
65
- "@krizaka/db": "^0.3.0",
66
- "@krizaka/settings": "^0.5.1"
64
+ "@krizaka/db": "^0.3.1",
65
+ "@krizaka/time": "^0.2.0",
66
+ "@krizaka/settings": "^0.6.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":[]}