@softure-ai/mailing 0.1.11 → 0.1.13

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 +42 -0
  2. package/README.md +182 -9
  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 +179 -25
  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 +13 -8
  37. package/dist/server/campaigns.d.ts.map +1 -1
  38. package/dist/server/campaigns.js +15 -14
  39. package/dist/server/campaigns.js.map +1 -1
  40. package/dist/server/deliveries.d.ts +32 -0
  41. package/dist/server/deliveries.d.ts.map +1 -1
  42. package/dist/server/deliveries.js +36 -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 +7 -5
  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 +62 -0
  61. package/dist/server/run-deliveries.d.ts.map +1 -0
  62. package/dist/server/run-deliveries.js +133 -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 +184 -27
  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 +25 -21
  85. package/src/server/deliveries.ts +53 -0
  86. package/src/server/dns.ts +60 -4
  87. package/src/server/index.ts +9 -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 +193 -0
  91. package/src/server/send-mail.ts +63 -37
  92. package/src/server/suppressions.ts +28 -1
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,15 +1,16 @@
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, and
5
- // after `limit` mails reached the provider, counting the rest for the next run.
4
+ // failure about the sending account (refused key, spent quota) and, unless told otherwise, at the first
5
+ // mail the provider could not take now, leaving the rest for the next run, and after `limit` mails
6
+ // reached the provider, counting the rest for the next run.
6
7
  import { createHash } from "node:crypto";
7
- import { err, ok, type Result } from "@softure-ai/core";
8
+ import { err, ok, type Result, type SoftureConfig } from "@softure-ai/core";
8
9
  import { and, eq, inArray } from "drizzle-orm";
9
10
  import type { CampaignRecipient, MailingErrorCode } from "../contract.js";
10
11
  import { campaigns, deliveries } from "../schema.js";
11
12
  import { getCampaignProblems, type CampaignContent } from "./campaign-file.js";
12
- import { deliverOnce, type DeliverOptions, type DeliveryContext, type DeliveryOutcome, type HaltingErrorCode } from "./deliveries.js";
13
+ import { deliverOnce, getRunHalt, type DeliverOptions, type DeliveryContext, type DeliveryOutcome, type RunHalt } from "./deliveries.js";
13
14
  import { getMailingOptions } from "./options.js";
14
15
  import { isSuppressed } from "./suppressions.js";
15
16
  import { getRecipientKey } from "./unsubscribe-link.js";
@@ -18,10 +19,7 @@ import { getRecipientKey } from "./unsubscribe-link.js";
18
19
  const PROVIDER_REASONS: ReadonlySet<MailingErrorCode> = new Set(["mailing.rejected", "mailing.unavailable"]);
19
20
 
20
21
  /** Why a run stopped before the end of the list. */
21
- export interface CampaignHalt {
22
- readonly reason: HaltingErrorCode;
23
- readonly httpStatus?: number;
24
- }
22
+ export type CampaignHalt = RunHalt;
25
23
 
26
24
  /** The campaign id is taken by other content. Give the new content a new id. */
27
25
  export type CampaignErrorCode = "mailing.campaign_changed";
@@ -43,8 +41,9 @@ export interface CampaignSummary {
43
41
  /** Claimed more than `uncertainClaimMs` ago and never closed: their mail may have gone out. */
44
42
  readonly uncertain: number;
45
43
  /**
46
- * Set when the run stopped at a failure about the sending account. The recipient it happened to and every one
47
- * after it were left as they were; run again once the account can send.
44
+ * Set when the run stopped early: at a failure about the sending account (run again once the account can send) or,
45
+ * with `stopOnRetryLater`, at the first mail the provider could not take now (`mailing.unavailable`; run again
46
+ * later). The recipient it happened to and every one after it were left as they were.
48
47
  */
49
48
  readonly halted: CampaignHalt | null;
50
49
  /**
@@ -67,6 +66,12 @@ export interface SendCampaignOptions extends DeliverOptions {
67
66
  * limit.
68
67
  */
69
68
  readonly limit?: number;
69
+ /**
70
+ * End the run at the first `retry-later` (the provider is down, timed out or rate limited), reported in `halted` as
71
+ * `mailing.unavailable`. Going on would spend an attempt of every following recipient on the same answer. Default
72
+ * `true`.
73
+ */
74
+ readonly stopOnRetryLater?: boolean;
70
75
  }
71
76
 
72
77
  /** sha256 (hex) of what a recipient reads: kind, subject, text and HTML. */
