@softure-ai/mailing 0.0.0-stage → 0.1.5

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 (192) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +349 -2
  3. package/dist/address.d.ts +27 -0
  4. package/dist/address.d.ts.map +1 -0
  5. package/dist/address.js +76 -0
  6. package/dist/address.js.map +1 -0
  7. package/dist/cli/bin.d.ts +3 -0
  8. package/dist/cli/bin.d.ts.map +1 -0
  9. package/dist/cli/bin.js +5 -0
  10. package/dist/cli/bin.js.map +1 -0
  11. package/dist/cli/command.d.ts +9 -0
  12. package/dist/cli/command.d.ts.map +1 -0
  13. package/dist/cli/command.js +36 -0
  14. package/dist/cli/command.js.map +1 -0
  15. package/dist/cli/index.d.ts +3 -0
  16. package/dist/cli/index.d.ts.map +1 -0
  17. package/dist/cli/index.js +4 -0
  18. package/dist/cli/index.js.map +1 -0
  19. package/dist/cli/run.d.ts +31 -0
  20. package/dist/cli/run.d.ts.map +1 -0
  21. package/dist/cli/run.js +285 -0
  22. package/dist/cli/run.js.map +1 -0
  23. package/dist/contract.d.ts +102 -0
  24. package/dist/contract.d.ts.map +1 -0
  25. package/dist/contract.js +3 -0
  26. package/dist/contract.js.map +1 -0
  27. package/dist/index.d.ts +47 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +59 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/messages/en.d.ts +26 -0
  32. package/dist/messages/en.d.ts.map +1 -0
  33. package/dist/messages/en.js +26 -0
  34. package/dist/messages/en.js.map +1 -0
  35. package/dist/messages/index.d.ts +59 -0
  36. package/dist/messages/index.d.ts.map +1 -0
  37. package/dist/messages/index.js +11 -0
  38. package/dist/messages/index.js.map +1 -0
  39. package/dist/messages/pl.d.ts +3 -0
  40. package/dist/messages/pl.d.ts.map +1 -0
  41. package/dist/messages/pl.js +27 -0
  42. package/dist/messages/pl.js.map +1 -0
  43. package/dist/next/actions.d.ts +2 -0
  44. package/dist/next/actions.d.ts.map +1 -0
  45. package/dist/next/actions.js +35 -0
  46. package/dist/next/actions.js.map +1 -0
  47. package/dist/next/context.d.ts +4 -0
  48. package/dist/next/context.d.ts.map +1 -0
  49. package/dist/next/context.js +14 -0
  50. package/dist/next/context.js.map +1 -0
  51. package/dist/next/deliver-once.d.ts +4 -0
  52. package/dist/next/deliver-once.d.ts.map +1 -0
  53. package/dist/next/deliver-once.js +7 -0
  54. package/dist/next/deliver-once.js.map +1 -0
  55. package/dist/next/index.d.ts +8 -0
  56. package/dist/next/index.d.ts.map +1 -0
  57. package/dist/next/index.js +11 -0
  58. package/dist/next/index.js.map +1 -0
  59. package/dist/next/messages.d.ts +5 -0
  60. package/dist/next/messages.d.ts.map +1 -0
  61. package/dist/next/messages.js +7 -0
  62. package/dist/next/messages.js.map +1 -0
  63. package/dist/next/pages.d.ts +7 -0
  64. package/dist/next/pages.d.ts.map +1 -0
  65. package/dist/next/pages.js +40 -0
  66. package/dist/next/pages.js.map +1 -0
  67. package/dist/next/params.d.ts +4 -0
  68. package/dist/next/params.d.ts.map +1 -0
  69. package/dist/next/params.js +4 -0
  70. package/dist/next/params.js.map +1 -0
  71. package/dist/next/route.d.ts +14 -0
  72. package/dist/next/route.d.ts.map +1 -0
  73. package/dist/next/route.js +44 -0
  74. package/dist/next/route.js.map +1 -0
  75. package/dist/next/send-mail.d.ts +4 -0
  76. package/dist/next/send-mail.d.ts.map +1 -0
  77. package/dist/next/send-mail.js +16 -0
  78. package/dist/next/send-mail.js.map +1 -0
  79. package/dist/options.d.ts +13 -0
  80. package/dist/options.d.ts.map +1 -0
  81. package/dist/options.js +29 -0
  82. package/dist/options.js.map +1 -0
  83. package/dist/providers/resend.d.ts +17 -0
  84. package/dist/providers/resend.d.ts.map +1 -0
  85. package/dist/providers/resend.js +64 -0
  86. package/dist/providers/resend.js.map +1 -0
  87. package/dist/schema.d.ts +350 -0
  88. package/dist/schema.d.ts.map +1 -0
  89. package/dist/schema.js +30 -0
  90. package/dist/schema.js.map +1 -0
  91. package/dist/server/campaign-file.d.ts +28 -0
  92. package/dist/server/campaign-file.d.ts.map +1 -0
  93. package/dist/server/campaign-file.js +87 -0
  94. package/dist/server/campaign-file.js.map +1 -0
  95. package/dist/server/campaigns.d.ts +58 -0
  96. package/dist/server/campaigns.d.ts.map +1 -0
  97. package/dist/server/campaigns.js +118 -0
  98. package/dist/server/campaigns.js.map +1 -0
  99. package/dist/server/deliveries.d.ts +56 -0
  100. package/dist/server/deliveries.d.ts.map +1 -0
  101. package/dist/server/deliveries.js +100 -0
  102. package/dist/server/deliveries.js.map +1 -0
  103. package/dist/server/dns.d.ts +33 -0
  104. package/dist/server/dns.d.ts.map +1 -0
  105. package/dist/server/dns.js +113 -0
  106. package/dist/server/dns.js.map +1 -0
  107. package/dist/server/health.d.ts +3 -0
  108. package/dist/server/health.d.ts.map +1 -0
  109. package/dist/server/health.js +13 -0
  110. package/dist/server/health.js.map +1 -0
  111. package/dist/server/index.d.ts +12 -0
  112. package/dist/server/index.d.ts.map +1 -0
  113. package/dist/server/index.js +14 -0
  114. package/dist/server/index.js.map +1 -0
  115. package/dist/server/list-mail.d.ts +20 -0
  116. package/dist/server/list-mail.d.ts.map +1 -0
  117. package/dist/server/list-mail.js +31 -0
  118. package/dist/server/list-mail.js.map +1 -0
  119. package/dist/server/options.d.ts +14 -0
  120. package/dist/server/options.d.ts.map +1 -0
  121. package/dist/server/options.js +22 -0
  122. package/dist/server/options.js.map +1 -0
  123. package/dist/server/send-mail.d.ts +19 -0
  124. package/dist/server/send-mail.d.ts.map +1 -0
  125. package/dist/server/send-mail.js +129 -0
  126. package/dist/server/send-mail.js.map +1 -0
  127. package/dist/server/suppressions.d.ts +27 -0
  128. package/dist/server/suppressions.d.ts.map +1 -0
  129. package/dist/server/suppressions.js +64 -0
  130. package/dist/server/suppressions.js.map +1 -0
  131. package/dist/server/unsubscribe-link.d.ts +51 -0
  132. package/dist/server/unsubscribe-link.d.ts.map +1 -0
  133. package/dist/server/unsubscribe-link.js +78 -0
  134. package/dist/server/unsubscribe-link.js.map +1 -0
  135. package/dist/server/validate-mail.d.ts +23 -0
  136. package/dist/server/validate-mail.d.ts.map +1 -0
  137. package/dist/server/validate-mail.js +52 -0
  138. package/dist/server/validate-mail.js.map +1 -0
  139. package/dist/testing/fake-provider.d.ts +24 -0
  140. package/dist/testing/fake-provider.d.ts.map +1 -0
  141. package/dist/testing/fake-provider.js +43 -0
  142. package/dist/testing/fake-provider.js.map +1 -0
  143. package/dist/testing/index.d.ts +3 -0
  144. package/dist/testing/index.d.ts.map +1 -0
  145. package/dist/testing/index.js +5 -0
  146. package/dist/testing/index.js.map +1 -0
  147. package/dist/testing/outbox.d.ts +6 -0
  148. package/dist/testing/outbox.d.ts.map +1 -0
  149. package/dist/testing/outbox.js +32 -0
  150. package/dist/testing/outbox.js.map +1 -0
  151. package/migrations/0001_create_suppressions.sql +13 -0
  152. package/migrations/0002_create_campaigns_and_deliveries.sql +41 -0
  153. package/module.json +32 -0
  154. package/package.json +70 -4
  155. package/src/address.ts +89 -0
  156. package/src/cli/bin.ts +5 -0
  157. package/src/cli/command.ts +44 -0
  158. package/src/cli/index.ts +3 -0
  159. package/src/cli/run.ts +315 -0
  160. package/src/contract.ts +108 -0
  161. package/src/index.ts +85 -0
  162. package/src/messages/en.ts +25 -0
  163. package/src/messages/index.ts +15 -0
  164. package/src/messages/pl.ts +28 -0
  165. package/src/next/actions.ts +35 -0
  166. package/src/next/context.ts +15 -0
  167. package/src/next/deliver-once.ts +10 -0
  168. package/src/next/index.ts +10 -0
  169. package/src/next/messages.ts +9 -0
  170. package/src/next/next-modules.d.ts +12 -0
  171. package/src/next/pages.tsx +69 -0
  172. package/src/next/params.ts +6 -0
  173. package/src/next/route.ts +45 -0
  174. package/src/next/send-mail.ts +15 -0
  175. package/src/options.ts +34 -0
  176. package/src/providers/resend.ts +86 -0
  177. package/src/schema.ts +40 -0
  178. package/src/server/campaign-file.ts +101 -0
  179. package/src/server/campaigns.ts +175 -0
  180. package/src/server/deliveries.ts +155 -0
  181. package/src/server/dns.ts +153 -0
  182. package/src/server/health.ts +14 -0
  183. package/src/server/index.ts +56 -0
  184. package/src/server/list-mail.ts +42 -0
  185. package/src/server/options.ts +32 -0
  186. package/src/server/send-mail.ts +152 -0
  187. package/src/server/suppressions.ts +77 -0
  188. package/src/server/unsubscribe-link.ts +109 -0
  189. package/src/server/validate-mail.ts +68 -0
  190. package/src/testing/fake-provider.ts +66 -0
  191. package/src/testing/index.ts +4 -0
  192. package/src/testing/outbox.ts +32 -0
