@softure-ai/mailing 0.1.10 → 0.1.12

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 (92) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +187 -10
  3. package/dist/cli/run.d.ts +1 -1
  4. package/dist/cli/run.d.ts.map +1 -1
  5. package/dist/cli/run.js +202 -29
  6. package/dist/cli/run.js.map +1 -1
  7. package/dist/contract.d.ts +5 -3
  8. package/dist/contract.d.ts.map +1 -1
  9. package/dist/index.d.ts +3 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +1 -1
  12. package/dist/next/index.d.ts +3 -1
  13. package/dist/next/index.d.ts.map +1 -1
  14. package/dist/next/index.js +3 -1
  15. package/dist/next/index.js.map +1 -1
  16. package/dist/next/pages.d.ts +25 -0
  17. package/dist/next/pages.d.ts.map +1 -1
  18. package/dist/next/pages.js +33 -10
  19. package/dist/next/pages.js.map +1 -1
  20. package/dist/next/preview-mail.d.ts +5 -0
  21. package/dist/next/preview-mail.d.ts.map +1 -0
  22. package/dist/next/preview-mail.js +8 -0
  23. package/dist/next/preview-mail.js.map +1 -0
  24. package/dist/next/run-deliveries.d.ts +4 -0
  25. package/dist/next/run-deliveries.d.ts.map +1 -0
  26. package/dist/next/run-deliveries.js +7 -0
  27. package/dist/next/run-deliveries.js.map +1 -0
  28. package/dist/options.d.ts +3 -1
  29. package/dist/options.d.ts.map +1 -1
  30. package/dist/options.js +16 -1
  31. package/dist/options.js.map +1 -1
  32. package/dist/server/campaign-file.d.ts +7 -2
  33. package/dist/server/campaign-file.d.ts.map +1 -1
  34. package/dist/server/campaign-file.js +35 -15
  35. package/dist/server/campaign-file.js.map +1 -1
  36. package/dist/server/campaigns.d.ts +15 -2
  37. package/dist/server/campaigns.d.ts.map +1 -1
  38. package/dist/server/campaigns.js +33 -11
  39. package/dist/server/campaigns.js.map +1 -1
  40. package/dist/server/deliveries.d.ts +19 -0
  41. package/dist/server/deliveries.d.ts.map +1 -1
  42. package/dist/server/deliveries.js +29 -0
  43. package/dist/server/deliveries.js.map +1 -1
  44. package/dist/server/dns.d.ts +15 -1
  45. package/dist/server/dns.d.ts.map +1 -1
  46. package/dist/server/dns.js +45 -4
  47. package/dist/server/dns.js.map +1 -1
  48. package/dist/server/index.d.ts +6 -4
  49. package/dist/server/index.d.ts.map +1 -1
  50. package/dist/server/index.js +6 -4
  51. package/dist/server/index.js.map +1 -1
  52. package/dist/server/operator-output.d.ts +5 -0
  53. package/dist/server/operator-output.d.ts.map +1 -0
  54. package/dist/server/operator-output.js +19 -0
  55. package/dist/server/operator-output.js.map +1 -0
  56. package/dist/server/options.d.ts +2 -0
  57. package/dist/server/options.d.ts.map +1 -1
  58. package/dist/server/options.js +5 -0
  59. package/dist/server/options.js.map +1 -1
  60. package/dist/server/run-deliveries.d.ts +55 -0
  61. package/dist/server/run-deliveries.d.ts.map +1 -0
  62. package/dist/server/run-deliveries.js +132 -0
  63. package/dist/server/run-deliveries.js.map +1 -0
  64. package/dist/server/send-mail.d.ts +21 -2
  65. package/dist/server/send-mail.d.ts.map +1 -1
  66. package/dist/server/send-mail.js +50 -30
  67. package/dist/server/send-mail.js.map +1 -1
  68. package/dist/server/suppressions.d.ts +5 -0
  69. package/dist/server/suppressions.d.ts.map +1 -1
  70. package/dist/server/suppressions.js +27 -1
  71. package/dist/server/suppressions.js.map +1 -1
  72. package/migrations/0005_add_sql_surface.sql +44 -0
  73. package/module.json +1 -1
  74. package/package.json +1 -1
  75. package/src/cli/run.ts +209 -29
  76. package/src/contract.ts +9 -3
  77. package/src/index.ts +1 -1
  78. package/src/next/index.ts +3 -1
  79. package/src/next/pages.tsx +69 -14
  80. package/src/next/preview-mail.ts +9 -0
  81. package/src/next/run-deliveries.ts +10 -0
  82. package/src/options.ts +18 -1
  83. package/src/server/campaign-file.ts +36 -2
  84. package/src/server/campaigns.ts +53 -12
  85. package/src/server/deliveries.ts +33 -0
  86. package/src/server/dns.ts +60 -4
  87. package/src/server/index.ts +7 -3
  88. package/src/server/operator-output.ts +20 -0
  89. package/src/server/options.ts +6 -0
  90. package/src/server/run-deliveries.ts +184 -0
  91. package/src/server/send-mail.ts +63 -37
  92. package/src/server/suppressions.ts +28 -1
