@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,155 @@
1
+ // The delivery ledger (mailing.deliveries): a mail goes out at most once per scope and recipient.
2
+ // The sender claims the row in one conditional upsert before it calls the provider, then closes it
3
+ // with one outcome. A claim left behind by a crash is taken over once it is stale, and the mail is
4
+ // sent again with the same idempotency key, so the provider folds it into the first send.
5
+ import { type ModuleContext } from "@softure-ai/core";
6
+ import type { Queryable } from "@softure-ai/db";
7
+ import { and, eq, lt, or, sql } from "drizzle-orm";
8
+ import { isMailKind } from "../address.js";
9
+ import { TRANSACTIONAL_KIND, type MailingErrorCode, type OutgoingMail } from "../contract.js";
10
+ import { deliveries } from "../schema.js";
11
+ import { sendMail } from "./send-mail.js";
12
+ import { getRecipientKey } from "./unsubscribe-link.js";
13
+
14
+ export type DeliveryContext = ModuleContext<Queryable>;
15
+
16
+ /** How long a claim may stay open before another sender may take it over. */
17
+ export const DEFAULT_STALE_CLAIM_MS = 15 * 60_000;
18
+ /** Claims a delivery gets before `mailing.unavailable` becomes its final outcome. */
19
+ export const DEFAULT_MAX_ATTEMPTS = 5;
20
+
21
+ const MAX_SCOPE_LENGTH = 128;
22
+ const SCOPE = /^[a-z0-9][a-z0-9._:-]*$/;
23
+
24
+ export interface Delivery {
25
+ /**
26
+ * What the mail is about, unique per recipient: `campaign:<id>` for campaigns (set by
27
+ * `sendCampaign`), `<module>.<event>:<entity>` for lifecycle mail, e.g.
28
+ * `billing.trial-ending:sub_42`. Lowercase letters, digits and `._:-`, at most 128 characters.
29
+ */
30
+ readonly scope: string;
31
+ /** The mail. Its `kind` is stored with the row; the idempotency key comes from the ledger. */
32
+ readonly mail: OutgoingMail;
33
+ /** The campaign the delivery belongs to; its scope must then be `campaign:<id>`. */
34
+ readonly campaignId?: string;
35
+ }
36
+
37
+ export interface DeliverOptions {
38
+ readonly staleClaimMs?: number;
39
+ readonly maxAttempts?: number;
40
+ }
41
+
42
+ export type DeliveryOutcome =
43
+ /** Sent now; `id` is the provider's message id. */
44
+ | { readonly status: "sent"; readonly id: string }
45
+ /** Refused now, for good (suppressed, rejected, invalid, or unavailable too many times). */
46
+ | { readonly status: "rejected"; readonly reason: MailingErrorCode }
47
+ /** An earlier run already closed this delivery; nothing was sent. */
48
+ | { readonly status: "done"; readonly outcome: "sent" | "rejected" }
49
+ /** Another sender holds a fresh claim; nothing was sent. */
50
+ | { readonly status: "in-flight" }
51
+ /** The provider was unavailable; the claim is released for a later run. */
52
+ | { readonly status: "retry-later" };
53
+
54
+ /**
55
+ * Sends `delivery.mail` unless the ledger already has an outcome (or a fresh claim) for its scope
56
+ * and recipient, and records the outcome. Throws on a database failure (after a send, the claim
57
+ * stays open and becomes stale, so a later run re-sends with the same idempotency key), for a
58
+ * malformed scope or kind, and when `sendMail` throws.
59
+ */
60
+ export async function deliverOnce(ctx: DeliveryContext, delivery: Delivery, options: DeliverOptions = {}): Promise<DeliveryOutcome> {
61
+ const { staleClaimMs = DEFAULT_STALE_CLAIM_MS, maxAttempts = DEFAULT_MAX_ATTEMPTS } = options;
62
+ const kind = delivery.mail.kind ?? TRANSACTIONAL_KIND;
63
+ assertDelivery(delivery, kind);
64
+
65
+ const recipientKey = getRecipientKey(delivery.mail.to);
66
+ const key = { scope: delivery.scope, recipientKey };
67
+ const attempt = await claimDelivery(ctx, { ...key, kind, campaignId: delivery.campaignId ?? null, staleClaimMs });
68
+ if (attempt === null) return readClosedDelivery(ctx, key);
69
+
70
+ const result = await sendMail(ctx, delivery.mail, { idempotencyKey: `${delivery.scope}:${recipientKey}` });
71
+ const fence = { ...key, attempt };
72
+ if (result.ok) {
73
+ await closeDelivery(ctx, fence, { status: "sent", providerMessageId: result.value.id, reason: null });
74
+ return { status: "sent", id: result.value.id };
75
+ }
76
+ if (result.error === "mailing.unavailable" && attempt < maxAttempts) {
77
+ await releaseDelivery(ctx, fence);
78
+ return { status: "retry-later" };
79
+ }
80
+ await closeDelivery(ctx, fence, { status: "rejected", providerMessageId: null, reason: result.error });
81
+ return { status: "rejected", reason: result.error };
82
+ }
83
+
84
+ function assertDelivery(delivery: Delivery, kind: string): void {
85
+ if (delivery.scope.length > MAX_SCOPE_LENGTH || !SCOPE.test(delivery.scope)) {
86
+ throw new Error(`@softure-ai/mailing: deliverOnce scope "${delivery.scope}" must be lowercase letters, digits and ._:- (at most 128 characters)`);
87
+ }
88
+ if (!isMailKind(kind)) {
89
+ throw new Error(`@softure-ai/mailing: deliverOnce kind "${kind}" must be kebab-case (at most 64 characters)`);
90
+ }
91
+ if (delivery.campaignId !== undefined && delivery.scope !== `campaign:${delivery.campaignId}`) {
92
+ throw new Error(`@softure-ai/mailing: a delivery of campaign "${delivery.campaignId}" must use the scope "campaign:${delivery.campaignId}"`);
93
+ }
94
+ }
95
+
96
+ interface DeliveryKey {
97
+ readonly scope: string;
98
+ readonly recipientKey: string;
99
+ }
100
+
101
+ /**
102
+ * Takes the row in one statement: a new row, a `pending` one, or a claim older than
103
+ * `staleClaimMs`. Resolves with the attempt number that fences the outcome, or `null` when the row
104
+ * is closed or claimed by someone else.
105
+ */
106
+ async function claimDelivery(
107
+ ctx: DeliveryContext,
108
+ claim: DeliveryKey & { readonly kind: string; readonly campaignId: string | null; readonly staleClaimMs: number },
109
+ ): Promise<number | null> {
110
+ const now = ctx.clock.now();
111
+ const staleBefore = new Date(now.getTime() - claim.staleClaimMs);
112
+ const rows = await ctx.db
113
+ .insert(deliveries)
114
+ .values({ scope: claim.scope, recipientKey: claim.recipientKey, kind: claim.kind, campaignId: claim.campaignId, status: "claimed", attempts: 1, claimedAt: now, createdAt: now })
115
+ .onConflictDoUpdate({
116
+ target: [deliveries.scope, deliveries.recipientKey],
117
+ set: { status: "claimed", attempts: sql`${deliveries.attempts} + 1`, claimedAt: now },
118
+ setWhere: or(eq(deliveries.status, "pending"), and(eq(deliveries.status, "claimed"), lt(deliveries.claimedAt, staleBefore))),
119
+ })
120
+ .returning();
121
+ return rows[0]?.attempts ?? null;
122
+ }
123
+
124
+ async function readClosedDelivery(ctx: DeliveryContext, key: DeliveryKey): Promise<DeliveryOutcome> {
125
+ const rows = await ctx.db.select({ status: deliveries.status }).from(deliveries).where(matchKey(key)).limit(1);
126
+ const status = rows[0]?.status;
127
+ if (status === "sent" || status === "rejected") return { status: "done", outcome: status };
128
+ // A fresh claim; `pending` only between another sender's release and this read.
129
+ return { status: "in-flight" };
130
+ }
131
+
132
+ type Fence = DeliveryKey & { readonly attempt: number };
133
+
134
+ /** Writes the outcome if this sender still holds the claim; a stale holder writes nothing. */
135
+ async function closeDelivery(
136
+ ctx: DeliveryContext,
137
+ fence: Fence,
138
+ outcome: { readonly status: "sent" | "rejected"; readonly providerMessageId: string | null; readonly reason: string | null },
139
+ ): Promise<void> {
140
+ await ctx.db
141
+ .update(deliveries)
142
+ .set({ ...outcome, finishedAt: ctx.clock.now() })
143
+ .where(and(matchKey(fence), eq(deliveries.status, "claimed"), eq(deliveries.attempts, fence.attempt)));
144
+ }
145
+
146
+ async function releaseDelivery(ctx: DeliveryContext, fence: Fence): Promise<void> {
147
+ await ctx.db
148
+ .update(deliveries)
149
+ .set({ status: "pending" })
150
+ .where(and(matchKey(fence), eq(deliveries.status, "claimed"), eq(deliveries.attempts, fence.attempt)));
151
+ }
152
+
153
+ function matchKey(key: DeliveryKey) {
154
+ return and(eq(deliveries.scope, key.scope), eq(deliveries.recipientKey, key.recipientKey));
155
+ }
@@ -0,0 +1,153 @@
1
+ // The sender domain's DNS, as receivers check it: SPF (RFC 7208), DKIM (RFC 6376) and DMARC
2
+ // (RFC 7489). Without all three, list mail lands in spam or is refused outright. The check only
3
+ // reads public TXT records; it cannot see whether the provider signs with the published key.
4
+ import { resolveTxt as resolveTxtRecords } from "node:dns/promises";
5
+
6
+ export type DnsCheckStatus = "pass" | "warn" | "fail";
7
+
8
+ export type DnsFinding =
9
+ | "found"
10
+ | "missing"
11
+ | "multiple"
12
+ | "permissive"
13
+ | "revoked"
14
+ | "monitor-only"
15
+ | "inherited"
16
+ | "lookup-failed";
17
+
18
+ export interface DnsCheck {
19
+ readonly status: DnsCheckStatus;
20
+ readonly finding: DnsFinding;
21
+ /** The name that answered (or the first one asked, when none did). */
22
+ readonly host: string;
23
+ /** The record that decided the status, when there is one. */
24
+ readonly record: string | null;
25
+ }
26
+
27
+ export interface SenderDnsReport {
28
+ readonly domain: string;
29
+ readonly spf: DnsCheck;
30
+ readonly dkim: DnsCheck;
31
+ readonly dmarc: DnsCheck;
32
+ }
33
+
34
+ export type ResolveTxt = (hostname: string) => Promise<string[][]>;
35
+
36
+ export interface CheckSenderDnsOptions {
37
+ /** DKIM selectors to look up under `_domainkey`. Default `["resend"]`, the Resend selector. */
38
+ readonly dkimSelectors?: readonly string[];
39
+ /**
40
+ * Where SPF is published. Default: the domain itself. Resend checks SPF on `send.<domain>` (its
41
+ * return path); DMARC accepts that in relaxed alignment.
42
+ */
43
+ readonly spfHosts?: readonly string[];
44
+ readonly resolveTxt?: ResolveTxt;
45
+ }
46
+
47
+ export const DEFAULT_DKIM_SELECTORS: readonly string[] = ["resend"];
48
+
49
+ /** Reads the SPF, DKIM and DMARC records of `domain`. Never throws: a failed lookup is a finding. */
50
+ export async function checkSenderDns(domain: string, options: CheckSenderDnsOptions = {}): Promise<SenderDnsReport> {
51
+ const name = domain.trim().toLowerCase().replace(/\.$/, "");
52
+ const resolveTxt = options.resolveTxt ?? resolveTxtRecords;
53
+ const lookup = (host: string) => lookupTxt(resolveTxt, host);
54
+ const [spf, dkim, dmarc] = await Promise.all([
55
+ checkSpf(lookup, options.spfHosts ?? [name]),
56
+ checkDkim(lookup, (options.dkimSelectors ?? DEFAULT_DKIM_SELECTORS).map((selector) => `${selector}._domainkey.${name}`)),
57
+ checkDmarc(lookup, name),
58
+ ]);
59
+ return { domain: name, spf, dkim, dmarc };
60
+ }
61
+
62
+ /** The domain part of a sender (`Plan <hello@mail.example.com>` gives `mail.example.com`). */
63
+ export function getSenderDomain(from: string): string {
64
+ // Index arithmetic, not a regular expression: `from` may come from anywhere, and a pattern over
65
+ // `<...>` backtracks polynomially on input like "<<<<".
66
+ const open = from.lastIndexOf("<");
67
+ const close = from.lastIndexOf(">");
68
+ const address = open !== -1 && close > open ? from.slice(open + 1, close) : from;
69
+ return address.slice(address.lastIndexOf("@") + 1).trim().toLowerCase();
70
+ }
71
+
72
+ type Lookup = (host: string) => Promise<TxtAnswer>;
73
+ type TxtAnswer = { readonly kind: "records"; readonly records: string[] } | { readonly kind: "none" } | { readonly kind: "error" };
74
+
75
+ const NO_RECORD_CODES = new Set(["ENOTFOUND", "ENODATA", "NXDOMAIN"]);
76
+
77
+ async function lookupTxt(resolveTxt: ResolveTxt, host: string): Promise<TxtAnswer> {
78
+ try {
79
+ // A TXT record may arrive in 255-byte chunks; they form one string.
80
+ const records = (await resolveTxt(host)).map((chunks) => chunks.join(""));
81
+ return records.length === 0 ? { kind: "none" } : { kind: "records", records };
82
+ } catch (error) {
83
+ const code = (error as { code?: unknown } | null)?.code;
84
+ return typeof code === "string" && NO_RECORD_CODES.has(code) ? { kind: "none" } : { kind: "error" };
85
+ }
86
+ }
87
+
88
+ function startsWithTag(record: string, tag: string): boolean {
89
+ return record.trim().toLowerCase().startsWith(tag);
90
+ }
91
+
92
+ /** One `v=spf1` record per host; `+all` (or a bare `all`) lets anyone send as the domain. */
93
+ async function checkSpf(lookup: Lookup, hosts: readonly string[]): Promise<DnsCheck> {
94
+ let lookupFailed: string | null = null;
95
+ for (const host of hosts) {
96
+ const answer = await lookup(host);
97
+ if (answer.kind === "error") {
98
+ lookupFailed ??= host;
99
+ continue;
100
+ }
101
+ const records = answer.kind === "records" ? answer.records.filter((record) => /^v=spf1(\s|$)/i.test(record.trim())) : [];
102
+ if (records.length > 1) return { status: "fail", finding: "multiple", host, record: null };
103
+ const record = records[0];
104
+ if (record === undefined) continue;
105
+ const permissive = /(^|\s)\+?all(\s|$)/i.test(record);
106
+ return permissive ? { status: "warn", finding: "permissive", host, record } : { status: "pass", finding: "found", host, record };
107
+ }
108
+ return lookupFailed === null ? { status: "fail", finding: "missing", host: hosts[0] ?? "", record: null } : { status: "fail", finding: "lookup-failed", host: lookupFailed, record: null };
109
+ }
110
+
111
+ /** A key record (`p=`) under one of the selectors; an empty `p=` means the key was revoked. */
112
+ async function checkDkim(lookup: Lookup, hosts: readonly string[]): Promise<DnsCheck> {
113
+ let fallback: DnsCheck = { status: "fail", finding: "missing", host: hosts[0] ?? "", record: null };
114
+ for (const host of hosts) {
115
+ const answer = await lookup(host);
116
+ if (answer.kind === "error") {
117
+ if (fallback.finding === "missing") fallback = { status: "fail", finding: "lookup-failed", host, record: null };
118
+ continue;
119
+ }
120
+ if (answer.kind === "none") continue;
121
+ const record = answer.records.find((candidate) => /(^|;)\s*p=/i.test(candidate));
122
+ if (record === undefined) continue;
123
+ const key = /(?:^|;)\s*p=([^;]*)/i.exec(record)?.[1]?.trim() ?? "";
124
+ if (key !== "") return { status: "pass", finding: "found", host, record };
125
+ fallback = { status: "fail", finding: "revoked", host, record };
126
+ }
127
+ return fallback;
128
+ }
129
+
130
+ /**
131
+ * `_dmarc.<domain>`, or the nearest parent's record (the organisational domain's policy, with
132
+ * `sp=` for subdomains when it is set). `p=none` only reports; it protects nothing.
133
+ */
134
+ async function checkDmarc(lookup: Lookup, domain: string): Promise<DnsCheck> {
135
+ const labels = domain.split(".");
136
+ const first = `_dmarc.${domain}`;
137
+ for (let index = 0; index <= labels.length - 2; index += 1) {
138
+ const host = `_dmarc.${labels.slice(index).join(".")}`;
139
+ const answer = await lookup(host);
140
+ if (answer.kind === "error") return { status: "fail", finding: "lookup-failed", host, record: null };
141
+ if (answer.kind === "none") continue;
142
+ const records = answer.records.filter((record) => startsWithTag(record, "v=dmarc1"));
143
+ if (records.length > 1) return { status: "fail", finding: "multiple", host, record: null };
144
+ const record = records[0];
145
+ if (record === undefined) continue;
146
+ const inherited = host !== first;
147
+ const tag = (name: string) => new RegExp(`(?:^|;)\\s*${name}=([^;]*)`, "i").exec(record)?.[1]?.trim().toLowerCase();
148
+ const policy = (inherited ? (tag("sp") ?? tag("p")) : tag("p")) ?? "none";
149
+ if (policy === "none") return { status: "warn", finding: "monitor-only", host, record };
150
+ return inherited ? { status: "pass", finding: "inherited", host, record } : { status: "pass", finding: "found", host, record };
151
+ }
152
+ return { status: "fail", finding: "missing", host: first, record: null };
153
+ }
@@ -0,0 +1,14 @@
1
+ // The module's readiness probe for `GET /api/health` of `@softure-ai/ops`: the suppression list,
2
+ // the campaigns and the delivery ledger exist and answer, i.e. `softure migrate` ran. It reads no rows.
3
+ import { ok, type HealthCheck } from "@softure-ai/core";
4
+ import type { Queryable } from "@softure-ai/db";
5
+ import { sql } from "drizzle-orm";
6
+
7
+ export const checkMailingTables: HealthCheck = async (context) => {
8
+ // Core types `db` as unknown; the health route passes the @softure-ai/db handle.
9
+ const db = context.db as Queryable;
10
+ await db.execute(sql`select 1 from mailing.suppressions limit 0`);
11
+ await db.execute(sql`select 1 from mailing.campaigns limit 0`);
12
+ await db.execute(sql`select 1 from mailing.deliveries limit 0`);
13
+ return ok();
14
+ };
@@ -0,0 +1,56 @@
1
+ // Server-only logic of @softure-ai/mailing. Functions receive a context and never read request
2
+ // scope; `next/*` imports are not allowed here (ESLint `no-restricted-imports`, NFR-3).
3
+ export { getCampaignProblems, parseCampaignFile, parseRecipientList, type CampaignContent, type CampaignFile, type CampaignFileResult } from "./campaign-file.js";
4
+ export {
5
+ getCampaignContentHash,
6
+ planCampaign,
7
+ registerCampaign,
8
+ sendCampaign,
9
+ type CampaignErrorCode,
10
+ type CampaignPlan,
11
+ type CampaignSummary,
12
+ type SendCampaignOptions,
13
+ } from "./campaigns.js";
14
+ export {
15
+ DEFAULT_MAX_ATTEMPTS,
16
+ DEFAULT_STALE_CLAIM_MS,
17
+ deliverOnce,
18
+ type DeliverOptions,
19
+ type Delivery,
20
+ type DeliveryContext,
21
+ type DeliveryOutcome,
22
+ } from "./deliveries.js";
23
+ export {
24
+ checkSenderDns,
25
+ DEFAULT_DKIM_SELECTORS,
26
+ getSenderDomain,
27
+ type CheckSenderDnsOptions,
28
+ type DnsCheck,
29
+ type DnsCheckStatus,
30
+ type DnsFinding,
31
+ type ResolveTxt,
32
+ type SenderDnsReport,
33
+ } from "./dns.js";
34
+ export { checkMailingTables } from "./health.js";
35
+ export { addHtmlFooter, addTextFooter, getListUnsubscribeHeaders, SIGNATURE_SEPARATOR } from "./list-mail.js";
36
+ export { getMailingModule, getMailingOptions, getMailingRoutes, type MailingRoutes } from "./options.js";
37
+ export { sendMail, type MailContext } from "./send-mail.js";
38
+ export { isSuppressed, liftSuppression, suppressRecipient, unsubscribe, type SuppressionContext } from "./suppressions.js";
39
+ export {
40
+ buildUnsubscribeLinks,
41
+ getRecipientKey,
42
+ MIN_UNSUBSCRIBE_SECRET_LENGTH,
43
+ readUnsubscribeSecrets,
44
+ readUnsubscribeToken,
45
+ RECIPIENT_PARAM,
46
+ signRecipientKey,
47
+ SIGNATURE_PARAM,
48
+ UNSUBSCRIBE_PREVIOUS_SECRET_ENV,
49
+ UNSUBSCRIBE_SECRET_ENV,
50
+ verifyUnsubscribeToken,
51
+ type Env,
52
+ type UnsubscribeLinks,
53
+ type UnsubscribeSecrets,
54
+ type UnsubscribeToken,
55
+ } from "./unsubscribe-link.js";
56
+ export { validateMail, type MailValidation, type ValidMail } from "./validate-mail.js";
@@ -0,0 +1,42 @@
1
+ // The footer and headers every list mail carries. Ported from FIRE_TRACKER
2
+ // `src/lib/unsubscribe-footer.ts`: one footer and one header pair for all list mail, so no kind of
3
+ // mail can lose its link while its own tests stay green. HTML bodies get a footer too.
4
+ import type { MailingMessages } from "../messages/index.js";
5
+ import type { UnsubscribeLinks } from "./unsubscribe-link.js";
6
+
7
+ /** The signature separator (RFC 3676): mail clients fold what follows it. */
8
+ export const SIGNATURE_SEPARATOR = "-- ";
9
+
10
+ type FooterCopy = MailingMessages["footer"];
11
+
12
+ /** `text` with the unsubscribe footer under the signature separator. */
13
+ export function addTextFooter(text: string, links: UnsubscribeLinks, copy: FooterCopy): string {
14
+ return [text, "", SIGNATURE_SEPARATOR, copy.text, links.page].join("\n");
15
+ }
16
+
17
+ /**
18
+ * `html` with an unsubscribe paragraph before its last `</body>`, or appended when it has none
19
+ * (a fragment). The copy is escaped; the link is ours and only needs its `&` escaped.
20
+ */
21
+ export function addHtmlFooter(html: string, links: UnsubscribeLinks, copy: FooterCopy): string {
22
+ const footer = `<p>${escapeHtml(copy.htmlLead)} <a href="${escapeHtml(links.page)}">${escapeHtml(copy.htmlLink)}</a></p>`;
23
+ // Searched on the original string: lowercasing can change its length (`İ` becomes two characters).
24
+ const bodyEnd = [...html.matchAll(/<\/body>/gi)].at(-1)?.index;
25
+ return bodyEnd === undefined ? `${html}\n${footer}` : `${html.slice(0, bodyEnd)}${footer}\n${html.slice(bodyEnd)}`;
26
+ }
27
+
28
+ /**
29
+ * RFC 8058: the mail client shows "Unsubscribe" next to the sender and POSTs to this URL itself,
30
+ * without opening a page or a session. Both headers must be covered by the DKIM signature, which
31
+ * the provider applies to every header it sends.
32
+ */
33
+ export function getListUnsubscribeHeaders(links: UnsubscribeLinks): Record<string, string> {
34
+ return {
35
+ "List-Unsubscribe": `<${links.oneClick}>`,
36
+ "List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
37
+ };
38
+ }
39
+
40
+ function escapeHtml(text: string): string {
41
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
42
+ }
@@ -0,0 +1,32 @@
1
+ // The mailing options and routes of the running app, read from the configuration.
2
+ import { getModule, type AnySoftureModule, type SoftureConfig } from "@softure-ai/core";
3
+ import type { MailingOptions } from "../options.js";
4
+
5
+ const MODULE_ID = "mailing";
6
+
7
+ /** The paths of the unsubscribe page and the one-click route (overridable in `mailing({ routes })`). */
8
+ export interface MailingRoutes {
9
+ readonly unsubscribe: string;
10
+ readonly oneClick: string;
11
+ }
12
+
13
+ /** The enabled module. Throws when the app did not enable it: sending then is a bug. */
14
+ export function getMailingModule(config: SoftureConfig): AnySoftureModule {
15
+ const module = getModule(config, MODULE_ID);
16
+ if (module === undefined) {
17
+ throw new Error("@softure-ai/mailing: the module is not enabled; add mailing({ ... }) to modules in softure.config.ts");
18
+ }
19
+ return module;
20
+ }
21
+
22
+ /** The module's options. Throws when the app did not enable it. */
23
+ export function getMailingOptions(config: SoftureConfig): MailingOptions {
24
+ // The module factory parsed these options with mailingOptionsSchema.
25
+ return getMailingModule(config).options as MailingOptions;
26
+ }
27
+
28
+ /** The module's routes, with the app's overrides. Throws when the app did not enable it. */
29
+ export function getMailingRoutes(config: SoftureConfig): MailingRoutes {
30
+ // The manifest declares both routes; the factory only replaces their paths.
31
+ return getMailingModule(config).routes as unknown as MailingRoutes;
32
+ }
@@ -0,0 +1,152 @@
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, type SoftureConfig } from "@softure-ai/core";
7
+ import type { Queryable } from "@softure-ai/db";
8
+ import { TRANSACTIONAL_KIND, type MailingErrorCode, type MailProvider, type OutgoingMail, type ProviderMessage, type ProviderOutcome, type SendMailOptions, type SendMailResult } from "../contract.js";
9
+ import type { MailingMessages } from "../messages/index.js";
10
+ import { addHtmlFooter, addTextFooter, getListUnsubscribeHeaders } from "./list-mail.js";
11
+ import { getMailingModule, getMailingOptions } from "./options.js";
12
+ import { isSuppressed } from "./suppressions.js";
13
+ import { buildUnsubscribeLinks, MIN_UNSUBSCRIBE_SECRET_LENGTH, readUnsubscribeSecrets, UNSUBSCRIBE_SECRET_ENV } from "./unsubscribe-link.js";
14
+ import { validateMail, type ValidMail } from "./validate-mail.js";
15
+
16
+ /**
17
+ * What `sendMail` needs: the app's configuration and, for list mail, the database handle (the
18
+ * suppression check reads it). Transactional mail needs no `db`.
19
+ */
20
+ export interface MailContext {
21
+ readonly config: SoftureConfig;
22
+ readonly db?: Queryable;
23
+ }
24
+
25
+ type MailContent = Omit<ProviderMessage, "from" | "replyTo" | "idempotencyKey">;
26
+
27
+ /**
28
+ * Sends `mail` and resolves with the provider's message id or a `mailing.*` code:
29
+ * `invalid_input` (fix the input), `rejected` (do not retry), `unavailable` (retry later with the
30
+ * same `idempotencyKey`), `suppressed` (a list mail to someone who unsubscribed; do not retry).
31
+ * Throws only when the module is not enabled, or for list mail without `context.db`.
32
+ */
33
+ export async function sendMail(context: MailContext, mail: OutgoingMail, options: SendMailOptions = {}): Promise<SendMailResult> {
34
+ const { from, replyTo, provider, timeoutMs } = getMailingOptions(context.config);
35
+
36
+ const validation = validateMail(mail, options);
37
+ if (!validation.ok) {
38
+ logFailure(provider, "mailing.invalid_input", { fields: validation.fields });
39
+ return err("mailing.invalid_input");
40
+ }
41
+
42
+ const { idempotencyKey, kind, ...content } = validation.value;
43
+ let message: MailContent = content;
44
+ if (kind !== TRANSACTIONAL_KIND) {
45
+ const listMail = await prepareListMail(context, validation.value);
46
+ if (!listMail.ok) {
47
+ logFailure(provider, listMail.error, { cause: listMail.cause });
48
+ return err(listMail.error);
49
+ }
50
+ message = listMail.value;
51
+ }
52
+
53
+ const outcome = await callProvider(provider, { ...message, from, replyTo: replyTo ?? null, idempotencyKey }, timeoutMs);
54
+ if (outcome.status === "sent") {
55
+ return ok({ id: outcome.id, provider: provider.name });
56
+ }
57
+ const code: MailingErrorCode = `mailing.${outcome.status}`;
58
+ logFailure(provider, code, { status: outcome.httpStatus });
59
+ return err(code);
60
+ }
61
+
62
+ type ListMailResult =
63
+ | { readonly ok: true; readonly value: MailContent }
64
+ | { readonly ok: false; readonly error: "mailing.suppressed" | "mailing.unavailable"; readonly cause: string };
65
+
66
+ /**
67
+ * A list mail with its footer and headers, or why it must not go out. Fails closed: without the
68
+ * secret, or when the suppression list cannot be read, nothing is sent.
69
+ */
70
+ async function prepareListMail(context: MailContext, mail: ValidMail): Promise<ListMailResult> {
71
+ if (context.db === undefined) {
72
+ throw new Error("@softure-ai/mailing: list mail needs the database handle; call sendMail({ config, db }, ...)");
73
+ }
74
+ const secret = readUnsubscribeSecrets().current;
75
+ if (secret === null) {
76
+ console.error(`mailing: list mail needs ${UNSUBSCRIBE_SECRET_ENV} (at least ${String(MIN_UNSUBSCRIBE_SECRET_LENGTH)} characters) to sign unsubscribe links`);
77
+ return { ok: false, error: "mailing.unavailable", cause: "no_unsubscribe_secret" };
78
+ }
79
+ let suppressed: boolean;
80
+ try {
81
+ suppressed = await isSuppressed({ db: context.db }, mail.to);
82
+ } catch {
83
+ // The error text can carry the query parameters, the recipient key among them.
84
+ return { ok: false, error: "mailing.unavailable", cause: "suppressions_unreadable" };
85
+ }
86
+ if (suppressed) return { ok: false, error: "mailing.suppressed", cause: "unsubscribed" };
87
+
88
+ const links = buildUnsubscribeLinks(context.config, mail.to, secret);
89
+ // The module factory merged the dictionaries; their shape is the module's own.
90
+ const copy = (getMailingModule(context.config).messages[context.config.locale] as MailingMessages).footer;
91
+ return {
92
+ ok: true,
93
+ value: {
94
+ to: mail.to,
95
+ subject: mail.subject,
96
+ text: addTextFooter(mail.text, links, copy),
97
+ html: mail.html === null ? null : addHtmlFooter(mail.html, links, copy),
98
+ headers: { ...mail.headers, ...getListUnsubscribeHeaders(links) },
99
+ },
100
+ };
101
+ }
102
+
103
+ /**
104
+ * The provider's outcome, `unavailable` when it throws, answers something else or outlives the
105
+ * timeout. The timer races the provider, so one that ignores its signal cannot hang the caller.
106
+ */
107
+ async function callProvider(provider: MailProvider, message: Parameters<MailProvider["send"]>[0], timeoutMs: number): Promise<ProviderOutcome> {
108
+ const controller = new AbortController();
109
+ let timer: ReturnType<typeof setTimeout> | undefined;
110
+ const timeout = new Promise<ProviderOutcome>((resolve) => {
111
+ timer = setTimeout(() => {
112
+ controller.abort(new DOMException("mailing: the provider did not answer in time", "TimeoutError"));
113
+ resolve({ status: "unavailable" });
114
+ }, timeoutMs);
115
+ });
116
+ const sending = provider
117
+ .send(message, { signal: controller.signal })
118
+ .then(readOutcome, (): ProviderOutcome => ({ status: "unavailable" }));
119
+ try {
120
+ return await Promise.race([sending, timeout]);
121
+ } finally {
122
+ clearTimeout(timer);
123
+ }
124
+ }
125
+
126
+ /** A provider's answer, narrowed: anything that is not a known outcome reads as `unavailable`. */
127
+ function readOutcome(outcome: unknown): ProviderOutcome {
128
+ if (typeof outcome !== "object" || outcome === null) return { status: "unavailable" };
129
+ const { status, id, httpStatus } = outcome as { status?: unknown; id?: unknown; httpStatus?: unknown };
130
+ if (status === "sent") {
131
+ return typeof id === "string" && id !== "" ? { status, id } : { status: "unavailable" };
132
+ }
133
+ if (status === "rejected" || status === "unavailable") {
134
+ return typeof httpStatus === "number" ? { status, httpStatus } : { status };
135
+ }
136
+ return { status: "unavailable" };
137
+ }
138
+
139
+ /**
140
+ * The module's only log line. Deliberately takes nothing but the provider, the code, a status,
141
+ * field names and a fixed cause: anything added "for diagnosis" is where an address leaks first.
142
+ */
143
+ function logFailure(
144
+ provider: MailProvider,
145
+ code: MailingErrorCode,
146
+ details: { readonly status?: number; readonly fields?: readonly string[]; readonly cause?: string },
147
+ ): void {
148
+ const parts = [`provider=${provider.name}`, `reason=${code.slice("mailing.".length)}`, `status=${details.status === undefined ? "none" : String(details.status)}`];
149
+ if (details.fields !== undefined) parts.push(`fields=${details.fields.join(",")}`);
150
+ if (details.cause !== undefined) parts.push(`cause=${details.cause}`);
151
+ console.error(`mailing: send failed ${parts.join(" ")}`);
152
+ }