@@ -79,7 +84,7 @@ export function getCampaignContentHash(content: CampaignContent): string {
79
84
  * database failure and for content `getCampaignProblems` refuses.
80
85
  */
81
86
  export async function registerCampaign(ctx: DeliveryContext, content: CampaignContent): Promise<Result<undefined, CampaignErrorCode>> {
82
- assertCampaign(content);
87
+ assertCampaign(content, ctx.config);
83
88
  const contentHash = getCampaignContentHash(content);
84
89
  await ctx.db
85
90
  .insert(campaigns)
@@ -92,7 +97,8 @@ export async function registerCampaign(ctx: DeliveryContext, content: CampaignCo
92
97
  /**
93
98
  * Sends `campaign` to every recipient that has no outcome yet, one at a time. Recipients who
94
99
  * unsubscribed are rejected (`mailing.suppressed`) and never retried; recipients the module's
95
- * `filterCampaignRecipient` refuses are skipped. Stops at the first `halted` delivery, and before the recipient
100
+ * `filterCampaignRecipient` refuses are skipped. Stops at the first `halted` delivery, unless `stopOnRetryLater` is
101
+ * `false` at the first `retry-later`, and before the recipient
96
102
  * after `limit` mails reached the provider. Throws on a database failure, when the filter throws, and (a
97
103
  * `RangeError`) for a `limit` that is not a whole number of at least 1.
98
104
  */
@@ -101,7 +107,7 @@ export async function sendCampaign(
101
107
  input: { readonly campaign: CampaignContent; readonly recipients: Iterable<string> | AsyncIterable<string> },
102
108
  options: SendCampaignOptions = {},
103
109
  ): Promise<Result<CampaignSummary, CampaignErrorCode>> {
104
- const { pauseMs = 0, onDelivery, sleep = wait, limit, ...deliverOptions } = options;
110
+ const { pauseMs = 0, onDelivery, sleep = wait, limit, stopOnRetryLater = true, ...deliverOptions } = options;
105
111
  if (limit !== undefined && !(Number.isInteger(limit) && limit >= 1)) {
106
112
  throw new RangeError(`@softure-ai/mailing: sendCampaign limit must be a whole number of at least 1, got ${String(limit)}`);
107
113
  }
@@ -136,11 +142,9 @@ export async function sendCampaign(
136
142
  );
137
143
  countOutcome(counts, outcome);
138
144
  onDelivery?.(outcome);
139
- if (outcome.status === "halted") {
140
- // The rest of the list is not even counted as seen: the summary covers what this run reached.
141
- const httpStatus = outcome.httpStatus === undefined ? {} : { httpStatus: outcome.httpStatus };
142
- return ok({ recipients: seen.size, ...counts, halted: { reason: outcome.reason, ...httpStatus }, remaining: null });
143
- }
145
+ const halt = getRunHalt(outcome, stopOnRetryLater);
146
+ // The rest of the list is not even counted as seen: the summary covers what this run reached.
147
+ if (halt !== null) return ok({ recipients: seen.size, ...counts, halted: halt, remaining: null });
144
148
  const reachedProvider = outcome.status === "sent" || outcome.status === "retry-later" || (outcome.status === "rejected" && PROVIDER_REASONS.has(outcome.reason));
145
149
  if (reachedProvider) handedToProvider += 1;
146
150
  if (reachedProvider && pauseMs > 0) await sleep(pauseMs);
@@ -202,7 +206,7 @@ export async function planCampaign(
202
206
  options: Pick<DeliverOptions, "uncertainClaimMs" | "retakeUncertain"> = {},
203
207
  ): Promise<CampaignPlan> {
204
208
  const { campaign } = input;
205
- assertCampaign(campaign);
209
+ assertCampaign(campaign, ctx.config);
206
210
  const stored = await ctx.db.select({ contentHash: campaigns.contentHash }).from(campaigns).where(eq(campaigns.id, campaign.id)).limit(1);
207
211
  const contentChanged = stored[0] !== undefined && stored[0].contentHash !== getCampaignContentHash(campaign);
208
212
 
@@ -258,8 +262,8 @@ async function countPending(
258
262
  return { done: closed.size, suppressed, filtered, uncertain, toSend };
259
263
  }
260
264
 
261
- function assertCampaign(content: CampaignContent): void {
262
- const problems = getCampaignProblems(content);
265
+ function assertCampaign(content: CampaignContent, config: SoftureConfig): void {
266
+ const problems = getCampaignProblems(content, config);
263
267
  if (problems.length > 0) throw new Error(`@softure-ai/mailing: campaign "${content.id}" cannot be sent: ${problems.join("; ")}`);
264
268
  }
265
269
 
@@ -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
 
@@ -56,6 +58,26 @@ export interface DeliverOptions {
56
58
  /** The codes that stop a run: the sending account, not the mail, is the problem. */
57
59
  export type HaltingErrorCode = "mailing.provider_refused" | "mailing.quota_exceeded";
58
60
 
61
+ /**
62
+ * Why a run of many deliveries (`runDeliveries`, `sendCampaign`) stopped early: the account cannot send
63
+ * (`HaltingErrorCode`), or the provider answered `retry-later` (`mailing.unavailable`: down, timed out or rate
64
+ * limited) and the run stops on it.
65
+ */
66
+ export type RunHaltReason = HaltingErrorCode | "mailing.unavailable";
67
+
68
+ /** Why a run stopped early, with the provider's HTTP status when it gave one. */
69
+ export interface RunHalt {
70
+ readonly reason: RunHaltReason;
71
+ readonly httpStatus?: number;
72
+ }
73
+
74
+ /** Why a run stops after this outcome: at `halted`, and at `retry-later` when it stops on it; `null` to go on. */
75
+ export function getRunHalt(outcome: DeliveryOutcome, stopOnRetryLater: boolean): RunHalt | null {
76
+ if (outcome.status !== "halted" && !(stopOnRetryLater && outcome.status === "retry-later")) return null;
77
+ const reason = outcome.status === "halted" ? outcome.reason : "mailing.unavailable";
78
+ return outcome.httpStatus === undefined ? { reason } : { reason, httpStatus: outcome.httpStatus };
79
+ }
80
+
59
81
  export type DeliveryOutcome =
60
82
  /** Sent now; `id` is the provider's message id. */
61
83
  | { readonly status: "sent"; readonly id: string }
@@ -115,6 +137,37 @@ export async function deliverOnce(ctx: DeliveryContext, delivery: Delivery, opti
115
137
  return { status: "rejected", reason: result.error, ...httpStatus };
116
138
  }
117
139
 
140
+ /** What `deliverOnce` would do with a delivery now, read without writing or sending. */
141
+ export type DeliveryForecast =
142
+ | { readonly status: "send" }
143
+ | { readonly status: "rejected"; readonly reason: "mailing.invalid_input" | "mailing.suppressed" }
144
+ | { readonly status: "done" }
145
+ | { readonly status: "in-flight" }
146
+ | { readonly status: "uncertain" };
147
+
148
+ /**
149
+ * Reads the ledger, checks the mail and, for list mail, the suppression list, as `deliverOnce` would, and writes
150
+ * and sends nothing. A provider's answer cannot be foreseen: `send` means the mail would reach it. Throws like
151
+ * `deliverOnce` for a malformed scope or kind, and on a database failure.
152
+ */
153
+ export async function forecastDelivery(ctx: DeliveryContext, delivery: Delivery, options: DeliverOptions = {}): Promise<DeliveryForecast> {
154
+ const windows = resolveClaimWindows(ctx, options);
155
+ const kind = delivery.mail.kind ?? TRANSACTIONAL_KIND;
156
+ assertDelivery(delivery, kind);
157
+ const key = { scope: delivery.scope, recipientKey: getRecipientKey(delivery.mail.to) };
158
+ const rows = await ctx.db.select({ status: deliveries.status, claimedAt: deliveries.claimedAt }).from(deliveries).where(matchKey(key)).limit(1);
159
+ const row = rows[0];
160
+ if (row?.status === "sent" || row?.status === "rejected") return { status: "done" };
161
+ if (row?.status === "claimed") {
162
+ const age = ctx.clock.now().getTime() - row.claimedAt.getTime();
163
+ if (age <= windows.staleClaimMs) return { status: "in-flight" };
164
+ if (age > windows.uncertainClaimMs && !windows.retakeUncertain) return { status: "uncertain" };
165
+ }
166
+ if (!validateMail(delivery.mail).ok) return { status: "rejected", reason: "mailing.invalid_input" };
167
+ if (kind !== TRANSACTIONAL_KIND && (await isSuppressed(ctx, delivery.mail.to))) return { status: "rejected", reason: "mailing.suppressed" };
168
+ return { status: "send" };
169
+ }
170
+
118
171
  function isHaltingCode(code: MailingErrorCode): code is HaltingErrorCode {
119
172
  return HALTING_ERROR_CODES.has(code);
120
173
  }
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
+ }
@@ -24,9 +24,12 @@ export {
24
24
  type DeliveryContext,
25
25
  type DeliveryOutcome,
26
26
  type HaltingErrorCode,
27
+ type RunHalt,
28
+ type RunHaltReason,
27
29
  } from "./deliveries.js";
28
30
  export {
29
31
  checkSenderDns,
32
+ CLOUDFLARE_INBOUND,
30
33
  DEFAULT_DKIM_SELECTORS,
31
34
  getSenderDomain,
32
35
  RESEND_RETURN_PATH_DOMAIN,
@@ -38,6 +41,7 @@ export {
38
41
  type DnsCheck,
39
42
  type DnsCheckStatus,
40
43
  type DnsFinding,
44
+ type InboundService,
41
45
  type MxRecord,
42
46
  type ResolveCname,
43
47
  type ResolveMx,
@@ -57,9 +61,11 @@ export {
57
61
  type ImportSummary,
58
62
  } from "./import-deliveries.js";
59
63
  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";
64
+ export { maskAddress, redactUnsubscribeSignatures } from "./operator-output.js";
65
+ export { getMailingModule, getMailingOptions, getMailingRoutes, resolveMailKind, type MailingRoutes } from "./options.js";
66
+ export { runDeliveries, type DeliveryRunInput, type DeliveryRunSummary, type RunDeliveriesOptions } from "./run-deliveries.js";
67
+ export { previewMail, sendMail, type MailContext, type MailPreviewFailure, type MailPreviewResult, type PreviewMailOptions } from "./send-mail.js";
68
+ export { findSuppressedAddresses, isSuppressed, liftSuppression, suppressRecipient, unsubscribe, type SuppressionContext } from "./suppressions.js";
63
69
  export {
64
70
  buildUnsubscribeLinks,
65
71
  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
+ }