@@ -0,0 +1,9 @@
1
+ // `previewMail` on the app's registered configuration, e.g. for an admin page that shows a mail before it goes out.
2
+ import { getSoftureConfig } from "@softure-ai/core/next";
3
+ import type { OutgoingMail } from "../contract.js";
4
+ import { previewMail as previewMailWithConfig, type MailPreviewResult, type PreviewMailOptions } from "../server/send-mail.js";
5
+
6
+ /** What `sendMail` would hand the provider for `mail`; see `previewMail` in `@softure-ai/mailing/server`. */
7
+ export function previewMail(mail: OutgoingMail, options: PreviewMailOptions = {}): MailPreviewResult {
8
+ return previewMailWithConfig(getSoftureConfig(), mail, options);
9
+ }
@@ -0,0 +1,10 @@
1
+ // `runDeliveries` on the app's registered configuration and the shared database, for a route handler or a job that
2
+ // sends lifecycle mail to many recipients at most once each.
3
+ import type { DeliveryRunInput, DeliveryRunSummary, RunDeliveriesOptions } from "../server/run-deliveries.js";
4
+ import { runDeliveries as runDeliveriesWithContext } from "../server/run-deliveries.js";
5
+ import { getMailingContext } from "./context.js";
6
+
7
+ /** Delivers one mail per recipient through the ledger; see `runDeliveries` in `@softure-ai/mailing/server`. */
8
+ export async function runDeliveries<Recipient>(input: DeliveryRunInput<Recipient>, options: RunDeliveriesOptions = {}): Promise<DeliveryRunSummary> {
9
+ return runDeliveriesWithContext(await getMailingContext(), input, options);
10
+ }
package/src/options.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // The options an app passes to `mailing({ ... })` in softure.config.ts, parsed at startup.
2
2
  import { z } from "zod";
3
- import { isMailbox, isSingleAddress } from "./address.js";
3
+ import { isMailbox, isMailKind, isSingleAddress } from "./address.js";
4
4
  import type { CampaignRecipientFilter, CampaignRecipientSource, LegacyUnsubscribe, MailProvider, OnUnsubscribedHook } from "./contract.js";
5
5
 
6
6
  export const DEFAULT_TIMEOUT_MS = 10_000;
