@krizaka/mail 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,357 @@
1
+ import {
2
+ DELIVERY_STATUS,
3
+ MAIL_BOUNDS,
4
+ MAIL_CODE,
5
+ MAIL_DEFAULTS,
6
+ MailError,
7
+ SUPPRESSION_REASON,
8
+ bareAddress,
9
+ checkAddress,
10
+ checkMessage,
11
+ checkSettings,
12
+ hashAddress,
13
+ normalizeAddress,
14
+ uuidOf
15
+ } from "./chunk-YDSARDFP.js";
16
+
17
+ // src/email-ports.ts
18
+ function emailPorts(store, clock) {
19
+ return {
20
+ log: { record: (entry) => store.record({ ...entry, at: entry.at ? new Date(entry.at) : clock.now() }) },
21
+ suppression: {
22
+ has: (email) => store.isSuppressed(hashAddress(email)),
23
+ add: async (email, reason2) => {
24
+ await store.suppress(hashAddress(email), reason2, clock.now());
25
+ }
26
+ },
27
+ idempotency: {
28
+ claim: (key) => store.claimKey(key, clock.now()),
29
+ release: (key) => store.releaseKey(key)
30
+ }
31
+ };
32
+ }
33
+
34
+ // src/mail.ts
35
+ import { subHours } from "@krizaka/time";
36
+ var MAIL_TOPIC = "mail";
37
+ var MAIL_EVENT = { requested: "evt.mail.requested.v1" };
38
+ var isMessage = (payload) => typeof payload === "object" && payload !== null && "to" in payload && "idempotencyKey" in payload;
39
+ var reason = (error) => (error instanceof Error ? error.message : String(error)).slice(0, 300);
40
+ function createMail(deps) {
41
+ const settings = async () => typeof deps.settings === "function" ? await deps.settings() : deps.settings;
42
+ const log = (entry) => deps.store.record({ ...entry, at: deps.clock.now() });
43
+ return {
44
+ async queue(tx, message) {
45
+ checkMessage(message);
46
+ const id = uuidOf(message.idempotencyKey);
47
+ const { appended } = await deps.outbox.append(tx, { topic: MAIL_TOPIC, type: MAIL_EVENT.requested, payload: message, id });
48
+ return { id, queued: appended };
49
+ },
50
+ async deliver(queued) {
51
+ if (!isMessage(queued.payload)) throw new MailError(MAIL_CODE.badMessage, `message ${queued.id} is not a mail`);
52
+ const message = queued.payload;
53
+ checkMessage(message);
54
+ const hash = hashAddress(message.to);
55
+ const base = { template: message.template, category: message.category, locale: message.locale, to: hash, idempotencyKey: message.idempotencyKey };
56
+ if (await deps.store.isSuppressed(hash)) {
57
+ await log({ ...base, status: DELIVERY_STATUS.suppressed });
58
+ return DELIVERY_STATUS.suppressed;
59
+ }
60
+ if (await deps.store.hasKey(message.idempotencyKey)) {
61
+ await log({ ...base, status: DELIVERY_STATUS.duplicate });
62
+ return DELIVERY_STATUS.duplicate;
63
+ }
64
+ const s = await settings();
65
+ const provider = typeof deps.provider === "function" ? deps.provider(s) : deps.provider;
66
+ const replyTo = message.replyTo || s.replyTo;
67
+ try {
68
+ const sent = await provider.send({ ...message, from: s.from, ...replyTo ? { replyTo } : {} });
69
+ 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 });
71
+ return DELIVERY_STATUS.sent;
72
+ } catch (error) {
73
+ await log({ ...base, status: DELIVERY_STATUS.failed, detail: reason(error) });
74
+ throw error;
75
+ }
76
+ },
77
+ suppress: (s) => deps.store.suppress(hashAddress(bareAddress(s.address)), s.reason, deps.clock.now()),
78
+ unsuppress: (address) => deps.store.unsuppress(hashAddress(bareAddress(address))),
79
+ isSuppressed: (address) => deps.store.isSuppressed(hashAddress(bareAddress(address))),
80
+ async purge() {
81
+ const s = await settings();
82
+ return deps.store.purgeBefore(subHours(deps.clock.now(), s.retentionDays * 24));
83
+ }
84
+ };
85
+ }
86
+
87
+ // src/memory.ts
88
+ function memoryMailStore() {
89
+ const log = [];
90
+ const suppressed = /* @__PURE__ */ new Map();
91
+ const keys = /* @__PURE__ */ new Map();
92
+ let seq = 0;
93
+ return {
94
+ async record({ at, ...entry }) {
95
+ seq += 1;
96
+ log.push({ ...entry, id: seq, at: at.toISOString(), when: at });
97
+ },
98
+ async recent(limit) {
99
+ return log.slice(-limit).reverse().map(({ when: _when, ...row }) => row);
100
+ },
101
+ async isSuppressed(hash) {
102
+ return suppressed.has(hash);
103
+ },
104
+ async suppress(hash, reason2) {
105
+ if (suppressed.has(hash)) return false;
106
+ suppressed.set(hash, reason2);
107
+ return true;
108
+ },
109
+ async unsuppress(hash) {
110
+ return suppressed.delete(hash);
111
+ },
112
+ async hasKey(key) {
113
+ return keys.has(key);
114
+ },
115
+ async claimKey(key, now) {
116
+ if (keys.has(key)) return false;
117
+ keys.set(key, now);
118
+ return true;
119
+ },
120
+ async releaseKey(key) {
121
+ keys.delete(key);
122
+ },
123
+ async purgeBefore(before) {
124
+ let n = 0;
125
+ for (let i = log.length - 1; i >= 0; i -= 1) {
126
+ if (log[i].when >= before) continue;
127
+ log.splice(i, 1);
128
+ n += 1;
129
+ }
130
+ for (const [key, at] of keys) {
131
+ if (at >= before) continue;
132
+ keys.delete(key);
133
+ n += 1;
134
+ }
135
+ return n;
136
+ }
137
+ };
138
+ }
139
+
140
+ // src/persistence.ts
141
+ import { ident } from "@krizaka/db";
142
+ function dbMailStore(query, schema = "mail") {
143
+ const s = ident(schema);
144
+ return {
145
+ 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]);
147
+ },
148
+ async recent(limit) {
149
+ 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 }));
151
+ },
152
+ async isSuppressed(hash) {
153
+ return (await query(`SELECT 1 FROM ${s}.suppression WHERE address_hash = $1`, [hash])).length > 0;
154
+ },
155
+ 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;
157
+ },
158
+ async unsuppress(hash) {
159
+ return (await query(`DELETE FROM ${s}.suppression WHERE address_hash = $1 RETURNING 1`, [hash])).length > 0;
160
+ },
161
+ async hasKey(key) {
162
+ return (await query(`SELECT 1 FROM ${s}.idempotency WHERE key = $1`, [key])).length > 0;
163
+ },
164
+ 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;
166
+ },
167
+ async releaseKey(key) {
168
+ await query(`DELETE FROM ${s}.idempotency WHERE key = $1`, [key]);
169
+ },
170
+ async purgeBefore(before) {
171
+ const logs = await query(`DELETE FROM ${s}.delivery WHERE at < $1 RETURNING 1`, [before]);
172
+ const keys = await query(`DELETE FROM ${s}.idempotency WHERE created_at < $1 RETURNING 1`, [before]);
173
+ return logs.length + keys.length;
174
+ }
175
+ };
176
+ }
177
+
178
+ // src/providers.ts
179
+ var withoutTrailingSlashes = (url) => {
180
+ let end = url.length;
181
+ while (end > 0 && url[end - 1] === "/") end -= 1;
182
+ return url.slice(0, end);
183
+ };
184
+ var failure = (provider, status, body) => new MailError(MAIL_CODE.provider, `${provider} answered ${status}: ${body.slice(0, 200)}`);
185
+ var idOf = (body, key) => {
186
+ try {
187
+ const value = JSON.parse(body)[key];
188
+ return typeof value === "string" ? value : null;
189
+ } catch {
190
+ return null;
191
+ }
192
+ };
193
+ function mailgunProvider(options) {
194
+ const base = withoutTrailingSlashes(options.apiUrl ?? "https://api.mailgun.net");
195
+ const auth = `Basic ${Buffer.from(`api:${options.apiKey}`).toString("base64")}`;
196
+ return {
197
+ name: "mailgun",
198
+ async send(mail) {
199
+ const form = new URLSearchParams({ from: mail.from, to: mail.to, subject: mail.subject, text: mail.text });
200
+ if (mail.html !== void 0) form.set("html", mail.html);
201
+ if (mail.replyTo) form.set("h:Reply-To", mail.replyTo);
202
+ 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() });
204
+ const body = await res.text();
205
+ if (!res.ok) throw failure("mailgun", res.status, body);
206
+ return { messageId: idOf(body, "id") };
207
+ }
208
+ };
209
+ }
210
+ function resendProvider(options) {
211
+ const base = withoutTrailingSlashes(options.apiUrl ?? "https://api.resend.com");
212
+ return {
213
+ name: "resend",
214
+ 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) });
217
+ const answer = await res.text();
218
+ if (!res.ok) throw failure("resend", res.status, answer);
219
+ return { messageId: idOf(answer, "id") };
220
+ }
221
+ };
222
+ }
223
+ function logProvider(write) {
224
+ return {
225
+ name: "log",
226
+ 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 }));
228
+ return { messageId: null };
229
+ }
230
+ };
231
+ }
232
+ function providerFor(kind, keys, fetch, write) {
233
+ if (kind === "log") return logProvider(write);
234
+ 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 } : {} });
236
+ 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 } : {} });
238
+ }
239
+
240
+ // src/schema.ts
241
+ import { defineModule } from "@krizaka/db";
242
+ var mailSchema = defineModule({
243
+ module: "mail",
244
+ migrations: [
245
+ {
246
+ version: 1,
247
+ name: "delivery, suppression and idempotency",
248
+ sql: (c) => `
249
+ CREATE TABLE ${c.schema}.delivery (
250
+ id bigserial PRIMARY KEY,
251
+ at timestamptz NOT NULL,
252
+ template text NOT NULL,
253
+ category text NOT NULL,
254
+ locale text NOT NULL,
255
+ to_hash text NOT NULL CHECK (char_length(to_hash) = 64),
256
+ status text NOT NULL CHECK (status IN ('sent', 'failed', 'suppressed', 'duplicate')),
257
+ message_id text,
258
+ idempotency_key text,
259
+ detail text
260
+ );
261
+ CREATE INDEX delivery_at ON ${c.schema}.delivery (at);
262
+ CREATE TABLE ${c.schema}.suppression (
263
+ address_hash text PRIMARY KEY CHECK (char_length(address_hash) = 64),
264
+ reason text NOT NULL CHECK (reason IN ('bounce', 'complaint', 'unsubscribed')),
265
+ created_at timestamptz NOT NULL
266
+ );
267
+ CREATE TABLE ${c.schema}.idempotency (
268
+ key text PRIMARY KEY CHECK (char_length(key) BETWEEN 1 AND 200),
269
+ created_at timestamptz NOT NULL
270
+ );
271
+ CREATE INDEX idempotency_created ON ${c.schema}.idempotency (created_at);`
272
+ }
273
+ ]
274
+ });
275
+
276
+ // src/webhooks.ts
277
+ import { createHmac, timingSafeEqual } from "crypto";
278
+ var TOLERANCE_SECONDS = 300;
279
+ var equal = (a, b) => {
280
+ const x = Buffer.from(a);
281
+ const y = Buffer.from(b);
282
+ return x.length === y.length && timingSafeEqual(x, y);
283
+ };
284
+ var bad = (why) => new MailError(MAIL_CODE.badWebhook, why);
285
+ var record = (value) => typeof value === "object" && value !== null ? value : {};
286
+ var text = (value) => typeof value === "string" ? value : "";
287
+ function parseMailgunWebhook(rawBody, signingKey, nowSeconds) {
288
+ let body;
289
+ try {
290
+ body = record(JSON.parse(rawBody));
291
+ } catch {
292
+ throw bad("the body is not JSON");
293
+ }
294
+ const sig = record(body.signature);
295
+ const timestamp = text(sig.timestamp);
296
+ const expected = createHmac("sha256", signingKey).update(timestamp + text(sig.token)).digest("hex");
297
+ 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");
299
+ const data = record(body["event-data"]);
300
+ const address = text(data.recipient);
301
+ if (address === "") return null;
302
+ const event = text(data.event);
303
+ if (event === "complained") return { address, reason: SUPPRESSION_REASON.complaint };
304
+ if (event === "unsubscribed") return { address, reason: SUPPRESSION_REASON.unsubscribed };
305
+ if (event === "failed" && text(data.severity) === "permanent") return { address, reason: SUPPRESSION_REASON.bounce };
306
+ return null;
307
+ }
308
+ function parseResendWebhook(rawBody, headers, secret, nowSeconds) {
309
+ const { id, timestamp, signature } = headers;
310
+ 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");
312
+ const key = Buffer.from(secret.startsWith("whsec_") ? secret.slice(6) : secret, "base64");
313
+ const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest("base64");
314
+ const ok = signature.split(" ").some((part) => part.startsWith("v1,") && equal(part.slice(3), expected));
315
+ if (!ok) throw bad("the signature does not match");
316
+ let event;
317
+ try {
318
+ event = record(JSON.parse(rawBody));
319
+ } catch {
320
+ throw bad("the body is not JSON");
321
+ }
322
+ const data = record(event.data);
323
+ const to = Array.isArray(data.to) ? data.to.filter((a) => typeof a === "string") : [];
324
+ const type = text(event.type);
325
+ 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 }));
327
+ return [];
328
+ }
329
+ export {
330
+ DELIVERY_STATUS,
331
+ MAIL_BOUNDS,
332
+ MAIL_CODE,
333
+ MAIL_DEFAULTS,
334
+ MAIL_EVENT,
335
+ MAIL_TOPIC,
336
+ MailError,
337
+ SUPPRESSION_REASON,
338
+ bareAddress,
339
+ checkAddress,
340
+ checkMessage,
341
+ checkSettings,
342
+ createMail,
343
+ dbMailStore,
344
+ emailPorts,
345
+ hashAddress,
346
+ logProvider,
347
+ mailSchema,
348
+ mailgunProvider,
349
+ memoryMailStore,
350
+ normalizeAddress,
351
+ parseMailgunWebhook,
352
+ parseResendWebhook,
353
+ providerFor,
354
+ resendProvider,
355
+ uuidOf
356
+ };
357
+ //# sourceMappingURL=index.js.map
@@ -0,0 +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"]}
@@ -0,0 +1,110 @@
1
+ /**
2
+ * The ports of « mail »: a message queued in the caller's transaction and delivered after the commit, once per idempotency key,
3
+ * never to a suppressed address. Generic: no vendor SDK (providers are adapters over an injected `fetch`), no environment variable
4
+ * (keys come in as parameters), no wall clock, no database client. The module takes mail ALREADY RENDERED: templates are
5
+ * `@krizaka/email`'s (a UI package this one never imports), and its three ports are satisfied structurally by `emailPorts`.
6
+ */
7
+
8
+ /** Why an address is never mailed again. The same values as `@krizaka/email`'s `SuppressionReason`. */
9
+ declare const SUPPRESSION_REASON: {
10
+ readonly bounce: "bounce";
11
+ readonly complaint: "complaint";
12
+ readonly unsubscribed: "unsubscribed";
13
+ };
14
+ type SuppressionReason = (typeof SUPPRESSION_REASON)[keyof typeof SUPPRESSION_REASON];
15
+ /** The mail a product queues. */
16
+ interface MailMessage {
17
+ to: string;
18
+ subject: string;
19
+ text: string;
20
+ html?: string;
21
+ /** What kind of mail it is (`transactional`, `notification`, `digest`…): lower-case token, kept in the delivery log. */
22
+ category: string;
23
+ /** The template that rendered it, for the log (never the body). */
24
+ template: string;
25
+ locale: string;
26
+ /** One key sends once: queueing it twice is one message, and delivering it twice sends one mail. */
27
+ idempotencyKey: string;
28
+ /** Overrides `mail.replyTo` for this message. */
29
+ replyTo?: string;
30
+ }
31
+ /** A mail the provider receives: the message with the sender the settings give. */
32
+ interface OutgoingMail extends MailMessage {
33
+ from: string;
34
+ }
35
+ /** What the provider answered. */
36
+ interface Sent {
37
+ messageId: string | null;
38
+ }
39
+ /** One provider of mail (an adapter over `fetch`). It resolves on acceptance and throws on any failure: the outbox retries. */
40
+ interface MailProvider {
41
+ readonly name: string;
42
+ send(mail: OutgoingMail): Promise<Sent>;
43
+ }
44
+ declare const DELIVERY_STATUS: {
45
+ readonly sent: "sent";
46
+ readonly failed: "failed";
47
+ readonly suppressed: "suppressed";
48
+ readonly duplicate: "duplicate";
49
+ };
50
+ type DeliveryStatus = (typeof DELIVERY_STATUS)[keyof typeof DELIVERY_STATUS];
51
+ /** One line of the delivery log: what, to whom (a hash, never the address), how it went. The shape of `@krizaka/email`'s `DeliveryLogEntry`. */
52
+ interface DeliveryEntry {
53
+ at?: string;
54
+ template: string;
55
+ category: string;
56
+ locale: string;
57
+ to: string;
58
+ status: DeliveryStatus;
59
+ messageId?: string | undefined;
60
+ idempotencyKey?: string | undefined;
61
+ detail?: string | undefined;
62
+ }
63
+ /** A line to write: the instant is a `Date`. */
64
+ type DeliveryInput = Omit<DeliveryEntry, "at"> & {
65
+ at: Date;
66
+ };
67
+ /** A line as read back. */
68
+ interface DeliveryRecord extends DeliveryEntry {
69
+ id: number;
70
+ at: string;
71
+ }
72
+ /** Where the module keeps its state. Every adapter runs `mailStoreContract` (`@krizaka/mail/testkit`). Addresses arrive hashed. */
73
+ interface MailStore {
74
+ record(entry: DeliveryInput): Promise<void>;
75
+ recent(limit: number): Promise<DeliveryRecord[]>;
76
+ isSuppressed(addressHash: string): Promise<boolean>;
77
+ /** `false` when the address was already suppressed (the first reason stays). */
78
+ suppress(addressHash: string, reason: SuppressionReason, now: Date): Promise<boolean>;
79
+ /** Lifts a suppression (the person re-subscribed, or it was a mistake). */
80
+ unsuppress(addressHash: string): Promise<boolean>;
81
+ hasKey(key: string): Promise<boolean>;
82
+ /** `true` the first time a key is claimed. */
83
+ claimKey(key: string, now: Date): Promise<boolean>;
84
+ releaseKey(key: string): Promise<void>;
85
+ /** Removes the keys and log lines older than `before` (suppressions are kept for good). Returns how many. */
86
+ purgeBefore(before: Date): Promise<number>;
87
+ }
88
+ interface MailSettings {
89
+ provider: "log" | "mailgun" | "resend";
90
+ /** The sender, `Name <address>` or an address. */
91
+ from: string;
92
+ /** Where replies go; empty for none. */
93
+ replyTo: string;
94
+ /** Days the delivery log and the idempotency keys are kept. */
95
+ retentionDays: number;
96
+ }
97
+ declare const MAIL_CODE: {
98
+ readonly badAddress: "MAIL_BAD_ADDRESS";
99
+ readonly badMessage: "MAIL_BAD_MESSAGE";
100
+ readonly badSettings: "MAIL_BAD_SETTINGS";
101
+ readonly provider: "MAIL_PROVIDER_FAILED";
102
+ readonly badWebhook: "MAIL_BAD_WEBHOOK";
103
+ };
104
+ type MailCode = (typeof MAIL_CODE)[keyof typeof MAIL_CODE];
105
+ declare class MailError extends Error {
106
+ readonly code: MailCode;
107
+ constructor(code: MailCode, message: string);
108
+ }
109
+
110
+ export { type DeliveryEntry as D, type MailStore as M, type OutgoingMail as O, type SuppressionReason as S, type MailMessage as a, type MailSettings as b, type MailProvider as c, DELIVERY_STATUS as d, type DeliveryInput as e, type DeliveryRecord as f, type DeliveryStatus as g, MAIL_CODE as h, type MailCode as i, MailError as j, SUPPRESSION_REASON as k, type Sent as l };
@@ -0,0 +1,18 @@
1
+ import * as _krizaka_settings from '@krizaka/settings';
2
+ import { b as MailSettings } from './port-Pk4TboMq.js';
3
+
4
+ /**
5
+ * The settings of « mail », a fragment the product adds to its registry (`@krizaka/mail/settings`): the admin picks the provider and
6
+ * the sender without a redeploy. Secrets (the provider's key, domain and webhook signing keys) stay in the environment.
7
+ */
8
+ declare const mailSettings: {
9
+ readonly "mail.provider": _krizaka_settings.Def<"log" | "mailgun" | "resend">;
10
+ readonly "mail.from": _krizaka_settings.Def<string>;
11
+ readonly "mail.replyTo": _krizaka_settings.Def<string>;
12
+ readonly "mail.retentionDays": _krizaka_settings.Def<number>;
13
+ };
14
+ type Reader = (key: keyof typeof mailSettings) => string | number | undefined | Promise<string | number | undefined>;
15
+ /** 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. */
16
+ declare function readMailSettings(read: Reader): Promise<MailSettings>;
17
+
18
+ export { mailSettings, readMailSettings };
@@ -0,0 +1,31 @@
1
+ import {
2
+ MAIL_BOUNDS,
3
+ MAIL_DEFAULTS
4
+ } from "./chunk-YDSARDFP.js";
5
+
6
+ // src/settings.ts
7
+ import { choice, integer, text } from "@krizaka/settings";
8
+ var PROVIDERS = ["log", "mailgun", "resend"];
9
+ var mailSettings = {
10
+ "mail.provider": choice("mail", PROVIDERS, MAIL_DEFAULTS.provider),
11
+ "mail.from": text("mail", MAIL_DEFAULTS.from),
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" })
14
+ };
15
+ async function readMailSettings(read) {
16
+ const provider = await read("mail.provider");
17
+ const from = await read("mail.from");
18
+ const replyTo = await read("mail.replyTo");
19
+ const retention = await read("mail.retentionDays");
20
+ return {
21
+ provider: PROVIDERS.find((p) => p === provider) ?? MAIL_DEFAULTS.provider,
22
+ from: typeof from === "string" && from !== "" ? from : MAIL_DEFAULTS.from,
23
+ replyTo: typeof replyTo === "string" ? replyTo : MAIL_DEFAULTS.replyTo,
24
+ retentionDays: typeof retention === "number" ? retention : MAIL_DEFAULTS.retentionDays
25
+ };
26
+ }
27
+ export {
28
+ mailSettings,
29
+ readMailSettings
30
+ };
31
+ //# sourceMappingURL=settings.js.map
@@ -0,0 +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":[]}
@@ -0,0 +1,7 @@
1
+ import { M as MailStore } from './port-Pk4TboMq.js';
2
+
3
+ declare const T0: Date;
4
+ /** What every `MailStore` adapter must do: the same behaviour behind the same port. `make` gives an empty store. */
5
+ declare function mailStoreContract(name: string, make: () => MailStore | Promise<MailStore>): void;
6
+
7
+ export { T0, mailStoreContract };