@@ -0,0 +1,77 @@
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, type ModuleContext, type Result } from "@softure-ai/core";
7
+ import type { Queryable } from "@softure-ai/db";
8
+ import { and, eq, inArray } from "drizzle-orm";
9
+ import type { SuppressionSource, UnsubscribeErrorCode } from "../contract.js";
10
+ import { suppressions } from "../schema.js";
11
+ import { getMailingOptions } from "./options.js";
12
+ import { getRecipientKey, readUnsubscribeSecrets, verifyUnsubscribeToken, type Env, type UnsubscribeToken } from "./unsubscribe-link.js";
13
+
14
+ export type SuppressionContext = ModuleContext<Queryable>;
15
+
16
+ /** Whether `address` unsubscribed. Throws on a database failure: callers decide how to fail. */
17
+ export async function isSuppressed(ctx: Pick<SuppressionContext, "db">, address: string): Promise<boolean> {
18
+ const rows = await ctx.db
19
+ .select({ recipientKey: suppressions.recipientKey })
20
+ .from(suppressions)
21
+ .where(eq(suppressions.recipientKey, getRecipientKey(address)))
22
+ .limit(1);
23
+ return rows.length > 0;
24
+ }
25
+
26
+ /** Records an opt-out by recipient key. Throws on a database failure. */
27
+ async function recordSuppression(ctx: SuppressionContext, recipientKey: string, source: SuppressionSource): Promise<void> {
28
+ await ctx.db.insert(suppressions).values({ recipientKey, source, createdAt: ctx.clock.now() }).onConflictDoNothing();
29
+ }
30
+
31
+ /**
32
+ * Unsubscribes `address` from list mail, for an operator's script or another module (a bounce or
33
+ * complaint handler). Throws on a database failure.
34
+ */
35
+ export async function suppressRecipient(ctx: SuppressionContext, address: string, source: SuppressionSource = "operator"): Promise<void> {
36
+ await recordSuppression(ctx, getRecipientKey(address), source);
37
+ }
38
+
39
+ /** The opt-outs a recipient made themselves; only these can be lifted by a new consent. */
40
+ const OWN_SOURCES: readonly SuppressionSource[] = ["one-click", "page"];
41
+
42
+ /**
43
+ * Lifts the opt-out of `address` when the recipient made it themselves (`page`, `one-click`), for a
44
+ * module that just recorded their new explicit consent, in that consent's transaction. An
45
+ * `operator` row (a bounce, a complaint, a script) stays. Returns whether a row was lifted.
46
+ * Throws on a database failure.
47
+ */
48
+ export async function liftSuppression(ctx: Pick<SuppressionContext, "db">, address: string): Promise<boolean> {
49
+ const lifted = await ctx.db
50
+ .delete(suppressions)
51
+ .where(and(eq(suppressions.recipientKey, getRecipientKey(address)), inArray(suppressions.source, [...OWN_SOURCES])))
52
+ .returning();
53
+ return lifted.length > 0;
54
+ }
55
+
56
+ /**
57
+ * Unsubscribes the recipient of a signed link and runs `onUnsubscribed` in the same transaction,
58
+ * also when the recipient had already unsubscribed (a retried one-click heals a missed hook). The
59
+ * signature is checked before the database is touched; a link that does not verify is
60
+ * `mailing.invalid_link`. Throws on a database failure or when the hook throws (nothing is stored).
61
+ */
62
+ export async function unsubscribe(
63
+ ctx: SuppressionContext,
64
+ token: UnsubscribeToken | null,
65
+ source: Exclude<SuppressionSource, "operator">,
66
+ env: Env = process.env,
67
+ ): Promise<Result<undefined, UnsubscribeErrorCode>> {
68
+ if (token === null || !verifyUnsubscribeToken(token, readUnsubscribeSecrets(env))) return err("mailing.invalid_link");
69
+ const { onUnsubscribed } = getMailingOptions(ctx.config);
70
+ const { recipientKey } = token;
71
+ await ctx.db.transaction(async (tx) => {
72
+ const txCtx: SuppressionContext = { ...ctx, db: tx };
73
+ await recordSuppression(txCtx, recipientKey, source);
74
+ await onUnsubscribed?.({ recipientKey, source }, txCtx);
75
+ });
76
+ return ok();
77
+ }
@@ -0,0 +1,109 @@
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 type { SoftureConfig } from "@softure-ai/core";
8
+ import { getMailingRoutes } from "./options.js";
9
+
10
+ /** Signs every new link and verifies. */
11
+ export const UNSUBSCRIBE_SECRET_ENV = "MAILING_UNSUBSCRIBE_SECRET";
12
+ /** Optional: verifies links signed before a rotation, never signs. */
13
+ export const UNSUBSCRIBE_PREVIOUS_SECRET_ENV = "MAILING_UNSUBSCRIBE_SECRET_PREVIOUS";
14
+ /** A shorter secret counts as missing: a guessable key lets anyone mint links. */
15
+ export const MIN_UNSUBSCRIBE_SECRET_LENGTH = 32;
16
+
17
+ /** The query parameters of both links. */
18
+ export const RECIPIENT_PARAM = "r";
19
+ export const SIGNATURE_PARAM = "t";
20
+
21
+ /**
22
+ * Domain separation: the same key signing anything else can never produce a valid unsubscribe
23
+ * signature. Changing it voids every link already sent, exactly like changing the key.
24
+ */
25
+ const SIGNED_PREFIX = "softure.mailing.unsubscribe.v1:";
26
+
27
+ /** 32 bytes in unpadded base64url. */
28
+ const TOKEN_PATTERN = /^[A-Za-z0-9_-]{43}$/;
29
+
30
+ export type Env = Readonly<Record<string, string | undefined>>;
31
+
32
+ /** The key pair a link carries: the recipient key and its signature. */
33
+ export interface UnsubscribeToken {
34
+ readonly recipientKey: string;
35
+ readonly signature: string;
36
+ }
37
+
38
+ /** Both links of one mail: the same token, two targets. */
39
+ export interface UnsubscribeLinks {
40
+ /** The page with a button, for the mail's footer (opening it changes nothing). */
41
+ readonly page: string;
42
+ /** The RFC 8058 one-click target, for the `List-Unsubscribe` header. */
43
+ readonly oneClick: string;
44
+ }
45
+
46
+ /** The secrets in use: `current` signs, both verify. `current` is null when it is not set. */
47
+ export interface UnsubscribeSecrets {
48
+ readonly current: string | null;
49
+ readonly previous: string | null;
50
+ }
51
+
52
+ /**
53
+ * The stored and linked identity of an address: base64url SHA-256 of the trimmed, lowercased
54
+ * address. Lowercasing the local part merges `Ada@` and `ada@`, which errs on the side of not
55
+ * mailing someone who opted out.
56
+ */
57
+ export function getRecipientKey(address: string): string {
58
+ return createHash("sha256").update(address.trim().toLowerCase()).digest("base64url");
59
+ }
60
+
61
+ /** The signature of a recipient key. */
62
+ export function signRecipientKey(recipientKey: string, secret: string): string {
63
+ return createHmac("sha256", secret).update(`${SIGNED_PREFIX}${recipientKey}`).digest("base64url");
64
+ }
65
+
66
+ /** The secrets from the environment; one shorter than 32 characters reads as not set. */
67
+ export function readUnsubscribeSecrets(env: Env = process.env): UnsubscribeSecrets {
68
+ return { current: readSecret(env[UNSUBSCRIBE_SECRET_ENV]), previous: readSecret(env[UNSUBSCRIBE_PREVIOUS_SECRET_ENV]) };
69
+ }
70
+
71
+ function readSecret(value: string | undefined): string | null {
72
+ return value !== undefined && value.length >= MIN_UNSUBSCRIBE_SECRET_LENGTH ? value : null;
73
+ }
74
+
75
+ /**
76
+ * Whether the token was signed by the current or the previous secret. Never throws: the shape is
77
+ * checked first (`timingSafeEqual` throws on buffers of different lengths, and a throw would be a
78
+ * second, faster answer), then every secret is compared in constant time.
79
+ */
80
+ export function verifyUnsubscribeToken(token: UnsubscribeToken, secrets: UnsubscribeSecrets): boolean {
81
+ if (!TOKEN_PATTERN.test(token.recipientKey) || !TOKEN_PATTERN.test(token.signature)) return false;
82
+ const candidate = Buffer.from(token.signature, "base64url");
83
+ let isValid = false;
84
+ for (const secret of [secrets.current, secrets.previous]) {
85
+ if (secret === null) continue;
86
+ const expected = Buffer.from(signRecipientKey(token.recipientKey, secret), "base64url");
87
+ // No early exit: both comparisons run whichever matches.
88
+ isValid = timingSafeEqual(candidate, expected) || isValid;
89
+ }
90
+ return isValid;
91
+ }
92
+
93
+ /** The two links for `address`, on the app's origin and the module's routes. */
94
+ export function buildUnsubscribeLinks(config: SoftureConfig, address: string, secret: string): UnsubscribeLinks {
95
+ const recipientKey = getRecipientKey(address);
96
+ const query = new URLSearchParams({ [RECIPIENT_PARAM]: recipientKey, [SIGNATURE_PARAM]: signRecipientKey(recipientKey, secret) });
97
+ const routes = getMailingRoutes(config);
98
+ return {
99
+ page: `${config.appOrigin}${routes.unsubscribe}?${query.toString()}`,
100
+ oneClick: `${config.appOrigin}${routes.oneClick}?${query.toString()}`,
101
+ };
102
+ }
103
+
104
+ /** The token in a link's query (or a form), when both parameters are present once. */
105
+ export function readUnsubscribeToken(params: { get(name: string): unknown }): UnsubscribeToken | null {
106
+ const recipientKey = params.get(RECIPIENT_PARAM);
107
+ const signature = params.get(SIGNATURE_PARAM);
108
+ return typeof recipientKey === "string" && typeof signature === "string" ? { recipientKey, signature } : null;
109
+ }
@@ -0,0 +1,68 @@
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
+
7
+ const headersSchema = z.record(z.string(), z.string()).superRefine((headers, context) => {
8
+ for (const [name, value] of Object.entries(headers)) {
9
+ if (!isHeaderName(name) || isReservedHeader(name) || hasLineBreak(value)) {
10
+ context.addIssue({ code: "custom", message: "invalid or reserved header" });
11
+ }
12
+ }
13
+ });
14
+
15
+ const mailSchema = z.object({
16
+ to: z.string().trim().refine(isSingleAddress),
17
+ subject: z
18
+ .string()
19
+ .refine((subject) => subject.trim() !== "" && subject.length <= MAX_SUBJECT_LENGTH && !hasLineBreak(subject)),
20
+ text: z.string().refine((text) => text.trim() !== ""),
21
+ html: z
22
+ .string()
23
+ .refine((html) => html.trim() !== "")
24
+ .optional(),
25
+ headers: headersSchema.optional(),
26
+ kind: z.string().refine(isMailKind).default(TRANSACTIONAL_KIND),
27
+ }).superRefine((mail, context) => {
28
+ // The module writes these two on list mail; a second, unsigned copy would confuse clients.
29
+ if (mail.kind !== TRANSACTIONAL_KIND && Object.keys(mail.headers ?? {}).some(isListMailHeader)) {
30
+ context.addIssue({ code: "custom", path: ["headers"], message: "list mail gets its unsubscribe headers from the module" });
31
+ }
32
+ });
33
+
34
+ const optionsSchema = z.object({
35
+ idempotencyKey: z.string().refine(isIdempotencyKey).optional(),
36
+ });
37
+
38
+ export interface ValidMail {
39
+ readonly to: string;
40
+ readonly subject: string;
41
+ readonly text: string;
42
+ readonly html: string | null;
43
+ readonly headers: Readonly<Record<string, string>>;
44
+ /** `transactional` or a list kind. */
45
+ readonly kind: string;
46
+ readonly idempotencyKey: string | null;
47
+ }
48
+
49
+ export type MailValidation = { readonly ok: true; readonly value: ValidMail } | { readonly ok: false; readonly fields: readonly string[] };
50
+
51
+ /**
52
+ * Checks one mail and its send options. `fields` lists what failed (`to`, `subject`, `text`,
53
+ * `html`, `headers`, `kind`, `idempotencyKey`, or `mail` when the input is not an object).
54
+ */
55
+ export function validateMail(mail: unknown, options: unknown = {}): MailValidation {
56
+ const parsedMail = mailSchema.safeParse(mail);
57
+ const parsedOptions = optionsSchema.safeParse(options ?? {});
58
+ if (!parsedMail.success || !parsedOptions.success) {
59
+ const issues = [...(parsedMail.error?.issues ?? []), ...(parsedOptions.error?.issues ?? [])];
60
+ const fields = new Set(issues.map((issue) => (issue.path.length === 0 ? "mail" : String(issue.path[0]))));
61
+ return { ok: false, fields: [...fields] };
62
+ }
63
+ const { to, subject, text, html, headers, kind } = parsedMail.data;
64
+ return {
65
+ ok: true,
66
+ value: { to, subject, text, html: html ?? null, headers: headers ?? {}, kind, idempotencyKey: parsedOptions.data.idempotencyKey ?? null },
67
+ };
68
+ }
@@ -0,0 +1,66 @@
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
+ import type { MailProvider, ProviderMessage, ProviderOutcome } from "../contract.js";
7
+
8
+ /** A captured mail: what the provider received, with the id it answered. */
9
+ export interface CapturedMail extends ProviderMessage {
10
+ readonly id: string;
11
+ }
12
+
13
+ export interface FakeMailProviderOptions {
14
+ /** A file to append each captured mail to, as one JSON line (`readMailOutbox` reads it). */
15
+ readonly outboxFile?: string;
16
+ /**
17
+ * Decides the outcome per mail, to test failure handling. Return `undefined` (or omit the
18
+ * option) to accept the mail.
19
+ */
20
+ readonly respond?: (message: ProviderMessage) => Exclude<ProviderOutcome, { status: "sent" }> | undefined;
21
+ }
22
+
23
+ export interface FakeMailProvider extends MailProvider {
24
+ /** Every mail accepted so far, oldest first. */
25
+ readonly sent: readonly CapturedMail[];
26
+ /** Forgets the captured mail and the idempotency keys. */
27
+ clear(): void;
28
+ }
29
+
30
+ export function fakeMailProvider(options: FakeMailProviderOptions = {}): FakeMailProvider {
31
+ const sent: CapturedMail[] = [];
32
+ const idsByKey = new Map<string, string>();
33
+ let counter = 0;
34
+
35
+ return {
36
+ name: "fake",
37
+ get sent() {
38
+ return [...sent];
39
+ },
40
+ clear() {
41
+ sent.length = 0;
42
+ idsByKey.clear();
43
+ },
44
+ async send(message) {
45
+ if (options.outboxFile === undefined && process.env.NODE_ENV === "production") {
46
+ // In memory, production mail would vanish without a trace.
47
+ console.error("mailing: fakeMailProvider() refuses to run in production without an outboxFile; configure a real provider");
48
+ return { status: "unavailable" };
49
+ }
50
+ const refusal = options.respond?.(message);
51
+ if (refusal !== undefined) return refusal;
52
+
53
+ const knownId = message.idempotencyKey === null ? undefined : idsByKey.get(message.idempotencyKey);
54
+ if (knownId !== undefined) return { status: "sent", id: knownId };
55
+
56
+ counter += 1;
57
+ const captured: CapturedMail = { ...message, headers: { ...message.headers }, id: `fake-${String(counter)}-${crypto.randomUUID()}` };
58
+ if (options.outboxFile !== undefined) {
59
+ await appendFile(options.outboxFile, `${JSON.stringify(captured)}\n`, "utf8");
60
+ }
61
+ sent.push(captured);
62
+ if (message.idempotencyKey !== null) idsByKey.set(message.idempotencyKey, captured.id);
63
+ return { status: "sent", id: captured.id };
64
+ },
65
+ };
66
+ }
@@ -0,0 +1,4 @@
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, type CapturedMail, type FakeMailProvider, type FakeMailProviderOptions } from "./fake-provider.js";
4
+ export { readMailOutbox } from "./outbox.js";
@@ -0,0 +1,32 @@
1
+ // Reads the outbox file of `fakeMailProvider({ outboxFile })`, e.g. from a Playwright test.
2
+ import { readFile } from "node:fs/promises";
3
+ import { z } from "zod";
4
+ import type { CapturedMail } from "./fake-provider.js";
5
+
6
+ const capturedMailSchema = z.object({
7
+ id: z.string(),
8
+ from: z.string(),
9
+ to: z.string(),
10
+ replyTo: z.string().nullable(),
11
+ subject: z.string(),
12
+ text: z.string(),
13
+ html: z.string().nullable(),
14
+ headers: z.record(z.string(), z.string()),
15
+ idempotencyKey: z.string().nullable(),
16
+ });
17
+
18
+ /** Every mail in the outbox, oldest first, optionally only those to `to`. A missing file is an empty outbox. */
19
+ export async function readMailOutbox(file: string, filter: { readonly to?: string } = {}): Promise<CapturedMail[]> {
20
+ let text: string;
21
+ try {
22
+ text = await readFile(file, "utf8");
23
+ } catch (error) {
24
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
25
+ throw error;
26
+ }
27
+ return text
28
+ .split("\n")
29
+ .filter((line) => line !== "")
30
+ .map((line) => capturedMailSchema.parse(JSON.parse(line)))
31
+ .filter((mail) => filter.to === undefined || mail.to === filter.to);
32
+ }