@@ -42,6 +42,13 @@ const legacyUnsubscribeSchema = z.strictObject({
42
42
 
43
43
  const HOUR_MS = 60 * 60_000;
44
44
 
45
+ const mailKind = z.string().refine(isMailKind, "must be a kebab-case mail kind such as newsletter");
46
+
47
+ /** Alias to kind; an alias never names a kind another alias points at, so one lookup resolves it. */
48
+ const kindAliasesSchema = z
49
+ .record(mailKind, mailKind)
50
+ .refine((aliases) => Object.keys(aliases).every((alias) => !Object.values(aliases).includes(alias)), "must not use a kind as an alias: aliases resolve in one step");
51
+
45
52
  function isMailProvider(value: unknown): value is MailProvider {
46
53
  if (typeof value !== "object" || value === null) return false;
47
54
  const candidate = value as { name?: unknown; send?: unknown };
@@ -56,6 +63,16 @@ export const mailingOptionsSchema = z.strictObject({
56
63
  from: z.string().trim().refine(isMailbox, "must be an address or Name <address>, e.g. Plan <hello@example.com>"),
57
64
  /** Where replies go; may sit on another domain than `from` (DMARC does not check it). */
58
65
  replyTo: z.string().trim().refine(isSingleAddress, "must be one address, e.g. support@example.com").optional(),
66
+ /**
67
+ * The only address `softure-mail test` sends to, e.g. the operator's inbox. Without it the command refuses to send,
68
+ * so a test never reaches a customer.
69
+ */
70
+ testAddress: z.string().trim().refine(isSingleAddress, "must be one address, e.g. ops@example.com").optional(),
71
+ /**
72
+ * Short names for mail kinds in campaign files and `softure-mail test --kind`, e.g. `{ news: "newsletter" }`. The
73
+ * campaign is stored and sent under the kind the alias names.
74
+ */
75
+ kindAliases: kindAliasesSchema.optional(),
59
76
  /** The adapter that delivers mail: `resend()`, or `fakeMailProvider()` from `/testing`. */
60
77
  provider: z.custom<MailProvider>(isMailProvider, "must be a mail provider such as resend()"),
61
78
  /** How long one send may take before it reads as `mailing.unavailable`. */
@@ -11,8 +11,12 @@
11
11
  //
12
12
  // `html` is optional and names a file next to the content file, rendered however the operator
13
13
  // likes. There is no markdown: the text body is sent as written.
14
+ import type { SoftureConfig } from "@softure-ai/core";
14
15
  import { isMailKind, MAX_SUBJECT_LENGTH } from "../address.js";
15
16
  import { TRANSACTIONAL_KIND } from "../contract.js";
17
+ import type { MailingMessages } from "../messages/index.js";
18
+ import { getMailingModule, getMailingRoutes } from "./options.js";
19
+ import { RECIPIENT_PARAM, SIGNATURE_PARAM } from "./unsubscribe-link.js";
16
20
 
17
21
  /** A campaign's content, as `sendCampaign` sends it. */
18
22
  export interface CampaignContent {
@@ -75,8 +79,12 @@ export function parseCampaignFile(source: string): CampaignFileResult {
75
79
  return problems.length === 0 ? { ok: true, value: file } : { ok: false, problems };
76
80
  }
77
81
 
78
- /** What makes `content` unsendable as a campaign; empty when it is fine. */
79
- export function getCampaignProblems(content: CampaignContent): string[] {
82
+ /**
83
+ * What makes `content` unsendable as a campaign; empty when it is fine. With the app's config it also refuses a
84
+ * body that carries an unsubscribe link or footer pasted from a sent mail: the module adds its own to every mail,
85
+ * and a pasted link is a credential that would let every recipient unsubscribe the person it was signed for.
86
+ */
87
+ export function getCampaignProblems(content: CampaignContent, config?: SoftureConfig): string[] {
80
88
  const problems: string[] = [];
81
89
  if (content.id.length > MAX_CAMPAIGN_ID_LENGTH || !CAMPAIGN_ID.test(content.id)) {
82
90
  problems.push("id: kebab-case, at most 64 characters, e.g. 2026-10-launch");
@@ -89,6 +97,32 @@ export function getCampaignProblems(content: CampaignContent): string[] {
89
97
  }
90
98
  if (content.text.trim() === "") problems.push("the text body under the frontmatter is empty");
91
99
  if (content.html?.trim() === "") problems.push("the HTML body is empty");
100
+ if (config !== undefined) problems.push(...getPastedFooterProblems(content, config));
101
+ return problems;
102
+ }
103
+
104
+ /** A signed pair of link parameters, raw or HTML-escaped (`&amp;`). */
105
+ const SIGNED_LINK = new RegExp(`[?&](?:amp;)?${RECIPIENT_PARAM}=[A-Za-z0-9_-]{43}&(?:amp;)?${SIGNATURE_PARAM}=`);
106
+
107
+ function getPastedFooterProblems(content: CampaignContent, config: SoftureConfig): string[] {
108
+ const routes = getMailingRoutes(config);
109
+ // The app's own links only: a link to another site's unsubscribe page is the author's business.
110
+ const linkMarkers = [`${config.appOrigin}${routes.unsubscribe}?`, `${config.appOrigin}${routes.oneClick}?`];
111
+ // Every locale's footer copy, the app's overrides included: a mail pasted from another locale counts too.
112
+ const footers = Object.values(getMailingModule(config).messages).map((messages) => (messages as MailingMessages).footer);
113
+ const bodies: [string, string | null][] = [
114
+ ["the text body", content.text],
115
+ ["the HTML body", content.html],
116
+ ];
117
+ const problems: string[] = [];
118
+ for (const [name, body] of bodies) {
119
+ if (body === null) continue;
120
+ if (SIGNED_LINK.test(body) || linkMarkers.some((marker) => body.includes(marker))) {
121
+ problems.push(`${name} carries an unsubscribe link; the module adds a link signed for each recipient, remove the pasted one`);
122
+ } else if (footers.some((footer) => body.includes(footer.text) || body.includes(`${footer.htmlLead} <a `))) {
123
+ problems.push(`${name} carries the unsubscribe footer; the module adds it to every mail, remove the pasted one`);
124
+ }
125
+ }
92
126
  return problems;
93
127
  }
94
128
 
@@ -1,9 +1,10 @@
1
1
  // Campaigns: one list mail to many recipients through the delivery ledger. The campaign row pins
2
2
  // the content (a hash of kind, subject and bodies), so a re-run with the same id sends the rest of
3
3
  // the same mail and nothing twice; other content under that id is refused. A run stops at the first
4
- // failure about the sending account (refused key, spent quota), leaving the rest for the next run.
4
+ // failure about the sending account (refused key, spent quota), leaving the rest for the next run, and
5
+ // after `limit` mails reached the provider, counting the rest for the next run.
5
6
  import { createHash } from "node:crypto";
6
- import { err, ok, type Result } from "@softure-ai/core";
7
+ import { err, ok, type Result, type SoftureConfig } from "@softure-ai/core";
7
8
  import { and, eq, inArray } from "drizzle-orm";
8
9
  import type { CampaignRecipient, MailingErrorCode } from "../contract.js";
9
10
  import { campaigns, deliveries } from "../schema.js";
@@ -46,6 +47,11 @@ export interface CampaignSummary {
46
47
  * after it were left as they were; run again once the account can send.
47
48
  */
48
49
  readonly halted: CampaignHalt | null;
50
+ /**
51
+ * Recipients past the `limit` cut that the next run would send to, counted as `planCampaign` counts `toSend`. 0 when
52
+ * the run reached the end of the list; `null` when it halted (the rest of the list was not read).
53
+ */
54
+ readonly remaining: number | null;
49
55
  }
50
56
 
51
57
  export interface SendCampaignOptions extends DeliverOptions {
@@ -54,6 +60,13 @@ export interface SendCampaignOptions extends DeliverOptions {
54
60
  /** Called after every recipient, e.g. for progress output. Never receives the address. */
55
61
  readonly onDelivery?: (outcome: DeliveryOutcome) => void;
56
62
  readonly sleep?: (ms: number) => Promise<void>;
63
+ /**
64
+ * Most mails this run hands to the provider (sent, rejected by it, or not taken now), e.g. to leave part of a shared
65
+ * daily quota for other mail. Done, unsubscribed, filtered, in-flight and uncertain recipients never count. Once
66
+ * reached, the run reads the rest of the list only to count `remaining`. A whole number of at least 1. Default: no
67
+ * limit.
68
+ */
69
+ readonly limit?: number;
57
70
  }
58
71
 
59
72
  /** sha256 (hex) of what a recipient reads: kind, subject, text and HTML. */
@@ -66,7 +79,7 @@ export function getCampaignContentHash(content: CampaignContent): string {
66
79
  * database failure and for content `getCampaignProblems` refuses.
67
80
  */
68
81
  export async function registerCampaign(ctx: DeliveryContext, content: CampaignContent): Promise<Result<undefined, CampaignErrorCode>> {
69
- assertCampaign(content);
82
+ assertCampaign(content, ctx.config);
70
83
  const contentHash = getCampaignContentHash(content);
71
84
  await ctx.db
72
85
  .insert(campaigns)
@@ -79,15 +92,19 @@ export async function registerCampaign(ctx: DeliveryContext, content: CampaignCo
79
92
  /**
80
93
  * Sends `campaign` to every recipient that has no outcome yet, one at a time. Recipients who
81
94
  * unsubscribed are rejected (`mailing.suppressed`) and never retried; recipients the module's
82
- * `filterCampaignRecipient` refuses are skipped. Stops at the first `halted` delivery. Throws on a
83
- * database failure and when the filter throws.
95
+ * `filterCampaignRecipient` refuses are skipped. Stops at the first `halted` delivery, and before the recipient
96
+ * after `limit` mails reached the provider. Throws on a database failure, when the filter throws, and (a
97
+ * `RangeError`) for a `limit` that is not a whole number of at least 1.
84
98
  */
85
99
  export async function sendCampaign(
86
100
  ctx: DeliveryContext,
87
101
  input: { readonly campaign: CampaignContent; readonly recipients: Iterable<string> | AsyncIterable<string> },
88
102
  options: SendCampaignOptions = {},
89
103
  ): Promise<Result<CampaignSummary, CampaignErrorCode>> {
90
- const { pauseMs = 0, onDelivery, sleep = wait, ...deliverOptions } = options;
104
+ const { pauseMs = 0, onDelivery, sleep = wait, limit, ...deliverOptions } = options;
105
+ if (limit !== undefined && !(Number.isInteger(limit) && limit >= 1)) {
106
+ throw new RangeError(`@softure-ai/mailing: sendCampaign limit must be a whole number of at least 1, got ${String(limit)}`);
107
+ }
91
108
  const { campaign } = input;
92
109
  const registered = await registerCampaign(ctx, campaign);
93
110
  if (!registered.ok) return registered;
@@ -96,10 +113,16 @@ export async function sendCampaign(
96
113
  const seen = new Set<string>();
97
114
  const scope = `campaign:${campaign.id}`;
98
115
  const isWanted = createRecipientFilter(ctx, campaign);
116
+ const pastLimit = new Map<string, string>();
117
+ let handedToProvider = 0;
99
118
  for await (const address of input.recipients) {
100
119
  const recipientKey = getRecipientKey(address);
101
120
  if (seen.has(recipientKey)) continue;
102
121
  seen.add(recipientKey);
122
+ if (limit !== undefined && handedToProvider >= limit) {
123
+ pastLimit.set(recipientKey, address);
124
+ continue;
125
+ }
103
126
  if (!(await isWanted(address, recipientKey))) {
104
127
  counts.filtered += 1;
105
128
  continue;
@@ -116,12 +139,14 @@ export async function sendCampaign(
116
139
  if (outcome.status === "halted") {
117
140
  // The rest of the list is not even counted as seen: the summary covers what this run reached.
118
141
  const httpStatus = outcome.httpStatus === undefined ? {} : { httpStatus: outcome.httpStatus };
119
- return ok({ recipients: seen.size, ...counts, halted: { reason: outcome.reason, ...httpStatus } });
142
+ return ok({ recipients: seen.size, ...counts, halted: { reason: outcome.reason, ...httpStatus }, remaining: null });
120
143
  }
121
144
  const reachedProvider = outcome.status === "sent" || outcome.status === "retry-later" || (outcome.status === "rejected" && PROVIDER_REASONS.has(outcome.reason));
145
+ if (reachedProvider) handedToProvider += 1;
122
146
  if (reachedProvider && pauseMs > 0) await sleep(pauseMs);
123
147
  }
124
- return ok({ recipients: seen.size, ...counts, halted: null });
148
+ const remaining = pastLimit.size === 0 ? 0 : (await countPending(ctx, campaign, pastLimit, deliverOptions)).toSend;
149
+ return ok({ recipients: seen.size, ...counts, halted: null, remaining });
125
150
  }
126
151
 
127
152
  /** `filterCampaignRecipient` bound to the campaign, or "everyone" when the app set none. */
@@ -177,7 +202,7 @@ export async function planCampaign(
177
202
  options: Pick<DeliverOptions, "uncertainClaimMs" | "retakeUncertain"> = {},
178
203
  ): Promise<CampaignPlan> {
179
204
  const { campaign } = input;
180
- assertCampaign(campaign);
205
+ assertCampaign(campaign, ctx.config);
181
206
  const stored = await ctx.db.select({ contentHash: campaigns.contentHash }).from(campaigns).where(eq(campaigns.id, campaign.id)).limit(1);
182
207
  const contentChanged = stored[0] !== undefined && stored[0].contentHash !== getCampaignContentHash(campaign);
183
208
 
@@ -186,6 +211,22 @@ export async function planCampaign(
186
211
  const key = getRecipientKey(address);
187
212
  if (!addresses.has(key)) addresses.set(key, address);
188
213
  }
214
+ const pending = await countPending(ctx, campaign, addresses, options);
215
+ return { recipients: addresses.size, ...pending, contentChanged };
216
+ }
217
+
218
+ type PendingCounts = Pick<CampaignPlan, "done" | "suppressed" | "filtered" | "uncertain" | "toSend">;
219
+
220
+ /**
221
+ * How the ledger, the filter and the suppression list sort `addresses` (recipient key to address) for the campaign,
222
+ * without writing. Throws on a database failure and when the filter throws.
223
+ */
224
+ async function countPending(
225
+ ctx: DeliveryContext,
226
+ campaign: CampaignContent,
227
+ addresses: ReadonlyMap<string, string>,
228
+ options: Pick<DeliverOptions, "uncertainClaimMs" | "retakeUncertain">,
229
+ ): Promise<PendingCounts> {
189
230
  const uncertainBefore = ctx.clock.now().getTime() - (options.uncertainClaimMs ?? getMailingOptions(ctx.config).uncertainClaimMs);
190
231
  const closed = new Set<string>();
191
232
  const uncertainKeys = new Set<string>();
@@ -214,11 +255,11 @@ export async function planCampaign(
214
255
  if (options.retakeUncertain === true) toSend += 1;
215
256
  } else toSend += 1;
216
257
  }
217
- return { recipients: addresses.size, done: closed.size, suppressed, filtered, uncertain, toSend, contentChanged };
258
+ return { done: closed.size, suppressed, filtered, uncertain, toSend };
218
259
  }
219
260
 
220
- function assertCampaign(content: CampaignContent): void {
221
- const problems = getCampaignProblems(content);
261
+ function assertCampaign(content: CampaignContent, config: SoftureConfig): void {
262
+ const problems = getCampaignProblems(content, config);
222
263
  if (problems.length > 0) throw new Error(`@softure-ai/mailing: campaign "${content.id}" cannot be sent: ${problems.join("; ")}`);
223
264
  }
224
265
 
@@ -14,7 +14,9 @@ import { DEFAULT_MAX_ATTEMPTS, DEFAULT_STALE_CLAIM_MS, DEFAULT_UNCERTAIN_CLAIM_M
14
14
  import { deliveries } from "../schema.js";
15
15
  import { getMailingOptions } from "./options.js";
16
16
  import { sendMail } from "./send-mail.js";
17
+ import { isSuppressed } from "./suppressions.js";
17
18
  import { getRecipientKey } from "./unsubscribe-link.js";
19
+ import { validateMail } from "./validate-mail.js";
18
20
 
19
21
  export type DeliveryContext = ModuleContext<Queryable>;
20
22
 
@@ -115,6 +117,37 @@ export async function deliverOnce(ctx: DeliveryContext, delivery: Delivery, opti
115
117
  return { status: "rejected", reason: result.error, ...httpStatus };
116
118
  }
117
119
 
120
+ /** What `deliverOnce` would do with a delivery now, read without writing or sending. */
121
+ export type DeliveryForecast =
122
+ | { readonly status: "send" }
123
+ | { readonly status: "rejected"; readonly reason: "mailing.invalid_input" | "mailing.suppressed" }
124
+ | { readonly status: "done" }
125
+ | { readonly status: "in-flight" }
126
+ | { readonly status: "uncertain" };
127
+
128
+ /**
129
+ * Reads the ledger, checks the mail and, for list mail, the suppression list, as `deliverOnce` would, and writes
130
+ * and sends nothing. A provider's answer cannot be foreseen: `send` means the mail would reach it. Throws like
131
+ * `deliverOnce` for a malformed scope or kind, and on a database failure.
132
+ */
133
+ export async function forecastDelivery(ctx: DeliveryContext, delivery: Delivery, options: DeliverOptions = {}): Promise<DeliveryForecast> {
134
+ const windows = resolveClaimWindows(ctx, options);
135
+ const kind = delivery.mail.kind ?? TRANSACTIONAL_KIND;
136
+ assertDelivery(delivery, kind);
137
+ const key = { scope: delivery.scope, recipientKey: getRecipientKey(delivery.mail.to) };
138
+ const rows = await ctx.db.select({ status: deliveries.status, claimedAt: deliveries.claimedAt }).from(deliveries).where(matchKey(key)).limit(1);
139
+ const row = rows[0];
140
+ if (row?.status === "sent" || row?.status === "rejected") return { status: "done" };
141
+ if (row?.status === "claimed") {
142
+ const age = ctx.clock.now().getTime() - row.claimedAt.getTime();
143
+ if (age <= windows.staleClaimMs) return { status: "in-flight" };
144
+ if (age > windows.uncertainClaimMs && !windows.retakeUncertain) return { status: "uncertain" };
145
+ }
146
+ if (!validateMail(delivery.mail).ok) return { status: "rejected", reason: "mailing.invalid_input" };
147
+ if (kind !== TRANSACTIONAL_KIND && (await isSuppressed(ctx, delivery.mail.to))) return { status: "rejected", reason: "mailing.suppressed" };
148
+ return { status: "send" };
149
+ }
150
+
118
151
  function isHaltingCode(code: MailingErrorCode): code is HaltingErrorCode {
119
152
  return HALTING_ERROR_CODES.has(code);
120
153
  }
package/src/server/dns.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  // The sender domain's DNS, as receivers check it: SPF (RFC 7208), DKIM (RFC 6376) and DMARC
2
2
  // (RFC 7489). Without all three, list mail lands in spam or is refused outright. On request it also
3
3
  // holds DMARC to a required minimum, checks that the reply-to domain accepts mail (MX) and that the
4
- // provider's return-path hosts resolve. The check only reads public records; it cannot see whether
4
+ // provider's return-path hosts resolve, and that an inbound service (Cloudflare Email Routing)
5
+ // receives the reply domain's mail. The check only reads public records; it cannot see whether
5
6
  // the provider signs with the published key.
6
7
  import { resolveCname as resolveCnameRecords, resolveMx as resolveMxRecords, resolveTxt as resolveTxtRecords } from "node:dns/promises";
7
8
 
@@ -18,6 +19,7 @@ export type DnsFinding =
18
19
  | "weak"
19
20
  | "null-mx"
20
21
  | "unexpected-target"
22
+ | "missing-include"
21
23
  | "lookup-failed";
22
24
 
23
25
  export interface DnsCheck {
@@ -38,8 +40,13 @@ export interface SenderDnsReport {
38
40
  readonly replyTo: DnsCheck | null;
39
41
  /** One check per `returnPath` host, in the order given. */
40
42
  readonly returnPath: readonly DnsCheck[];
43
+ /** The inbound service's MX and SPF checks, in that order, when `inbound` was given; `[]` otherwise. */
44
+ readonly inbound: readonly DnsCheck[];
41
45
  }
42
46
 
47
+ /** A service that receives the domain's mail: `cloudflare` is Cloudflare Email Routing. */
48
+ export type InboundService = "cloudflare";
49
+
43
50
  export type ResolveTxt = (hostname: string) => Promise<string[][]>;
44
51
  export interface MxRecord {
45
52
  readonly exchange: string;
@@ -87,6 +94,11 @@ export interface CheckSenderDnsOptions {
87
94
  readonly replyTo?: string;
88
95
  /** Hosts that must resolve as a CNAME or with MX records; `resendReturnPath(domain)` gives Resend's. */
89
96
  readonly returnPath?: readonly ReturnPathHost[];
97
+ /**
98
+ * Checks that this service receives mail for the reply-to domain (else the checked domain): every MX record is the
99
+ * service's, and the one SPF record includes the service's, so replies arrive and forwarding passes SPF.
100
+ */
101
+ readonly inbound?: InboundService;
90
102
  readonly resolveTxt?: ResolveTxt;
91
103
  readonly resolveMx?: ResolveMx;
92
104
  readonly resolveCname?: ResolveCname;
@@ -109,6 +121,11 @@ export function resendReturnPath(domain: string): ReturnPathHost[] {
109
121
  ];
110
122
  }
111
123
 
124
+ /** Cloudflare Email Routing: its MX hosts (`route1.mx.cloudflare.net`, ...) and the SPF include it asks for. */
125
+ export const CLOUDFLARE_INBOUND = { mxDomain: "mx.cloudflare.net", spfInclude: "_spf.mx.cloudflare.net" } as const;
126
+
127
+ const INBOUND_SERVICES: Readonly<Record<InboundService, { readonly mxDomain: string; readonly spfInclude: string }>> = { cloudflare: CLOUDFLARE_INBOUND };
128
+
112
129
  /** Reads the SPF, DKIM and DMARC records of `domain`. Never throws: a failed lookup is a finding. */
113
130
  export async function checkSenderDns(domain: string, options: CheckSenderDnsOptions = {}): Promise<SenderDnsReport> {
114
131
  const name = normalizeHost(domain);
@@ -116,14 +133,16 @@ export async function checkSenderDns(domain: string, options: CheckSenderDnsOpti
116
133
  const lookup = (host: string) => lookupTxt(resolveTxt, host);
117
134
  const lookupMx = (host: string) => lookupRecords(options.resolveMx ?? resolveMxRecords, host);
118
135
  const lookupCname = (host: string) => lookupRecords(options.resolveCname ?? resolveCnameRecords, host);
119
- const [spf, dkim, dmarc, replyTo, returnPath] = await Promise.all([
136
+ const replyDomain = options.replyTo === undefined ? null : normalizeHost(getSenderDomain(options.replyTo));
137
+ const [spf, dkim, dmarc, replyTo, returnPath, inbound] = await Promise.all([
120
138
  checkSpf(lookup, options.spfHosts ?? [name]),
121
139
  checkDkim(lookup, (options.dkimSelectors ?? DEFAULT_DKIM_SELECTORS).map((selector) => `${selector}._domainkey.${name}`)),
122
140
  checkDmarc(lookup, name, options.expectDmarc),
123
- options.replyTo === undefined ? null : checkReplyPath(lookup, lookupMx, normalizeHost(getSenderDomain(options.replyTo))),
141
+ replyDomain === null ? null : checkReplyPath(lookup, lookupMx, replyDomain),
124
142
  Promise.all((options.returnPath ?? []).map((path) => checkReturnPath(lookupCname, lookupMx, path))),
143
+ options.inbound === undefined ? [] : checkInbound({ lookup, lookupMx }, replyDomain ?? name, INBOUND_SERVICES[options.inbound]),
125
144
  ]);
126
- return { domain: name, spf, dkim, dmarc, replyTo, returnPath };
145
+ return { domain: name, spf, dkim, dmarc, replyTo, returnPath, inbound };
127
146
  }
128
147
 
129
148
  /** The domain part of a sender (`Plan <hello@mail.example.com>` gives `mail.example.com`). */
@@ -309,3 +328,40 @@ async function checkReturnPath(lookupCname: CnameLookup, lookupMx: MxLookup, pat
309
328
  if (mx.kind === "none" || isNullMx(mx.records)) return { status: "fail", finding: "missing", host, record: null };
310
329
  return { status: "pass", finding: "found", host, record: formatMx(mx.records) };
311
330
  }
331
+
332
+ /** Whether `host` is `domain` or under it. Strings, not patterns: the domain is input. */
333
+ function isUnder(host: string, domain: string): boolean {
334
+ return host === domain || host.endsWith(`.${domain}`);
335
+ }
336
+
337
+ /** The inbound service's MX (every record its own) and SPF (exactly one record, including the service's). */
338
+ async function checkInbound(
339
+ { lookup, lookupMx }: { readonly lookup: Lookup; readonly lookupMx: MxLookup },
340
+ host: string,
341
+ service: { readonly mxDomain: string; readonly spfInclude: string },
342
+ ): Promise<DnsCheck[]> {
343
+ const [mx, txt] = await Promise.all([lookupMx(host), lookup(host)]);
344
+ return [checkInboundMx(mx, host, service.mxDomain), checkInboundSpf(txt, host, service.spfInclude)];
345
+ }
346
+
347
+ function checkInboundMx(mx: Answer<MxRecord>, host: string, mxDomain: string): DnsCheck {
348
+ if (mx.kind === "error") return { status: "fail", finding: "lookup-failed", host, record: null };
349
+ if (mx.kind === "none") return { status: "fail", finding: "missing", host, record: null };
350
+ const record = formatMx(mx.records);
351
+ if (isNullMx(mx.records)) return { status: "fail", finding: "null-mx", host, record };
352
+ const isService = mx.records.every((entry) => isUnder(normalizeHost(entry.exchange), mxDomain));
353
+ return isService ? { status: "pass", finding: "found", host, record } : { status: "fail", finding: "unexpected-target", host, record };
354
+ }
355
+
356
+ function checkInboundSpf(txt: TxtAnswer, host: string, include: string): DnsCheck {
357
+ if (txt.kind === "error") return { status: "fail", finding: "lookup-failed", host, record: null };
358
+ const records = txt.kind === "records" ? txt.records.filter(isSpfRecord) : [];
359
+ if (records.length > 1) return { status: "fail", finding: "multiple", host, record: null };
360
+ const record = records[0];
361
+ if (record === undefined) return { status: "fail", finding: "missing", host, record: null };
362
+ const includes = record
363
+ .trim()
364
+ .split(/\s+/)
365
+ .some((term) => term.toLowerCase().replace(/^\+/, "").replace(/\.$/, "") === `include:${include}`);
366
+ return includes ? { status: "pass", finding: "found", host, record } : { status: "fail", finding: "missing-include", host, record };
367
+ }
@@ -27,6 +27,7 @@ export {
27
27
  } from "./deliveries.js";
28
28
  export {
29
29
  checkSenderDns,
30
+ CLOUDFLARE_INBOUND,
30
31
  DEFAULT_DKIM_SELECTORS,
31
32
  getSenderDomain,
32
33
  RESEND_RETURN_PATH_DOMAIN,
@@ -38,6 +39,7 @@ export {
38
39
  type DnsCheck,
39
40
  type DnsCheckStatus,
40
41
  type DnsFinding,
42
+ type InboundService,
41
43
  type MxRecord,
42
44
  type ResolveCname,
43
45
  type ResolveMx,
@@ -57,9 +59,11 @@ export {
57
59
  type ImportSummary,
58
60
  } from "./import-deliveries.js";
59
61
  export { addHtmlFooter, addTextFooter, getListUnsubscribeHeaders, SIGNATURE_SEPARATOR } from "./list-mail.js";
60
- export { getMailingModule, getMailingOptions, getMailingRoutes, type MailingRoutes } from "./options.js";
61
- export { sendMail, type MailContext } from "./send-mail.js";
62
- export { isSuppressed, liftSuppression, suppressRecipient, unsubscribe, type SuppressionContext } from "./suppressions.js";
62
+ export { maskAddress, redactUnsubscribeSignatures } from "./operator-output.js";
63
+ export { getMailingModule, getMailingOptions, getMailingRoutes, resolveMailKind, type MailingRoutes } from "./options.js";
64
+ export { runDeliveries, type DeliveryRunInput, type DeliveryRunSummary, type RunDeliveriesOptions } from "./run-deliveries.js";
65
+ export { previewMail, sendMail, type MailContext, type MailPreviewFailure, type MailPreviewResult, type PreviewMailOptions } from "./send-mail.js";
66
+ export { findSuppressedAddresses, isSuppressed, liftSuppression, suppressRecipient, unsubscribe, type SuppressionContext } from "./suppressions.js";
63
67
  export {
64
68
  buildUnsubscribeLinks,
65
69
  getRecipientKey,
@@ -0,0 +1,20 @@
1
+ // What an operator's terminal may show of a mail: addresses masked, link signatures redacted. Command output ends
2
+ // up in shell history, CI logs and pasted chat messages; a full address is personal data, and a signed unsubscribe
3
+ // link lets whoever holds it unsubscribe that address.
4
+ import { SIGNATURE_PARAM } from "./unsubscribe-link.js";
5
+
6
+ /** `ada@example.org` becomes `a**@example.org`: the first character of the local part, then one `*` per other. */
7
+ export function maskAddress(address: string): string {
8
+ const trimmed = address.trim();
9
+ const at = trimmed.lastIndexOf("@");
10
+ if (at <= 0) return "*".repeat(Math.max(trimmed.length, 1));
11
+ const local = trimmed.slice(0, at);
12
+ return `${local.slice(0, 1)}${"*".repeat(local.length - 1)}${trimmed.slice(at)}`;
13
+ }
14
+
15
+ const SIGNATURE = new RegExp(`([?&](?:amp;)?${SIGNATURE_PARAM}=)[A-Za-z0-9_-]+`, "g");
16
+
17
+ /** `text` with the signature of every unsubscribe link replaced by `<signature>`, so the link no longer works. */
18
+ export function redactUnsubscribeSignatures(text: string): string {
19
+ return text.replace(SIGNATURE, "$1<signature>");
20
+ }
@@ -30,3 +30,9 @@ export function getMailingRoutes(config: SoftureConfig): MailingRoutes {
30
30
  // The manifest declares both routes; the factory only replaces their paths.
31
31
  return getMailingModule(config).routes as unknown as MailingRoutes;
32
32
  }
33
+
34
+ /** The kind an alias of `mailing({ kindAliases })` names, or `kind` itself when it is no alias. */
35
+ export function resolveMailKind(config: SoftureConfig, kind: string): string {
36
+ const aliases = getMailingOptions(config).kindAliases ?? {};
37
+ return Object.hasOwn(aliases, kind) ? (aliases[kind] ?? kind) : kind;
38
+ }