@softure-ai/mailing 0.0.0-stage → 0.1.6

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.
Files changed (193) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/LICENSE +21 -0
  3. package/README.md +346 -2
  4. package/dist/address.d.ts +27 -0
  5. package/dist/address.d.ts.map +1 -0
  6. package/dist/address.js +76 -0
  7. package/dist/address.js.map +1 -0
  8. package/dist/cli/bin.d.ts +3 -0
  9. package/dist/cli/bin.d.ts.map +1 -0
  10. package/dist/cli/bin.js +5 -0
  11. package/dist/cli/bin.js.map +1 -0
  12. package/dist/cli/command.d.ts +9 -0
  13. package/dist/cli/command.d.ts.map +1 -0
  14. package/dist/cli/command.js +36 -0
  15. package/dist/cli/command.js.map +1 -0
  16. package/dist/cli/index.d.ts +3 -0
  17. package/dist/cli/index.d.ts.map +1 -0
  18. package/dist/cli/index.js +4 -0
  19. package/dist/cli/index.js.map +1 -0
  20. package/dist/cli/run.d.ts +31 -0
  21. package/dist/cli/run.d.ts.map +1 -0
  22. package/dist/cli/run.js +288 -0
  23. package/dist/cli/run.js.map +1 -0
  24. package/dist/contract.d.ts +102 -0
  25. package/dist/contract.d.ts.map +1 -0
  26. package/dist/contract.js +3 -0
  27. package/dist/contract.js.map +1 -0
  28. package/dist/index.d.ts +47 -0
  29. package/dist/index.d.ts.map +1 -0
  30. package/dist/index.js +59 -0
  31. package/dist/index.js.map +1 -0
  32. package/dist/messages/en.d.ts +26 -0
  33. package/dist/messages/en.d.ts.map +1 -0
  34. package/dist/messages/en.js +26 -0
  35. package/dist/messages/en.js.map +1 -0
  36. package/dist/messages/index.d.ts +59 -0
  37. package/dist/messages/index.d.ts.map +1 -0
  38. package/dist/messages/index.js +11 -0
  39. package/dist/messages/index.js.map +1 -0
  40. package/dist/messages/pl.d.ts +3 -0
  41. package/dist/messages/pl.d.ts.map +1 -0
  42. package/dist/messages/pl.js +27 -0
  43. package/dist/messages/pl.js.map +1 -0
  44. package/dist/next/actions.d.ts +2 -0
  45. package/dist/next/actions.d.ts.map +1 -0
  46. package/dist/next/actions.js +35 -0
  47. package/dist/next/actions.js.map +1 -0
  48. package/dist/next/context.d.ts +4 -0
  49. package/dist/next/context.d.ts.map +1 -0
  50. package/dist/next/context.js +14 -0
  51. package/dist/next/context.js.map +1 -0
  52. package/dist/next/deliver-once.d.ts +4 -0
  53. package/dist/next/deliver-once.d.ts.map +1 -0
  54. package/dist/next/deliver-once.js +7 -0
  55. package/dist/next/deliver-once.js.map +1 -0
  56. package/dist/next/index.d.ts +8 -0
  57. package/dist/next/index.d.ts.map +1 -0
  58. package/dist/next/index.js +11 -0
  59. package/dist/next/index.js.map +1 -0
  60. package/dist/next/messages.d.ts +5 -0
  61. package/dist/next/messages.d.ts.map +1 -0
  62. package/dist/next/messages.js +7 -0
  63. package/dist/next/messages.js.map +1 -0
  64. package/dist/next/pages.d.ts +7 -0
  65. package/dist/next/pages.d.ts.map +1 -0
  66. package/dist/next/pages.js +40 -0
  67. package/dist/next/pages.js.map +1 -0
  68. package/dist/next/params.d.ts +4 -0
  69. package/dist/next/params.d.ts.map +1 -0
  70. package/dist/next/params.js +4 -0
  71. package/dist/next/params.js.map +1 -0
  72. package/dist/next/route.d.ts +14 -0
  73. package/dist/next/route.d.ts.map +1 -0
  74. package/dist/next/route.js +44 -0
  75. package/dist/next/route.js.map +1 -0
  76. package/dist/next/send-mail.d.ts +4 -0
  77. package/dist/next/send-mail.d.ts.map +1 -0
  78. package/dist/next/send-mail.js +16 -0
  79. package/dist/next/send-mail.js.map +1 -0
  80. package/dist/options.d.ts +13 -0
  81. package/dist/options.d.ts.map +1 -0
  82. package/dist/options.js +29 -0
  83. package/dist/options.js.map +1 -0
  84. package/dist/providers/resend.d.ts +17 -0
  85. package/dist/providers/resend.d.ts.map +1 -0
  86. package/dist/providers/resend.js +64 -0
  87. package/dist/providers/resend.js.map +1 -0
  88. package/dist/schema.d.ts +350 -0
  89. package/dist/schema.d.ts.map +1 -0
  90. package/dist/schema.js +30 -0
  91. package/dist/schema.js.map +1 -0
  92. package/dist/server/campaign-file.d.ts +28 -0
  93. package/dist/server/campaign-file.d.ts.map +1 -0
  94. package/dist/server/campaign-file.js +87 -0
  95. package/dist/server/campaign-file.js.map +1 -0
  96. package/dist/server/campaigns.d.ts +58 -0
  97. package/dist/server/campaigns.d.ts.map +1 -0
  98. package/dist/server/campaigns.js +118 -0
  99. package/dist/server/campaigns.js.map +1 -0
  100. package/dist/server/deliveries.d.ts +56 -0
  101. package/dist/server/deliveries.d.ts.map +1 -0
  102. package/dist/server/deliveries.js +100 -0
  103. package/dist/server/deliveries.js.map +1 -0
  104. package/dist/server/dns.d.ts +33 -0
  105. package/dist/server/dns.d.ts.map +1 -0
  106. package/dist/server/dns.js +113 -0
  107. package/dist/server/dns.js.map +1 -0
  108. package/dist/server/health.d.ts +3 -0
  109. package/dist/server/health.d.ts.map +1 -0
  110. package/dist/server/health.js +13 -0
  111. package/dist/server/health.js.map +1 -0
  112. package/dist/server/index.d.ts +12 -0
  113. package/dist/server/index.d.ts.map +1 -0
  114. package/dist/server/index.js +14 -0
  115. package/dist/server/index.js.map +1 -0
  116. package/dist/server/list-mail.d.ts +20 -0
  117. package/dist/server/list-mail.d.ts.map +1 -0
  118. package/dist/server/list-mail.js +31 -0
  119. package/dist/server/list-mail.js.map +1 -0
  120. package/dist/server/options.d.ts +14 -0
  121. package/dist/server/options.d.ts.map +1 -0
  122. package/dist/server/options.js +22 -0
  123. package/dist/server/options.js.map +1 -0
  124. package/dist/server/send-mail.d.ts +19 -0
  125. package/dist/server/send-mail.d.ts.map +1 -0
  126. package/dist/server/send-mail.js +129 -0
  127. package/dist/server/send-mail.js.map +1 -0
  128. package/dist/server/suppressions.d.ts +27 -0
  129. package/dist/server/suppressions.d.ts.map +1 -0
  130. package/dist/server/suppressions.js +64 -0
  131. package/dist/server/suppressions.js.map +1 -0
  132. package/dist/server/unsubscribe-link.d.ts +51 -0
  133. package/dist/server/unsubscribe-link.d.ts.map +1 -0
  134. package/dist/server/unsubscribe-link.js +78 -0
  135. package/dist/server/unsubscribe-link.js.map +1 -0
  136. package/dist/server/validate-mail.d.ts +23 -0
  137. package/dist/server/validate-mail.d.ts.map +1 -0
  138. package/dist/server/validate-mail.js +52 -0
  139. package/dist/server/validate-mail.js.map +1 -0
  140. package/dist/testing/fake-provider.d.ts +24 -0
  141. package/dist/testing/fake-provider.d.ts.map +1 -0
  142. package/dist/testing/fake-provider.js +43 -0
  143. package/dist/testing/fake-provider.js.map +1 -0
  144. package/dist/testing/index.d.ts +3 -0
  145. package/dist/testing/index.d.ts.map +1 -0
  146. package/dist/testing/index.js +5 -0
  147. package/dist/testing/index.js.map +1 -0
  148. package/dist/testing/outbox.d.ts +6 -0
  149. package/dist/testing/outbox.d.ts.map +1 -0
  150. package/dist/testing/outbox.js +32 -0
  151. package/dist/testing/outbox.js.map +1 -0
  152. package/migrations/0001_create_suppressions.sql +13 -0
  153. package/migrations/0002_create_campaigns_and_deliveries.sql +41 -0
  154. package/module.json +32 -0
  155. package/package.json +74 -4
  156. package/src/address.ts +89 -0
  157. package/src/cli/bin.ts +5 -0
  158. package/src/cli/command.ts +44 -0
  159. package/src/cli/index.ts +3 -0
  160. package/src/cli/run.ts +317 -0
  161. package/src/contract.ts +108 -0
  162. package/src/index.ts +85 -0
  163. package/src/messages/en.ts +25 -0
  164. package/src/messages/index.ts +15 -0
  165. package/src/messages/pl.ts +28 -0
  166. package/src/next/actions.ts +35 -0
  167. package/src/next/context.ts +15 -0
  168. package/src/next/deliver-once.ts +10 -0
  169. package/src/next/index.ts +10 -0
  170. package/src/next/messages.ts +9 -0
  171. package/src/next/next-modules.d.ts +12 -0
  172. package/src/next/pages.tsx +69 -0
  173. package/src/next/params.ts +6 -0
  174. package/src/next/route.ts +45 -0
  175. package/src/next/send-mail.ts +15 -0
  176. package/src/options.ts +34 -0
  177. package/src/providers/resend.ts +86 -0
  178. package/src/schema.ts +40 -0
  179. package/src/server/campaign-file.ts +101 -0
  180. package/src/server/campaigns.ts +175 -0
  181. package/src/server/deliveries.ts +155 -0
  182. package/src/server/dns.ts +153 -0
  183. package/src/server/health.ts +14 -0
  184. package/src/server/index.ts +56 -0
  185. package/src/server/list-mail.ts +42 -0
  186. package/src/server/options.ts +32 -0
  187. package/src/server/send-mail.ts +152 -0
  188. package/src/server/suppressions.ts +77 -0
  189. package/src/server/unsubscribe-link.ts +109 -0
  190. package/src/server/validate-mail.ts +68 -0
  191. package/src/testing/fake-provider.ts +66 -0
  192. package/src/testing/index.ts +4 -0
  193. package/src/testing/outbox.ts +32 -0
@@ -0,0 +1,22 @@
1
+ // The mailing options and routes of the running app, read from the configuration.
2
+ import { getModule } from "@softure-ai/core";
3
+ const MODULE_ID = "mailing";
4
+ /** The enabled module. Throws when the app did not enable it: sending then is a bug. */
5
+ export function getMailingModule(config) {
6
+ const module = getModule(config, MODULE_ID);
7
+ if (module === undefined) {
8
+ throw new Error("@softure-ai/mailing: the module is not enabled; add mailing({ ... }) to modules in softure.config.ts");
9
+ }
10
+ return module;
11
+ }
12
+ /** The module's options. Throws when the app did not enable it. */
13
+ export function getMailingOptions(config) {
14
+ // The module factory parsed these options with mailingOptionsSchema.
15
+ return getMailingModule(config).options;
16
+ }
17
+ /** The module's routes, with the app's overrides. Throws when the app did not enable it. */
18
+ export function getMailingRoutes(config) {
19
+ // The manifest declares both routes; the factory only replaces their paths.
20
+ return getMailingModule(config).routes;
21
+ }
22
+ //# sourceMappingURL=options.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"options.js","sourceRoot":"","sources":["../../src/server/options.ts"],"names":[],"mappings":"AAAA,kFAAkF;AAClF,OAAO,EAAE,SAAS,EAA6C,MAAM,kBAAkB,CAAC;AAGxF,MAAM,SAAS,GAAG,SAAS,CAAC;AAQ5B,wFAAwF;AACxF,MAAM,UAAU,gBAAgB,CAAC,MAAqB;IACpD,MAAM,MAAM,GAAG,SAAS,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC;IAC5C,IAAI,MAAM,KAAK,SAAS,EAAE,CAAC;QACzB,MAAM,IAAI,KAAK,CAAC,sGAAsG,CAAC,CAAC;IAC1H,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC;AAED,mEAAmE;AACnE,MAAM,UAAU,iBAAiB,CAAC,MAAqB;IACrD,qEAAqE;IACrE,OAAO,gBAAgB,CAAC,MAAM,CAAC,CAAC,OAAyB,CAAC;AAC5D,CAAC;AAED,4FAA4F;AAC5F,MAAM,UAAU,gBAAgB,CAAC,MAAqB;IACpD,4EAA4E;IAC5E,OAAO,gBAAgB,CAAC,MAAM,CAAC,CAAC,MAAkC,CAAC;AACrE,CAAC"}
@@ -0,0 +1,19 @@
1
+ import { type SoftureConfig } from "@softure-ai/core";
2
+ import type { Queryable } from "@softure-ai/db";
3
+ import { type OutgoingMail, type SendMailOptions, type SendMailResult } from "../contract.js";
4
+ /**
5
+ * What `sendMail` needs: the app's configuration and, for list mail, the database handle (the
6
+ * suppression check reads it). Transactional mail needs no `db`.
7
+ */
8
+ export interface MailContext {
9
+ readonly config: SoftureConfig;
10
+ readonly db?: Queryable;
11
+ }
12
+ /**
13
+ * Sends `mail` and resolves with the provider's message id or a `mailing.*` code:
14
+ * `invalid_input` (fix the input), `rejected` (do not retry), `unavailable` (retry later with the
15
+ * same `idempotencyKey`), `suppressed` (a list mail to someone who unsubscribed; do not retry).
16
+ * Throws only when the module is not enabled, or for list mail without `context.db`.
17
+ */
18
+ export declare function sendMail(context: MailContext, mail: OutgoingMail, options?: SendMailOptions): Promise<SendMailResult>;
19
+ //# sourceMappingURL=send-mail.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"send-mail.d.ts","sourceRoot":"","sources":["../../src/server/send-mail.ts"],"names":[],"mappings":"AAKA,OAAO,EAAW,KAAK,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAC/D,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAChD,OAAO,EAAgE,KAAK,YAAY,EAA8C,KAAK,eAAe,EAAE,KAAK,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAQxM;;;GAGG;AACH,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC;IAC/B,QAAQ,CAAC,EAAE,CAAC,EAAE,SAAS,CAAC;CACzB;AAID;;;;;GAKG;AACH,wBAAsB,QAAQ,CAAC,OAAO,EAAE,WAAW,EAAE,IAAI,EAAE,YAAY,EAAE,OAAO,GAAE,eAAoB,GAAG,OAAO,CAAC,cAAc,CAAC,CA2B/H"}
@@ -0,0 +1,129 @@
1
+ // Sends one mail through the configured provider. Ported from FIRE_TRACKER `src/lib/mail.ts` with
2
+ // its three rules: it never throws for a failed send (every failure is a result), nothing of the
3
+ // mail (address, subject, body, key) reaches a log or the result, and `headers` cannot replace the
4
+ // envelope or the sender. List mail (any kind but `transactional`) also gets a signed unsubscribe
5
+ // link in a footer and the RFC 8058 headers, and is refused for a recipient who unsubscribed.
6
+ import { err, ok } from "@softure-ai/core";
7
+ import { TRANSACTIONAL_KIND } from "../contract.js";
8
+ import { addHtmlFooter, addTextFooter, getListUnsubscribeHeaders } from "./list-mail.js";
9
+ import { getMailingModule, getMailingOptions } from "./options.js";
10
+ import { isSuppressed } from "./suppressions.js";
11
+ import { buildUnsubscribeLinks, MIN_UNSUBSCRIBE_SECRET_LENGTH, readUnsubscribeSecrets, UNSUBSCRIBE_SECRET_ENV } from "./unsubscribe-link.js";
12
+ import { validateMail } from "./validate-mail.js";
13
+ /**
14
+ * Sends `mail` and resolves with the provider's message id or a `mailing.*` code:
15
+ * `invalid_input` (fix the input), `rejected` (do not retry), `unavailable` (retry later with the
16
+ * same `idempotencyKey`), `suppressed` (a list mail to someone who unsubscribed; do not retry).
17
+ * Throws only when the module is not enabled, or for list mail without `context.db`.
18
+ */
19
+ export async function sendMail(context, mail, options = {}) {
20
+ const { from, replyTo, provider, timeoutMs } = getMailingOptions(context.config);
21
+ const validation = validateMail(mail, options);
22
+ if (!validation.ok) {
23
+ logFailure(provider, "mailing.invalid_input", { fields: validation.fields });
24
+ return err("mailing.invalid_input");
25
+ }
26
+ const { idempotencyKey, kind, ...content } = validation.value;
27
+ let message = content;
28
+ if (kind !== TRANSACTIONAL_KIND) {
29
+ const listMail = await prepareListMail(context, validation.value);
30
+ if (!listMail.ok) {
31
+ logFailure(provider, listMail.error, { cause: listMail.cause });
32
+ return err(listMail.error);
33
+ }
34
+ message = listMail.value;
35
+ }
36
+ const outcome = await callProvider(provider, { ...message, from, replyTo: replyTo ?? null, idempotencyKey }, timeoutMs);
37
+ if (outcome.status === "sent") {
38
+ return ok({ id: outcome.id, provider: provider.name });
39
+ }
40
+ const code = `mailing.${outcome.status}`;
41
+ logFailure(provider, code, { status: outcome.httpStatus });
42
+ return err(code);
43
+ }
44
+ /**
45
+ * A list mail with its footer and headers, or why it must not go out. Fails closed: without the
46
+ * secret, or when the suppression list cannot be read, nothing is sent.
47
+ */
48
+ async function prepareListMail(context, mail) {
49
+ if (context.db === undefined) {
50
+ throw new Error("@softure-ai/mailing: list mail needs the database handle; call sendMail({ config, db }, ...)");
51
+ }
52
+ const secret = readUnsubscribeSecrets().current;
53
+ if (secret === null) {
54
+ console.error(`mailing: list mail needs ${UNSUBSCRIBE_SECRET_ENV} (at least ${String(MIN_UNSUBSCRIBE_SECRET_LENGTH)} characters) to sign unsubscribe links`);
55
+ return { ok: false, error: "mailing.unavailable", cause: "no_unsubscribe_secret" };
56
+ }
57
+ let suppressed;
58
+ try {
59
+ suppressed = await isSuppressed({ db: context.db }, mail.to);
60
+ }
61
+ catch {
62
+ // The error text can carry the query parameters, the recipient key among them.
63
+ return { ok: false, error: "mailing.unavailable", cause: "suppressions_unreadable" };
64
+ }
65
+ if (suppressed)
66
+ return { ok: false, error: "mailing.suppressed", cause: "unsubscribed" };
67
+ const links = buildUnsubscribeLinks(context.config, mail.to, secret);
68
+ // The module factory merged the dictionaries; their shape is the module's own.
69
+ const copy = getMailingModule(context.config).messages[context.config.locale].footer;
70
+ return {
71
+ ok: true,
72
+ value: {
73
+ to: mail.to,
74
+ subject: mail.subject,
75
+ text: addTextFooter(mail.text, links, copy),
76
+ html: mail.html === null ? null : addHtmlFooter(mail.html, links, copy),
77
+ headers: { ...mail.headers, ...getListUnsubscribeHeaders(links) },
78
+ },
79
+ };
80
+ }
81
+ /**
82
+ * The provider's outcome, `unavailable` when it throws, answers something else or outlives the
83
+ * timeout. The timer races the provider, so one that ignores its signal cannot hang the caller.
84
+ */
85
+ async function callProvider(provider, message, timeoutMs) {
86
+ const controller = new AbortController();
87
+ let timer;
88
+ const timeout = new Promise((resolve) => {
89
+ timer = setTimeout(() => {
90
+ controller.abort(new DOMException("mailing: the provider did not answer in time", "TimeoutError"));
91
+ resolve({ status: "unavailable" });
92
+ }, timeoutMs);
93
+ });
94
+ const sending = provider
95
+ .send(message, { signal: controller.signal })
96
+ .then(readOutcome, () => ({ status: "unavailable" }));
97
+ try {
98
+ return await Promise.race([sending, timeout]);
99
+ }
100
+ finally {
101
+ clearTimeout(timer);
102
+ }
103
+ }
104
+ /** A provider's answer, narrowed: anything that is not a known outcome reads as `unavailable`. */
105
+ function readOutcome(outcome) {
106
+ if (typeof outcome !== "object" || outcome === null)
107
+ return { status: "unavailable" };
108
+ const { status, id, httpStatus } = outcome;
109
+ if (status === "sent") {
110
+ return typeof id === "string" && id !== "" ? { status, id } : { status: "unavailable" };
111
+ }
112
+ if (status === "rejected" || status === "unavailable") {
113
+ return typeof httpStatus === "number" ? { status, httpStatus } : { status };
114
+ }
115
+ return { status: "unavailable" };
116
+ }
117
+ /**
118
+ * The module's only log line. Deliberately takes nothing but the provider, the code, a status,
119
+ * field names and a fixed cause: anything added "for diagnosis" is where an address leaks first.
120
+ */
121
+ function logFailure(provider, code, details) {
122
+ const parts = [`provider=${provider.name}`, `reason=${code.slice("mailing.".length)}`, `status=${details.status === undefined ? "none" : String(details.status)}`];
123
+ if (details.fields !== undefined)
124
+ parts.push(`fields=${details.fields.join(",")}`);
125
+ if (details.cause !== undefined)
126
+ parts.push(`cause=${details.cause}`);
127
+ console.error(`mailing: send failed ${parts.join(" ")}`);
128
+ }
129
+ //# sourceMappingURL=send-mail.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"send-mail.js","sourceRoot":"","sources":["../../src/server/send-mail.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,27 @@
1
+ import { type ModuleContext, type Result } from "@softure-ai/core";
2
+ import type { Queryable } from "@softure-ai/db";
3
+ import type { SuppressionSource, UnsubscribeErrorCode } from "../contract.js";
4
+ import { type Env, type UnsubscribeToken } from "./unsubscribe-link.js";
5
+ export type SuppressionContext = ModuleContext<Queryable>;
6
+ /** Whether `address` unsubscribed. Throws on a database failure: callers decide how to fail. */
7
+ export declare function isSuppressed(ctx: Pick<SuppressionContext, "db">, address: string): Promise<boolean>;
8
+ /**
9
+ * Unsubscribes `address` from list mail, for an operator's script or another module (a bounce or
10
+ * complaint handler). Throws on a database failure.
11
+ */
12
+ export declare function suppressRecipient(ctx: SuppressionContext, address: string, source?: SuppressionSource): Promise<void>;
13
+ /**
14
+ * Lifts the opt-out of `address` when the recipient made it themselves (`page`, `one-click`), for a
15
+ * module that just recorded their new explicit consent, in that consent's transaction. An
16
+ * `operator` row (a bounce, a complaint, a script) stays. Returns whether a row was lifted.
17
+ * Throws on a database failure.
18
+ */
19
+ export declare function liftSuppression(ctx: Pick<SuppressionContext, "db">, address: string): Promise<boolean>;
20
+ /**
21
+ * Unsubscribes the recipient of a signed link and runs `onUnsubscribed` in the same transaction,
22
+ * also when the recipient had already unsubscribed (a retried one-click heals a missed hook). The
23
+ * signature is checked before the database is touched; a link that does not verify is
24
+ * `mailing.invalid_link`. Throws on a database failure or when the hook throws (nothing is stored).
25
+ */
26
+ export declare function unsubscribe(ctx: SuppressionContext, token: UnsubscribeToken | null, source: Exclude<SuppressionSource, "operator">, env?: Env): Promise<Result<undefined, UnsubscribeErrorCode>>;
27
+ //# sourceMappingURL=suppressions.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"suppressions.d.ts","sourceRoot":"","sources":["../../src/server/suppressions.ts"],"names":[],"mappings":"AAKA,OAAO,EAAW,KAAK,aAAa,EAAE,KAAK,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAC5E,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAEhD,OAAO,KAAK,EAAE,iBAAiB,EAAE,oBAAoB,EAAE,MAAM,gBAAgB,CAAC;AAG9E,OAAO,EAAmE,KAAK,GAAG,EAAE,KAAK,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAEzI,MAAM,MAAM,kBAAkB,GAAG,aAAa,CAAC,SAAS,CAAC,CAAC;AAE1D,gGAAgG;AAChG,wBAAsB,YAAY,CAAC,GAAG,EAAE,IAAI,CAAC,kBAAkB,EAAE,IAAI,CAAC,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAOzG;AAOD;;;GAGG;AACH,wBAAsB,iBAAiB,CAAC,GAAG,EAAE,kBAAkB,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,GAAE,iBAA8B,GAAG,OAAO,CAAC,IAAI,CAAC,CAEvI;AAKD;;;;;GAKG;AACH,wBAAsB,eAAe,CAAC,GAAG,EAAE,IAAI,CAAC,kBAAkB,EAAE,IAAI,CAAC,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAM5G;AAED;;;;;GAKG;AACH,wBAAsB,WAAW,CAC/B,GAAG,EAAE,kBAAkB,EACvB,KAAK,EAAE,gBAAgB,GAAG,IAAI,EAC9B,MAAM,EAAE,OAAO,CAAC,iBAAiB,EAAE,UAAU,CAAC,EAC9C,GAAG,GAAE,GAAiB,GACrB,OAAO,CAAC,MAAM,CAAC,SAAS,EAAE,oBAAoB,CAAC,CAAC,CAUlD"}
@@ -0,0 +1,64 @@
1
+ // The suppression list: who unsubscribed from list mail. Rows hold the recipient key, never the
2
+ // address. Writes are idempotent (a second opt-out keeps the first row), so a mail client that
3
+ // retries its one-click POST, or a person who clicks twice, changes nothing. A verified unsubscribe
4
+ // also runs the app's `onUnsubscribed` hook in the same transaction; a new explicit consent lifts
5
+ // the recipient's own opt-out (`liftSuppression`).
6
+ import { err, ok } from "@softure-ai/core";
7
+ import { and, eq, inArray } from "drizzle-orm";
8
+ import { suppressions } from "../schema.js";
9
+ import { getMailingOptions } from "./options.js";
10
+ import { getRecipientKey, readUnsubscribeSecrets, verifyUnsubscribeToken } from "./unsubscribe-link.js";
11
+ /** Whether `address` unsubscribed. Throws on a database failure: callers decide how to fail. */
12
+ export async function isSuppressed(ctx, address) {
13
+ const rows = await ctx.db
14
+ .select({ recipientKey: suppressions.recipientKey })
15
+ .from(suppressions)
16
+ .where(eq(suppressions.recipientKey, getRecipientKey(address)))
17
+ .limit(1);
18
+ return rows.length > 0;
19
+ }
20
+ /** Records an opt-out by recipient key. Throws on a database failure. */
21
+ async function recordSuppression(ctx, recipientKey, source) {
22
+ await ctx.db.insert(suppressions).values({ recipientKey, source, createdAt: ctx.clock.now() }).onConflictDoNothing();
23
+ }
24
+ /**
25
+ * Unsubscribes `address` from list mail, for an operator's script or another module (a bounce or
26
+ * complaint handler). Throws on a database failure.
27
+ */
28
+ export async function suppressRecipient(ctx, address, source = "operator") {
29
+ await recordSuppression(ctx, getRecipientKey(address), source);
30
+ }
31
+ /** The opt-outs a recipient made themselves; only these can be lifted by a new consent. */
32
+ const OWN_SOURCES = ["one-click", "page"];
33
+ /**
34
+ * Lifts the opt-out of `address` when the recipient made it themselves (`page`, `one-click`), for a
35
+ * module that just recorded their new explicit consent, in that consent's transaction. An
36
+ * `operator` row (a bounce, a complaint, a script) stays. Returns whether a row was lifted.
37
+ * Throws on a database failure.
38
+ */
39
+ export async function liftSuppression(ctx, address) {
40
+ const lifted = await ctx.db
41
+ .delete(suppressions)
42
+ .where(and(eq(suppressions.recipientKey, getRecipientKey(address)), inArray(suppressions.source, [...OWN_SOURCES])))
43
+ .returning();
44
+ return lifted.length > 0;
45
+ }
46
+ /**
47
+ * Unsubscribes the recipient of a signed link and runs `onUnsubscribed` in the same transaction,
48
+ * also when the recipient had already unsubscribed (a retried one-click heals a missed hook). The
49
+ * signature is checked before the database is touched; a link that does not verify is
50
+ * `mailing.invalid_link`. Throws on a database failure or when the hook throws (nothing is stored).
51
+ */
52
+ export async function unsubscribe(ctx, token, source, env = process.env) {
53
+ if (token === null || !verifyUnsubscribeToken(token, readUnsubscribeSecrets(env)))
54
+ return err("mailing.invalid_link");
55
+ const { onUnsubscribed } = getMailingOptions(ctx.config);
56
+ const { recipientKey } = token;
57
+ await ctx.db.transaction(async (tx) => {
58
+ const txCtx = { ...ctx, db: tx };
59
+ await recordSuppression(txCtx, recipientKey, source);
60
+ await onUnsubscribed?.({ recipientKey, source }, txCtx);
61
+ });
62
+ return ok();
63
+ }
64
+ //# sourceMappingURL=suppressions.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"suppressions.js","sourceRoot":"","sources":["../../src/server/suppressions.ts"],"names":[],"mappings":"AAAA,gGAAgG;AAChG,+FAA+F;AAC/F,oGAAoG;AACpG,kGAAkG;AAClG,mDAAmD;AACnD,OAAO,EAAE,GAAG,EAAE,EAAE,EAAmC,MAAM,kBAAkB,CAAC;AAE5E,OAAO,EAAE,GAAG,EAAE,EAAE,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC;AAE/C,OAAO,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAC5C,OAAO,EAAE,iBAAiB,EAAE,MAAM,cAAc,CAAC;AACjD,OAAO,EAAE,eAAe,EAAE,sBAAsB,EAAE,sBAAsB,EAAmC,MAAM,uBAAuB,CAAC;AAIzI,gGAAgG;AAChG,MAAM,CAAC,KAAK,UAAU,YAAY,CAAC,GAAmC,EAAE,OAAe;IACrF,MAAM,IAAI,GAAG,MAAM,GAAG,CAAC,EAAE;SACtB,MAAM,CAAC,EAAE,YAAY,EAAE,YAAY,CAAC,YAAY,EAAE,CAAC;SACnD,IAAI,CAAC,YAAY,CAAC;SAClB,KAAK,CAAC,EAAE,CAAC,YAAY,CAAC,YAAY,EAAE,eAAe,CAAC,OAAO,CAAC,CAAC,CAAC;SAC9D,KAAK,CAAC,CAAC,CAAC,CAAC;IACZ,OAAO,IAAI,CAAC,MAAM,GAAG,CAAC,CAAC;AACzB,CAAC;AAED,yEAAyE;AACzE,KAAK,UAAU,iBAAiB,CAAC,GAAuB,EAAE,YAAoB,EAAE,MAAyB;IACvG,MAAM,GAAG,CAAC,EAAE,CAAC,MAAM,CAAC,YAAY,CAAC,CAAC,MAAM,CAAC,EAAE,YAAY,EAAE,MAAM,EAAE,SAAS,EAAE,GAAG,CAAC,KAAK,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC,mBAAmB,EAAE,CAAC;AACvH,CAAC;AAED;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,iBAAiB,CAAC,GAAuB,EAAE,OAAe,EAAE,SAA4B,UAAU;IACtH,MAAM,iBAAiB,CAAC,GAAG,EAAE,eAAe,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC,CAAC;AACjE,CAAC;AAED,2FAA2F;AAC3F,MAAM,WAAW,GAAiC,CAAC,WAAW,EAAE,MAAM,CAAC,CAAC;AAExE;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,eAAe,CAAC,GAAmC,EAAE,OAAe;IACxF,MAAM,MAAM,GAAG,MAAM,GAAG,CAAC,EAAE;SACxB,MAAM,CAAC,YAAY,CAAC;SACpB,KAAK,CAAC,GAAG,CAAC,EAAE,CAAC,YAAY,CAAC,YAAY,EAAE,eAAe,CAAC,OAAO,CAAC,CAAC,EAAE,OAAO,CAAC,YAAY,CAAC,MAAM,EAAE,CAAC,GAAG,WAAW,CAAC,CAAC,CAAC,CAAC;SACnH,SAAS,EAAE,CAAC;IACf,OAAO,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC;AAC3B,CAAC;AAED;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,WAAW,CAC/B,GAAuB,EACvB,KAA8B,EAC9B,MAA8C,EAC9C,MAAW,OAAO,CAAC,GAAG;IAEtB,IAAI,KAAK,KAAK,IAAI,IAAI,CAAC,sBAAsB,CAAC,KAAK,EAAE,sBAAsB,CAAC,GAAG,CAAC,CAAC;QAAE,OAAO,GAAG,CAAC,sBAAsB,CAAC,CAAC;IACtH,MAAM,EAAE,cAAc,EAAE,GAAG,iBAAiB,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;IACzD,MAAM,EAAE,YAAY,EAAE,GAAG,KAAK,CAAC;IAC/B,MAAM,GAAG,CAAC,EAAE,CAAC,WAAW,CAAC,KAAK,EAAE,EAAE,EAAE,EAAE;QACpC,MAAM,KAAK,GAAuB,EAAE,GAAG,GAAG,EAAE,EAAE,EAAE,EAAE,EAAE,CAAC;QACrD,MAAM,iBAAiB,CAAC,KAAK,EAAE,YAAY,EAAE,MAAM,CAAC,CAAC;QACrD,MAAM,cAAc,EAAE,CAAC,EAAE,YAAY,EAAE,MAAM,EAAE,EAAE,KAAK,CAAC,CAAC;IAC1D,CAAC,CAAC,CAAC;IACH,OAAO,EAAE,EAAE,CAAC;AACd,CAAC"}
@@ -0,0 +1,51 @@
1
+ import type { SoftureConfig } from "@softure-ai/core";
2
+ /** Signs every new link and verifies. */
3
+ export declare const UNSUBSCRIBE_SECRET_ENV = "MAILING_UNSUBSCRIBE_SECRET";
4
+ /** Optional: verifies links signed before a rotation, never signs. */
5
+ export declare const UNSUBSCRIBE_PREVIOUS_SECRET_ENV = "MAILING_UNSUBSCRIBE_SECRET_PREVIOUS";
6
+ /** A shorter secret counts as missing: a guessable key lets anyone mint links. */
7
+ export declare const MIN_UNSUBSCRIBE_SECRET_LENGTH = 32;
8
+ /** The query parameters of both links. */
9
+ export declare const RECIPIENT_PARAM = "r";
10
+ export declare const SIGNATURE_PARAM = "t";
11
+ export type Env = Readonly<Record<string, string | undefined>>;
12
+ /** The key pair a link carries: the recipient key and its signature. */
13
+ export interface UnsubscribeToken {
14
+ readonly recipientKey: string;
15
+ readonly signature: string;
16
+ }
17
+ /** Both links of one mail: the same token, two targets. */
18
+ export interface UnsubscribeLinks {
19
+ /** The page with a button, for the mail's footer (opening it changes nothing). */
20
+ readonly page: string;
21
+ /** The RFC 8058 one-click target, for the `List-Unsubscribe` header. */
22
+ readonly oneClick: string;
23
+ }
24
+ /** The secrets in use: `current` signs, both verify. `current` is null when it is not set. */
25
+ export interface UnsubscribeSecrets {
26
+ readonly current: string | null;
27
+ readonly previous: string | null;
28
+ }
29
+ /**
30
+ * The stored and linked identity of an address: base64url SHA-256 of the trimmed, lowercased
31
+ * address. Lowercasing the local part merges `Ada@` and `ada@`, which errs on the side of not
32
+ * mailing someone who opted out.
33
+ */
34
+ export declare function getRecipientKey(address: string): string;
35
+ /** The signature of a recipient key. */
36
+ export declare function signRecipientKey(recipientKey: string, secret: string): string;
37
+ /** The secrets from the environment; one shorter than 32 characters reads as not set. */
38
+ export declare function readUnsubscribeSecrets(env?: Env): UnsubscribeSecrets;
39
+ /**
40
+ * Whether the token was signed by the current or the previous secret. Never throws: the shape is
41
+ * checked first (`timingSafeEqual` throws on buffers of different lengths, and a throw would be a
42
+ * second, faster answer), then every secret is compared in constant time.
43
+ */
44
+ export declare function verifyUnsubscribeToken(token: UnsubscribeToken, secrets: UnsubscribeSecrets): boolean;
45
+ /** The two links for `address`, on the app's origin and the module's routes. */
46
+ export declare function buildUnsubscribeLinks(config: SoftureConfig, address: string, secret: string): UnsubscribeLinks;
47
+ /** The token in a link's query (or a form), when both parameters are present once. */
48
+ export declare function readUnsubscribeToken(params: {
49
+ get(name: string): unknown;
50
+ }): UnsubscribeToken | null;
51
+ //# sourceMappingURL=unsubscribe-link.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"unsubscribe-link.d.ts","sourceRoot":"","sources":["../../src/server/unsubscribe-link.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAGtD,yCAAyC;AACzC,eAAO,MAAM,sBAAsB,+BAA+B,CAAC;AACnE,sEAAsE;AACtE,eAAO,MAAM,+BAA+B,wCAAwC,CAAC;AACrF,kFAAkF;AAClF,eAAO,MAAM,6BAA6B,KAAK,CAAC;AAEhD,0CAA0C;AAC1C,eAAO,MAAM,eAAe,MAAM,CAAC;AACnC,eAAO,MAAM,eAAe,MAAM,CAAC;AAWnC,MAAM,MAAM,GAAG,GAAG,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC,CAAC;AAE/D,wEAAwE;AACxE,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC5B;AAED,2DAA2D;AAC3D,MAAM,WAAW,gBAAgB;IAC/B,kFAAkF;IAClF,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,wEAAwE;IACxE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;CAC3B;AAED,8FAA8F;AAC9F,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;CAClC;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAEvD;AAED,wCAAwC;AACxC,wBAAgB,gBAAgB,CAAC,YAAY,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAE7E;AAED,yFAAyF;AACzF,wBAAgB,sBAAsB,CAAC,GAAG,GAAE,GAAiB,GAAG,kBAAkB,CAEjF;AAMD;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,gBAAgB,EAAE,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAWpG;AAED,gFAAgF;AAChF,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,gBAAgB,CAQ9G;AAED,sFAAsF;AACtF,wBAAgB,oBAAoB,CAAC,MAAM,EAAE;IAAE,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAA;CAAE,GAAG,gBAAgB,GAAG,IAAI,CAIpG"}
@@ -0,0 +1,78 @@
1
+ // Signed unsubscribe links. Ported from FIRE_TRACKER `src/lib/unsubscribe-link.ts`, with two
2
+ // changes: the link carries a hash of the address instead of a waitlist row id (the module has no
3
+ // recipient table, and the address never appears in a URL), and a previous secret keeps verifying
4
+ // during a rotation. The whole link is a credential: whoever has it can unsubscribe that address,
5
+ // so it never reaches a log, an error or a result.
6
+ import { createHash, createHmac, timingSafeEqual } from "node:crypto";
7
+ import { getMailingRoutes } from "./options.js";
8
+ /** Signs every new link and verifies. */
9
+ export const UNSUBSCRIBE_SECRET_ENV = "MAILING_UNSUBSCRIBE_SECRET";
10
+ /** Optional: verifies links signed before a rotation, never signs. */
11
+ export const UNSUBSCRIBE_PREVIOUS_SECRET_ENV = "MAILING_UNSUBSCRIBE_SECRET_PREVIOUS";
12
+ /** A shorter secret counts as missing: a guessable key lets anyone mint links. */
13
+ export const MIN_UNSUBSCRIBE_SECRET_LENGTH = 32;
14
+ /** The query parameters of both links. */
15
+ export const RECIPIENT_PARAM = "r";
16
+ export const SIGNATURE_PARAM = "t";
17
+ /**
18
+ * Domain separation: the same key signing anything else can never produce a valid unsubscribe
19
+ * signature. Changing it voids every link already sent, exactly like changing the key.
20
+ */
21
+ const SIGNED_PREFIX = "softure.mailing.unsubscribe.v1:";
22
+ /** 32 bytes in unpadded base64url. */
23
+ const TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/;
24
+ /**
25
+ * The stored and linked identity of an address: base64url SHA-256 of the trimmed, lowercased
26
+ * address. Lowercasing the local part merges `Ada@` and `ada@`, which errs on the side of not
27
+ * mailing someone who opted out.
28
+ */
29
+ export function getRecipientKey(address) {
30
+ return createHash("sha256").update(address.trim().toLowerCase()).digest("base64url");
31
+ }
32
+ /** The signature of a recipient key. */
33
+ export function signRecipientKey(recipientKey, secret) {
34
+ return createHmac("sha256", secret).update(`${SIGNED_PREFIX}${recipientKey}`).digest("base64url");
35
+ }
36
+ /** The secrets from the environment; one shorter than 32 characters reads as not set. */
37
+ export function readUnsubscribeSecrets(env = process.env) {
38
+ return { current: readSecret(env[UNSUBSCRIBE_SECRET_ENV]), previous: readSecret(env[UNSUBSCRIBE_PREVIOUS_SECRET_ENV]) };
39
+ }
40
+ function readSecret(value) {
41
+ return value !== undefined && value.length >= MIN_UNSUBSCRIBE_SECRET_LENGTH ? value : null;
42
+ }
43
+ /**
44
+ * Whether the token was signed by the current or the previous secret. Never throws: the shape is
45
+ * checked first (`timingSafeEqual` throws on buffers of different lengths, and a throw would be a
46
+ * second, faster answer), then every secret is compared in constant time.
47
+ */
48
+ export function verifyUnsubscribeToken(token, secrets) {
49
+ if (!TOKEN_PATTERN.test(token.recipientKey) || !TOKEN_PATTERN.test(token.signature))
50
+ return false;
51
+ const candidate = Buffer.from(token.signature, "base64url");
52
+ let isValid = false;
53
+ for (const secret of [secrets.current, secrets.previous]) {
54
+ if (secret === null)
55
+ continue;
56
+ const expected = Buffer.from(signRecipientKey(token.recipientKey, secret), "base64url");
57
+ // No early exit: both comparisons run whichever matches.
58
+ isValid = timingSafeEqual(candidate, expected) || isValid;
59
+ }
60
+ return isValid;
61
+ }
62
+ /** The two links for `address`, on the app's origin and the module's routes. */
63
+ export function buildUnsubscribeLinks(config, address, secret) {
64
+ const recipientKey = getRecipientKey(address);
65
+ const query = new URLSearchParams({ [RECIPIENT_PARAM]: recipientKey, [SIGNATURE_PARAM]: signRecipientKey(recipientKey, secret) });
66
+ const routes = getMailingRoutes(config);
67
+ return {
68
+ page: `${config.appOrigin}${routes.unsubscribe}?${query.toString()}`,
69
+ oneClick: `${config.appOrigin}${routes.oneClick}?${query.toString()}`,
70
+ };
71
+ }
72
+ /** The token in a link's query (or a form), when both parameters are present once. */
73
+ export function readUnsubscribeToken(params) {
74
+ const recipientKey = params.get(RECIPIENT_PARAM);
75
+ const signature = params.get(SIGNATURE_PARAM);
76
+ return typeof recipientKey === "string" && typeof signature === "string" ? { recipientKey, signature } : null;
77
+ }
78
+ //# sourceMappingURL=unsubscribe-link.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"unsubscribe-link.js","sourceRoot":"","sources":["../../src/server/unsubscribe-link.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,23 @@
1
+ export interface ValidMail {
2
+ readonly to: string;
3
+ readonly subject: string;
4
+ readonly text: string;
5
+ readonly html: string | null;
6
+ readonly headers: Readonly<Record<string, string>>;
7
+ /** `transactional` or a list kind. */
8
+ readonly kind: string;
9
+ readonly idempotencyKey: string | null;
10
+ }
11
+ export type MailValidation = {
12
+ readonly ok: true;
13
+ readonly value: ValidMail;
14
+ } | {
15
+ readonly ok: false;
16
+ readonly fields: readonly string[];
17
+ };
18
+ /**
19
+ * Checks one mail and its send options. `fields` lists what failed (`to`, `subject`, `text`,
20
+ * `html`, `headers`, `kind`, `idempotencyKey`, or `mail` when the input is not an object).
21
+ */
22
+ export declare function validateMail(mail: unknown, options?: unknown): MailValidation;
23
+ //# sourceMappingURL=validate-mail.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validate-mail.d.ts","sourceRoot":"","sources":["../../src/server/validate-mail.ts"],"names":[],"mappings":"AAqCA,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,QAAQ,CAAC,OAAO,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACnD,sCAAsC;IACtC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;CACxC;AAED,MAAM,MAAM,cAAc,GAAG;IAAE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAA;CAAE,GAAG;IAAE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAA;CAAE,CAAC;AAE3I;;;GAGG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,OAAO,EAAE,OAAO,GAAE,OAAY,GAAG,cAAc,CAajF"}
@@ -0,0 +1,52 @@
1
+ // The boundary check of `sendMail`: a mail and its options are parsed before anything leaves the
2
+ // process. Failures name the fields, never their values (an address is personal data).
3
+ import { z } from "zod";
4
+ import { hasLineBreak, isHeaderName, isIdempotencyKey, isListMailHeader, isMailKind, isReservedHeader, isSingleAddress, MAX_SUBJECT_LENGTH } from "../address.js";
5
+ import { TRANSACTIONAL_KIND } from "../contract.js";
6
+ const headersSchema = z.record(z.string(), z.string()).superRefine((headers, context) => {
7
+ for (const [name, value] of Object.entries(headers)) {
8
+ if (!isHeaderName(name) || isReservedHeader(name) || hasLineBreak(value)) {
9
+ context.addIssue({ code: "custom", message: "invalid or reserved header" });
10
+ }
11
+ }
12
+ });
13
+ const mailSchema = z.object({
14
+ to: z.string().trim().refine(isSingleAddress),
15
+ subject: z
16
+ .string()
17
+ .refine((subject) => subject.trim() !== "" && subject.length <= MAX_SUBJECT_LENGTH && !hasLineBreak(subject)),
18
+ text: z.string().refine((text) => text.trim() !== ""),
19
+ html: z
20
+ .string()
21
+ .refine((html) => html.trim() !== "")
22
+ .optional(),
23
+ headers: headersSchema.optional(),
24
+ kind: z.string().refine(isMailKind).default(TRANSACTIONAL_KIND),
25
+ }).superRefine((mail, context) => {
26
+ // The module writes these two on list mail; a second, unsigned copy would confuse clients.
27
+ if (mail.kind !== TRANSACTIONAL_KIND && Object.keys(mail.headers ?? {}).some(isListMailHeader)) {
28
+ context.addIssue({ code: "custom", path: ["headers"], message: "list mail gets its unsubscribe headers from the module" });
29
+ }
30
+ });
31
+ const optionsSchema = z.object({
32
+ idempotencyKey: z.string().refine(isIdempotencyKey).optional(),
33
+ });
34
+ /**
35
+ * Checks one mail and its send options. `fields` lists what failed (`to`, `subject`, `text`,
36
+ * `html`, `headers`, `kind`, `idempotencyKey`, or `mail` when the input is not an object).
37
+ */
38
+ export function validateMail(mail, options = {}) {
39
+ const parsedMail = mailSchema.safeParse(mail);
40
+ const parsedOptions = optionsSchema.safeParse(options ?? {});
41
+ if (!parsedMail.success || !parsedOptions.success) {
42
+ const issues = [...(parsedMail.error?.issues ?? []), ...(parsedOptions.error?.issues ?? [])];
43
+ const fields = new Set(issues.map((issue) => (issue.path.length === 0 ? "mail" : String(issue.path[0]))));
44
+ return { ok: false, fields: [...fields] };
45
+ }
46
+ const { to, subject, text, html, headers, kind } = parsedMail.data;
47
+ return {
48
+ ok: true,
49
+ value: { to, subject, text, html: html ?? null, headers: headers ?? {}, kind, idempotencyKey: parsedOptions.data.idempotencyKey ?? null },
50
+ };
51
+ }
52
+ //# sourceMappingURL=validate-mail.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validate-mail.js","sourceRoot":"","sources":["../../src/server/validate-mail.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,24 @@
1
+ import type { MailProvider, ProviderMessage, ProviderOutcome } from "../contract.js";
2
+ /** A captured mail: what the provider received, with the id it answered. */
3
+ export interface CapturedMail extends ProviderMessage {
4
+ readonly id: string;
5
+ }
6
+ export interface FakeMailProviderOptions {
7
+ /** A file to append each captured mail to, as one JSON line (`readMailOutbox` reads it). */
8
+ readonly outboxFile?: string;
9
+ /**
10
+ * Decides the outcome per mail, to test failure handling. Return `undefined` (or omit the
11
+ * option) to accept the mail.
12
+ */
13
+ readonly respond?: (message: ProviderMessage) => Exclude<ProviderOutcome, {
14
+ status: "sent";
15
+ }> | undefined;
16
+ }
17
+ export interface FakeMailProvider extends MailProvider {
18
+ /** Every mail accepted so far, oldest first. */
19
+ readonly sent: readonly CapturedMail[];
20
+ /** Forgets the captured mail and the idempotency keys. */
21
+ clear(): void;
22
+ }
23
+ export declare function fakeMailProvider(options?: FakeMailProviderOptions): FakeMailProvider;
24
+ //# sourceMappingURL=fake-provider.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fake-provider.d.ts","sourceRoot":"","sources":["../../src/testing/fake-provider.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,YAAY,EAAE,eAAe,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAErF,4EAA4E;AAC5E,MAAM,WAAW,YAAa,SAAQ,eAAe;IACnD,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,uBAAuB;IACtC,4FAA4F;IAC5F,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B;;;OAGG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,CAAC,OAAO,EAAE,eAAe,KAAK,OAAO,CAAC,eAAe,EAAE;QAAE,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,GAAG,SAAS,CAAC;CAC3G;AAED,MAAM,WAAW,gBAAiB,SAAQ,YAAY;IACpD,gDAAgD;IAChD,QAAQ,CAAC,IAAI,EAAE,SAAS,YAAY,EAAE,CAAC;IACvC,0DAA0D;IAC1D,KAAK,IAAI,IAAI,CAAC;CACf;AAED,wBAAgB,gBAAgB,CAAC,OAAO,GAAE,uBAA4B,GAAG,gBAAgB,CAoCxF"}
@@ -0,0 +1,43 @@
1
+ // A provider that sends nothing: it captures mail in memory (`provider.sent`) and, with an outbox
2
+ // file, appends each mail as one JSON line for a test in another process (an e2e against
3
+ // `next start`). It honours idempotency keys like a real provider: a repeated key answers the
4
+ // first id and captures nothing new.
5
+ import { appendFile } from "node:fs/promises";
6
+ export function fakeMailProvider(options = {}) {
7
+ const sent = [];
8
+ const idsByKey = new Map();
9
+ let counter = 0;
10
+ return {
11
+ name: "fake",
12
+ get sent() {
13
+ return [...sent];
14
+ },
15
+ clear() {
16
+ sent.length = 0;
17
+ idsByKey.clear();
18
+ },
19
+ async send(message) {
20
+ if (options.outboxFile === undefined && process.env.NODE_ENV === "production") {
21
+ // In memory, production mail would vanish without a trace.
22
+ console.error("mailing: fakeMailProvider() refuses to run in production without an outboxFile; configure a real provider");
23
+ return { status: "unavailable" };
24
+ }
25
+ const refusal = options.respond?.(message);
26
+ if (refusal !== undefined)
27
+ return refusal;
28
+ const knownId = message.idempotencyKey === null ? undefined : idsByKey.get(message.idempotencyKey);
29
+ if (knownId !== undefined)
30
+ return { status: "sent", id: knownId };
31
+ counter += 1;
32
+ const captured = { ...message, headers: { ...message.headers }, id: `fake-${String(counter)}-${crypto.randomUUID()}` };
33
+ if (options.outboxFile !== undefined) {
34
+ await appendFile(options.outboxFile, `${JSON.stringify(captured)}\n`, "utf8");
35
+ }
36
+ sent.push(captured);
37
+ if (message.idempotencyKey !== null)
38
+ idsByKey.set(message.idempotencyKey, captured.id);
39
+ return { status: "sent", id: captured.id };
40
+ },
41
+ };
42
+ }
43
+ //# sourceMappingURL=fake-provider.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fake-provider.js","sourceRoot":"","sources":["../../src/testing/fake-provider.ts"],"names":[],"mappings":"AAAA,kGAAkG;AAClG,yFAAyF;AACzF,8FAA8F;AAC9F,qCAAqC;AACrC,OAAO,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAyB9C,MAAM,UAAU,gBAAgB,CAAC,UAAmC,EAAE;IACpE,MAAM,IAAI,GAAmB,EAAE,CAAC;IAChC,MAAM,QAAQ,GAAG,IAAI,GAAG,EAAkB,CAAC;IAC3C,IAAI,OAAO,GAAG,CAAC,CAAC;IAEhB,OAAO;QACL,IAAI,EAAE,MAAM;QACZ,IAAI,IAAI;YACN,OAAO,CAAC,GAAG,IAAI,CAAC,CAAC;QACnB,CAAC;QACD,KAAK;YACH,IAAI,CAAC,MAAM,GAAG,CAAC,CAAC;YAChB,QAAQ,CAAC,KAAK,EAAE,CAAC;QACnB,CAAC;QACD,KAAK,CAAC,IAAI,CAAC,OAAO;YAChB,IAAI,OAAO,CAAC,UAAU,KAAK,SAAS,IAAI,OAAO,CAAC,GAAG,CAAC,QAAQ,KAAK,YAAY,EAAE,CAAC;gBAC9E,2DAA2D;gBAC3D,OAAO,CAAC,KAAK,CAAC,2GAA2G,CAAC,CAAC;gBAC3H,OAAO,EAAE,MAAM,EAAE,aAAa,EAAE,CAAC;YACnC,CAAC;YACD,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,EAAE,CAAC,OAAO,CAAC,CAAC;YAC3C,IAAI,OAAO,KAAK,SAAS;gBAAE,OAAO,OAAO,CAAC;YAE1C,MAAM,OAAO,GAAG,OAAO,CAAC,cAAc,KAAK,IAAI,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,QAAQ,CAAC,GAAG,CAAC,OAAO,CAAC,cAAc,CAAC,CAAC;YACnG,IAAI,OAAO,KAAK,SAAS;gBAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,EAAE,EAAE,OAAO,EAAE,CAAC;YAElE,OAAO,IAAI,CAAC,CAAC;YACb,MAAM,QAAQ,GAAiB,EAAE,GAAG,OAAO,EAAE,OAAO,EAAE,EAAE,GAAG,OAAO,CAAC,OAAO,EAAE,EAAE,EAAE,EAAE,QAAQ,MAAM,CAAC,OAAO,CAAC,IAAI,MAAM,CAAC,UAAU,EAAE,EAAE,EAAE,CAAC;YACrI,IAAI,OAAO,CAAC,UAAU,KAAK,SAAS,EAAE,CAAC;gBACrC,MAAM,UAAU,CAAC,OAAO,CAAC,UAAU,EAAE,GAAG,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;YAChF,CAAC;YACD,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;YACpB,IAAI,OAAO,CAAC,cAAc,KAAK,IAAI;gBAAE,QAAQ,CAAC,GAAG,CAAC,OAAO,CAAC,cAAc,EAAE,QAAQ,CAAC,EAAE,CAAC,CAAC;YACvF,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,EAAE,EAAE,QAAQ,CAAC,EAAE,EAAE,CAAC;QAC7C,CAAC;KACF,CAAC;AACJ,CAAC"}
@@ -0,0 +1,3 @@
1
+ export { fakeMailProvider, type CapturedMail, type FakeMailProvider, type FakeMailProviderOptions } from "./fake-provider.js";
2
+ export { readMailOutbox } from "./outbox.js";
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/testing/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,gBAAgB,EAAE,KAAK,YAAY,EAAE,KAAK,gBAAgB,EAAE,KAAK,uBAAuB,EAAE,MAAM,oBAAoB,CAAC;AAC9H,OAAO,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC"}
@@ -0,0 +1,5 @@
1
+ // Test helpers of @softure-ai/mailing: a provider that captures mail instead of sending it, and a
2
+ // reader for its outbox file. Server-only (it writes files).
3
+ export { fakeMailProvider } from "./fake-provider.js";
4
+ export { readMailOutbox } from "./outbox.js";
5
+ //# sourceMappingURL=index.js.map