@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
package/src/cli/run.ts CHANGED
@@ -1,5 +1,5 @@
1
- // `softure-mail`: sends a campaign from a content file, imports an app's delivery history into the
2
- // ledger and checks the sender domain's DNS. The app passes its validated config, so the same
1
+ // `softure-mail`: sends a campaign from a content file, sends a test mail to the configured test
2
+ // address, imports an app's delivery history into the ledger and checks the sender domain's DNS. The app passes its validated config, so the same
3
3
  // function serves the bin and an app script that a bundler packs for a container:
4
4
  //
5
5
  // // scripts/mail.ts
@@ -12,12 +12,15 @@ import { parseArgs } from "node:util";
12
12
  import { systemClock, type SoftureConfig, type SoftureDatabaseConfig } from "@softure-ai/core";
13
13
  import { z } from "zod";
14
14
  import { openCommandDatabase, type CommandDatabase, type DatabaseHandle } from "@softure-ai/db";
15
- import { parseCampaignFile, parseRecipientList, type CampaignContent } from "../server/campaign-file.js";
15
+ import { TRANSACTIONAL_KIND, type OutgoingMail, type ProviderMessage } from "../contract.js";
16
+ import { getCampaignProblems, parseCampaignFile, parseRecipientList, type CampaignContent } from "../server/campaign-file.js";
16
17
  import { listConfiguredCampaignRecipients, planCampaign, sendCampaign, type CampaignSummary } from "../server/campaigns.js";
17
18
  import type { DeliveryContext, HaltingErrorCode } from "../server/deliveries.js";
18
19
  import { checkSenderDns, getSenderDomain, resendReturnPath, type DmarcAlignment, type DmarcExpectation, type DmarcPolicy, type DnsCheck, type ResolveCname, type ResolveMx, type ResolveTxt, type ReturnPathHost } from "../server/dns.js";
19
20
  import { checkImportedDeliveries, importDeliveries, type ImportedDelivery, type ImportProblem } from "../server/import-deliveries.js";
20
- import { getMailingOptions } from "../server/options.js";
21
+ import { maskAddress, redactUnsubscribeSignatures } from "../server/operator-output.js";
22
+ import { getMailingOptions, resolveMailKind } from "../server/options.js";
23
+ import { previewMail, sendMail } from "../server/send-mail.js";
21
24
  import { MIN_UNSUBSCRIBE_SECRET_LENGTH, readUnsubscribeSecrets, UNSUBSCRIBE_SECRET_ENV, type Env } from "../server/unsubscribe-link.js";
22
25
 
23
26
  export interface CliOutput {
@@ -26,7 +29,7 @@ export interface CliOutput {
26
29
  }
27
30
 
28
31
  export interface RunMailCliOptions {
29
- /** The app's config. Needed by `campaign` and `import`, and by `dns` without `--domain`. */
32
+ /** The app's config. Needed by `campaign`, `test` and `import`, and by `dns` without `--domain`. */
30
33
  readonly config?: SoftureConfig;
31
34
  /** The arguments after the executable, e.g. `["campaign", "launch.md", "--recipients", "list.txt"]`. */
32
35
  readonly argv: readonly string[];
@@ -53,10 +56,13 @@ export const DEFAULT_PAUSE_MS = 500;
53
56
 
54
57
  export const MAIL_USAGE = `Usage:
55
58
  softure-mail campaign <content-file> [--recipients <file>] [--dry-run] [--pause-ms <ms>] [--resend-uncertain]
59
+ [--limit <n>]
60
+ softure-mail test [<content-file>] [--kind <kind>] [--preview]
56
61
  softure-mail import <history-file> [--dry-run]
57
62
  softure-mail dns [--domain <domain>] [--dkim-selector <name>]... [--spf-host <host>]...
58
63
  [--dmarc-policy <p>] [--dmarc-sp <p>] [--dmarc-adkim <a>] [--dmarc-aspf <a>]
59
64
  [--reply-to <address>] [--return-path <host>]... [--resend-return-path]
65
+ [--inbound cloudflare]
60
66
 
61
67
  campaign Sends the campaign in <content-file> (frontmatter id, kind, subject, optional html,
62
68
  then the text body) to every address in the recipients file (one per line), at most
@@ -68,6 +74,19 @@ campaign Sends the campaign in <content-file> (frontmatter id, kind, subject, o
68
74
  --pause-ms <ms> pause after every send (default ${String(DEFAULT_PAUSE_MS)})
69
75
  --resend-uncertain also send to recipients whose send was interrupted long ago
70
76
  (it may have gone out: they may get the mail twice)
77
+ --limit <n> hand at most n mails to the provider in this run (1 to 1000000);
78
+ the rest waits for the next run
79
+ --content-file <file> the content file, instead of naming it first (for softure-deploy run)
80
+ The kind may be an alias of kindAliases in the mailing options. A dry run also prints the
81
+ mail as the test address (testAddress in the mailing options) would get it. Content that
82
+ carries an unsubscribe link or footer pasted from a sent mail is refused.
83
+
84
+ test Sends one mail to testAddress of the mailing options, and to no one else: the
85
+ campaign in <content-file> (without the ledger: it can be sent again), or a short
86
+ fixed test mail.
87
+ --kind <kind> the mail's kind (default: the file's, else transactional); list kinds
88
+ get the unsubscribe footer and headers
89
+ --preview print the mail instead of sending it
71
90
 
72
91
  import Writes deliveries the app made before it adopted the module into the ledger, so
73
92
  later sends skip them. <history-file> holds one JSON object per line: scope,
@@ -90,13 +109,16 @@ dns Checks SPF, DKIM and DMARC for the sender domain (default: the domain
90
109
  (default: replyTo of the config, when --domain is not given)
91
110
  --return-path <host> a return-path host that must be a CNAME or have MX (repeatable)
92
111
  --resend-return-path check Resend's send.<domain> and rsend.<domain> CNAMEs
112
+ --inbound cloudflare check that Cloudflare Email Routing receives the reply-to domain's
113
+ mail (its MX and one SPF record that includes it)
93
114
 
94
115
  Options for all:
95
116
  --config <file> the app's softure.config file (bin only)
96
117
  --help show this help
97
118
 
98
119
  A file given as - is read from standard input (one of them per command), e.g.
99
- docker compose exec -T app npx softure-mail campaign - < launch.md`;
120
+ docker compose exec -T app npx softure-mail campaign - < launch.md
121
+ Addresses in the output are masked and unsubscribe links lose their signature.`;
100
122
 
101
123
  const consoleOutput: CliOutput = {
102
124
  log: (line) => console.log(line),
@@ -113,6 +135,16 @@ type Command =
113
135
  readonly dryRun: boolean;
114
136
  readonly pauseMs: number;
115
137
  readonly resendUncertain: boolean;
138
+ /** `null`: no limit. */
139
+ readonly limit: number | null;
140
+ }
141
+ | {
142
+ readonly kind: "test";
143
+ /** `null`: the fixed test mail. */
144
+ readonly contentFile: string | null;
145
+ /** `null`: the file's kind, else transactional. */
146
+ readonly mailKind: string | null;
147
+ readonly preview: boolean;
116
148
  }
117
149
  | { readonly kind: "import"; readonly historyFile: string; readonly dryRun: boolean }
118
150
  | {
@@ -124,14 +156,18 @@ type Command =
124
156
  readonly replyTo: string | undefined;
125
157
  readonly returnPathHosts: readonly string[];
126
158
  readonly resendReturnPath: boolean;
159
+ readonly inbound: "cloudflare" | undefined;
127
160
  };
128
161
 
129
162
  const DMARC_POLICIES: readonly DmarcPolicy[] = ["quarantine", "reject"];
130
163
  const DMARC_ALIGNMENTS: readonly DmarcAlignment[] = ["r", "s"];
131
164
  /** A file name that means standard input. */
132
165
  const STDIN = "-";
133
- const CAMPAIGN_ONLY_OPTIONS = ["recipients", "pause-ms", "resend-uncertain"] as const;
134
- const DNS_ONLY_OPTIONS = ["domain", "dkim-selector", "spf-host", "dmarc-policy", "dmarc-sp", "dmarc-adkim", "dmarc-aspf", "reply-to", "return-path", "resend-return-path"] as const;
166
+ const CAMPAIGN_ONLY_OPTIONS = ["recipients", "pause-ms", "resend-uncertain", "limit"] as const;
167
+ const TEST_ONLY_OPTIONS = ["kind", "preview"] as const;
168
+ const MAX_LIMIT = 1_000_000;
169
+ const DNS_ONLY_OPTIONS = ["domain", "dkim-selector", "spf-host", "dmarc-policy", "dmarc-sp", "dmarc-adkim", "dmarc-aspf", "reply-to", "return-path", "resend-return-path", "inbound"] as const;
170
+ const COMMANDS = ["campaign", "test", "import", "dns"] as const;
135
171
 
136
172
  /** Runs the command and returns the process exit code: 0 done, 1 failed or incomplete, 2 usage error. */
137
173
  export async function runMailCli(options: RunMailCliOptions): Promise<number> {
@@ -150,6 +186,8 @@ export async function runMailCli(options: RunMailCliOptions): Promise<number> {
150
186
  return runDns(command, options, output);
151
187
  case "campaign":
152
188
  return runCampaign(command, options, output);
189
+ case "test":
190
+ return runTest(command, options, output);
153
191
  case "import":
154
192
  return runImport(command, options, output);
155
193
  }
@@ -157,9 +195,9 @@ export async function runMailCli(options: RunMailCliOptions): Promise<number> {
157
195
 
158
196
  function parseCommand(argv: readonly string[]): Command | string {
159
197
  const [name, ...rest] = argv;
160
- if (name === undefined) return "missing command; use campaign, import or dns";
198
+ if (name === undefined) return "missing command; use campaign, test, import or dns";
161
199
  if (name === "--help") return { kind: "help" };
162
- if (name !== "campaign" && name !== "import" && name !== "dns") return `unknown command "${name}"; use campaign, import or dns`;
200
+ if (!(COMMANDS as readonly string[]).includes(name)) return `unknown command "${name}"; use campaign, test, import or dns`;
163
201
 
164
202
  let parsed: ReturnType<typeof parseCommandArgs>;
165
203
  try {
@@ -171,11 +209,13 @@ function parseCommand(argv: readonly string[]): Command | string {
171
209
  if (values.help === true) return { kind: "help" };
172
210
 
173
211
  if (name === "dns") {
174
- const misplaced = ([...CAMPAIGN_ONLY_OPTIONS, "dry-run"] as const).filter((option) => values[option] !== undefined);
212
+ const misplaced = ([...CAMPAIGN_ONLY_OPTIONS, ...TEST_ONLY_OPTIONS, "content-file", "dry-run"] as const).filter((option) => values[option] !== undefined);
175
213
  if (misplaced.length > 0) return `dns does not take --${misplaced.join(", --")}`;
176
214
  if (positionals.length > 0) return `dns takes no file, got "${positionals.join(" ")}"`;
177
215
  const expectDmarc = parseDmarcExpectation(values);
178
216
  if (typeof expectDmarc === "string") return expectDmarc;
217
+ const inbound = values.inbound?.trim().toLowerCase();
218
+ if (inbound !== undefined && inbound !== "cloudflare") return `--inbound expects cloudflare, got "${values.inbound ?? ""}"`;
179
219
  return {
180
220
  kind: "dns",
181
221
  domain: values.domain,
@@ -185,11 +225,12 @@ function parseCommand(argv: readonly string[]): Command | string {
185
225
  replyTo: values["reply-to"],
186
226
  returnPathHosts: values["return-path"] ?? [],
187
227
  resendReturnPath: values["resend-return-path"] === true,
228
+ inbound,
188
229
  };
189
230
  }
190
231
 
191
232
  if (name === "import") {
192
- const misplaced = [...CAMPAIGN_ONLY_OPTIONS, ...DNS_ONLY_OPTIONS].filter((option) => values[option] !== undefined);
233
+ const misplaced = [...CAMPAIGN_ONLY_OPTIONS, ...TEST_ONLY_OPTIONS, ...DNS_ONLY_OPTIONS, "content-file" as const].filter((option) => values[option] !== undefined);
193
234
  if (misplaced.length > 0) return `import does not take --${misplaced.join(", --")}`;
194
235
  const [historyFile, ...extra] = positionals;
195
236
  if (historyFile === undefined) return "import needs a history file (- for standard input)";
@@ -197,15 +238,32 @@ function parseCommand(argv: readonly string[]): Command | string {
197
238
  return { kind: "import", historyFile, dryRun: values["dry-run"] === true };
198
239
  }
199
240
 
200
- const misplaced = DNS_ONLY_OPTIONS.filter((option) => values[option] !== undefined);
241
+ const content = pickContentFile(name, positionals, values["content-file"]);
242
+ if (typeof content !== "object") return content;
243
+ if (name === "test") {
244
+ const misplaced = ([...CAMPAIGN_ONLY_OPTIONS, ...DNS_ONLY_OPTIONS, "dry-run"] as const).filter((option) => values[option] !== undefined);
245
+ if (misplaced.length > 0) return `test does not take --${misplaced.join(", --")}`;
246
+ return { kind: "test", contentFile: content.file, mailKind: values.kind ?? null, preview: values.preview === true };
247
+ }
248
+
249
+ const misplaced = [...TEST_ONLY_OPTIONS, ...DNS_ONLY_OPTIONS].filter((option) => values[option] !== undefined);
201
250
  if (misplaced.length > 0) return `campaign does not take --${misplaced.join(", --")}`;
202
- const [contentFile, ...extra] = positionals;
203
- if (contentFile === undefined) return "campaign needs a content file";
204
- if (extra.length > 0) return `campaign takes one content file, got also "${extra.join(" ")}"`;
251
+ const contentFile = content.file;
252
+ if (contentFile === null) return "campaign needs a content file";
205
253
  if (contentFile === STDIN && values.recipients === STDIN) return "campaign can read only one of the content file and --recipients from standard input";
206
254
  const pauseMs = values["pause-ms"] === undefined ? DEFAULT_PAUSE_MS : Number(values["pause-ms"]);
207
255
  if (!Number.isInteger(pauseMs) || pauseMs < 0 || pauseMs > 60_000) return `--pause-ms expects whole milliseconds from 0 to 60000, got "${values["pause-ms"] ?? ""}"`;
208
- return { kind: "campaign", contentFile, recipientsFile: values.recipients ?? null, dryRun: values["dry-run"] === true, pauseMs, resendUncertain: values["resend-uncertain"] === true };
256
+ const limit = values.limit === undefined ? null : Number(values.limit);
257
+ if (limit !== null && (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT)) return `--limit expects a whole number from 1 to ${String(MAX_LIMIT)}, got "${values.limit ?? ""}"`;
258
+ return {
259
+ kind: "campaign",
260
+ contentFile,
261
+ recipientsFile: values.recipients ?? null,
262
+ dryRun: values["dry-run"] === true,
263
+ pauseMs,
264
+ resendUncertain: values["resend-uncertain"] === true,
265
+ limit,
266
+ };
209
267
  }
210
268
 
211
269
  function parseCommandArgs(args: readonly string[]) {
@@ -216,6 +274,7 @@ function parseCommandArgs(args: readonly string[]) {
216
274
  "dry-run": { type: "boolean" },
217
275
  "pause-ms": { type: "string" },
218
276
  "resend-uncertain": { type: "boolean" },
277
+ limit: { type: "string" },
219
278
  domain: { type: "string" },
220
279
  "dkim-selector": { type: "string", multiple: true },
221
280
  "spf-host": { type: "string", multiple: true },
@@ -226,6 +285,10 @@ function parseCommandArgs(args: readonly string[]) {
226
285
  "reply-to": { type: "string" },
227
286
  "return-path": { type: "string", multiple: true },
228
287
  "resend-return-path": { type: "boolean" },
288
+ inbound: { type: "string" },
289
+ "content-file": { type: "string" },
290
+ kind: { type: "string" },
291
+ preview: { type: "boolean" },
229
292
  help: { type: "boolean" },
230
293
  },
231
294
  strict: true,
@@ -235,6 +298,14 @@ function parseCommandArgs(args: readonly string[]) {
235
298
 
236
299
  type ParsedValues = ReturnType<typeof parseCommandArgs>["values"];
237
300
 
301
+ /** The content file, named first or with `--content-file` (not both, at most one); `null` when none was given. */
302
+ function pickContentFile(command: string, positionals: readonly string[], option: string | undefined): { readonly file: string | null } | string {
303
+ const [first, ...extra] = positionals;
304
+ if (extra.length > 0) return `${command} takes one content file, got also "${extra.join(" ")}"`;
305
+ if (first !== undefined && option !== undefined) return `${command} takes the content file once: name it first or with --content-file`;
306
+ return { file: first ?? option ?? null };
307
+ }
308
+
238
309
  /** One choice of a fixed list, case-insensitive; `undefined` when the option was not given. */
239
310
  function pickChoice<T extends string>(values: ParsedValues, option: "dmarc-policy" | "dmarc-sp" | "dmarc-adkim" | "dmarc-aspf", allowed: readonly T[]): { readonly value: T | undefined } | string {
240
311
  const given = values[option];
@@ -280,6 +351,7 @@ async function runDns(command: Extract<Command, { kind: "dns" }>, options: RunMa
280
351
  ...(command.expectDmarc === undefined ? {} : { expectDmarc: command.expectDmarc }),
281
352
  ...(replyTo === undefined ? {} : { replyTo }),
282
353
  returnPath,
354
+ ...(command.inbound === undefined ? {} : { inbound: command.inbound }),
283
355
  ...(options.resolveTxt === undefined ? {} : { resolveTxt: options.resolveTxt }),
284
356
  ...(options.resolveMx === undefined ? {} : { resolveMx: options.resolveMx }),
285
357
  ...(options.resolveCname === undefined ? {} : { resolveCname: options.resolveCname }),
@@ -291,9 +363,10 @@ async function runDns(command: Extract<Command, { kind: "dns" }>, options: RunMa
291
363
  ];
292
364
  const reply: [string, DnsCheck][] = report.replyTo === null ? [] : [["REPLY", report.replyTo]];
293
365
  const paths: [string, DnsCheck][] = report.returnPath.map((check) => ["PATH", check]);
366
+ const inbound: [string, DnsCheck][] = report.inbound.map((check) => ["INBOUND", check]);
294
367
  output.log(`sender domain ${report.domain}`);
295
- for (const [name, check] of [...authentication, ...reply, ...paths]) {
296
- const line = `${name.padEnd(6)}${check.status.padEnd(6)}${`${check.finding} `.padEnd(15)}${check.host}${check.record === null ? "" : ` ${check.record}`}`;
368
+ for (const [name, check] of [...authentication, ...reply, ...paths, ...inbound]) {
369
+ const line = `${name.padEnd(name.length >= 6 ? name.length + 1 : 6)}${check.status.padEnd(6)}${`${check.finding} `.padEnd(15)}${check.host}${check.record === null ? "" : ` ${check.record}`}`;
297
370
  if (check.status === "fail") output.error(line);
298
371
  else output.log(line);
299
372
  }
@@ -301,13 +374,15 @@ async function runDns(command: Extract<Command, { kind: "dns" }>, options: RunMa
301
374
  const failedAuthentication = countFailed(authentication);
302
375
  const failedReply = countFailed(reply);
303
376
  const failedPaths = countFailed(paths);
304
- if (failedAuthentication + failedReply + failedPaths === 0) {
377
+ const failedInbound = countFailed(inbound);
378
+ if (failedAuthentication + failedReply + failedPaths + failedInbound === 0) {
305
379
  output.log("receivers can authenticate mail from this domain");
306
380
  return EXIT_OK;
307
381
  }
308
382
  if (failedAuthentication > 0) output.log(`${String(failedAuthentication)} check(s) failed: list mail from this domain will land in spam or be refused`);
309
383
  if (failedReply > 0) output.log("the reply-to domain does not accept mail: replies will bounce");
310
384
  if (failedPaths > 0) output.log(`${String(failedPaths)} return-path host(s) failed: the provider cannot use them for bounces`);
385
+ if (failedInbound > 0) output.log("the inbound service does not receive this domain's mail: replies will not reach it");
311
386
  return EXIT_FAILED;
312
387
  }
313
388
 
@@ -319,7 +394,7 @@ async function runCampaign(command: Extract<Command, { kind: "campaign" }>, opti
319
394
  return EXIT_FAILED;
320
395
  }
321
396
  const input = createInputReader(cwd, options.readStdin ?? readProcessStdin);
322
- const content = await loadCampaignContent(command.contentFile, input);
397
+ const content = await loadCampaignContent(command.contentFile, input, config);
323
398
  if (typeof content === "string") {
324
399
  output.error(`softure-mail campaign: ${content}`);
325
400
  return EXIT_FAILED;
@@ -353,8 +428,8 @@ async function runCampaign(command: Extract<Command, { kind: "campaign" }>, opti
353
428
  try {
354
429
  const loaded: LoadedCampaign = { campaign: content, recipients: listed ?? (await collectRecipients(ctx, content)) };
355
430
  return command.dryRun
356
- ? await reportPlan(ctx, loaded, command.resendUncertain, output)
357
- : await reportSend(ctx, loaded, { pauseMs: command.pauseMs, sleep: options.sleep, retakeUncertain: command.resendUncertain }, output);
431
+ ? await reportPlan(ctx, loaded, { retakeUncertain: command.resendUncertain, limit: command.limit, env: options.env ?? process.env }, output)
432
+ : await reportSend(ctx, loaded, { pauseMs: command.pauseMs, sleep: options.sleep, retakeUncertain: command.resendUncertain, limit: command.limit }, output);
358
433
  } catch (error) {
359
434
  // Driver errors: the message only, never a stack, an address or the database URL.
360
435
  output.error(`softure-mail campaign: ${describeError(error)} (did softure migrate run?)`);
@@ -384,12 +459,16 @@ function createInputReader(cwd: string, readStdin: () => Promise<string>): Input
384
459
  };
385
460
  }
386
461
 
387
- async function loadCampaignContent(file: string, input: InputReader): Promise<CampaignContent | string> {
462
+ /**
463
+ * The campaign in `file`, its kind resolved through `kindAliases`, checked for pasted unsubscribe links and footers;
464
+ * a string says what is wrong.
465
+ */
466
+ async function loadCampaignContent(file: string, input: InputReader, config: SoftureConfig): Promise<CampaignContent | string> {
388
467
  const source = await input.read(file);
389
468
  if (source.text === null) return `cannot read the content file ${source.name}`;
390
469
  const parsed = parseCampaignFile(source.text);
391
470
  if (!parsed.ok) return `${source.name} is not a campaign:\n ${parsed.problems.join("\n ")}`;
392
- const { htmlPath, ...content } = parsed.value;
471
+ const { htmlPath, kind, ...content } = parsed.value;
393
472
  let html: string | null = null;
394
473
  if (htmlPath !== null) {
395
474
  // Next to the content file; for standard input, the working directory.
@@ -398,7 +477,9 @@ async function loadCampaignContent(file: string, input: InputReader): Promise<Ca
398
477
  if (html === null) return `cannot read the HTML body ${htmlFile}`;
399
478
  if (html.trim() === "") return `the HTML body ${htmlFile} is empty`;
400
479
  }
401
- return { ...content, html };
480
+ const campaign: CampaignContent = { ...content, kind: resolveMailKind(config, kind), html };
481
+ const problems = getCampaignProblems(campaign, config);
482
+ return problems.length === 0 ? campaign : `${source.name} cannot be sent:\n ${problems.join("\n ")}`;
402
483
  }
403
484
 
404
485
  async function loadRecipientFile(file: string, input: InputReader): Promise<readonly string[] | string> {
@@ -517,8 +598,13 @@ function reportImportProblems(problems: readonly LineProblem[], output: CliOutpu
517
598
  return EXIT_FAILED;
518
599
  }
519
600
 
520
- async function reportPlan(ctx: DeliveryContext, loaded: LoadedCampaign, retakeUncertain: boolean, output: CliOutput): Promise<number> {
521
- const plan = await planCampaign(ctx, loaded, { retakeUncertain });
601
+ async function reportPlan(
602
+ ctx: DeliveryContext,
603
+ loaded: LoadedCampaign,
604
+ run: { readonly retakeUncertain: boolean; readonly limit: number | null; readonly env: Env },
605
+ output: CliOutput,
606
+ ): Promise<number> {
607
+ const plan = await planCampaign(ctx, loaded, { retakeUncertain: run.retakeUncertain });
522
608
  output.log(`campaign ${loaded.campaign.id} (${loaded.campaign.kind}), dry run: nothing sent or written`);
523
609
  output.log(
524
610
  [
@@ -530,17 +616,109 @@ async function reportPlan(ctx: DeliveryContext, loaded: LoadedCampaign, retakeUn
530
616
  `to send ${String(plan.toSend)}`,
531
617
  ].join(", "),
532
618
  );
619
+ if (run.limit !== null) output.log(`with --limit ${String(run.limit)} this run would send ${String(Math.min(plan.toSend, run.limit))}`);
533
620
  if (plan.contentChanged) {
534
621
  output.error(`campaign ${loaded.campaign.id} was sent with other content; give this content a new id`);
535
622
  return EXIT_FAILED;
536
623
  }
624
+ const { campaign } = loaded;
625
+ const to = getMailingOptions(ctx.config).testAddress ?? PREVIEW_ADDRESS;
626
+ const preview = previewMail(ctx.config, { to, subject: campaign.subject, text: campaign.text, kind: campaign.kind, ...(campaign.html === null ? {} : { html: campaign.html }) }, { env: run.env });
627
+ if (preview.ok) printPreview(preview.value, output);
628
+ else if (preview.error === "mailing.unavailable") output.log(`no preview: set ${UNSUBSCRIBE_SECRET_ENV} to render the unsubscribe footer`);
629
+ else output.log(`no preview: the mail is invalid (${preview.fields.join(", ")})`);
537
630
  return EXIT_OK;
538
631
  }
539
632
 
633
+ /** Who a dry run's preview is addressed to without `testAddress`; `.example` never receives mail (RFC 2606). */
634
+ const PREVIEW_ADDRESS = "recipient@example.com";
635
+
636
+ /** The mail as the provider would get it, one output line per line, the address masked and signatures redacted. */
637
+ function printPreview(message: ProviderMessage, output: CliOutput): void {
638
+ const headers = [
639
+ `From: ${message.from}`,
640
+ `To: ${maskAddress(message.to)}`,
641
+ ...(message.replyTo === null ? [] : [`Reply-To: ${maskAddress(message.replyTo)}`]),
642
+ `Subject: ${message.subject}`,
643
+ ...Object.entries(message.headers).map(([name, value]) => `${name}: ${value}`),
644
+ ];
645
+ const sections = [`--- preview (addresses masked, link signatures redacted) ---`, ...headers, "", "--- text ---", message.text];
646
+ if (message.html !== null) sections.push("--- html ---", message.html);
647
+ sections.push("--- end of preview ---");
648
+ for (const line of redactUnsubscribeSignatures(sections.join("\n")).split("\n")) output.log(line);
649
+ }
650
+
651
+ /** The fixed mail of `softure-mail test` without a content file. Operator text: the test address is the operator's. */
652
+ const TEST_MAIL = {
653
+ subject: "softure-mail test",
654
+ text: "This is a test mail from softure-mail. It arrived, so the provider, the sender address and the reply-to work.",
655
+ };
656
+
657
+ async function runTest(command: Extract<Command, { kind: "test" }>, options: RunMailCliOptions, output: CliOutput): Promise<number> {
658
+ const config = options.config;
659
+ if (config === undefined) {
660
+ output.error("softure-mail test: needs the app's config");
661
+ return EXIT_FAILED;
662
+ }
663
+ const testAddress = getMailingOptions(config).testAddress;
664
+ if (testAddress === undefined) {
665
+ output.error("softure-mail test: set testAddress in the mailing options; the test sends to that address only");
666
+ return EXIT_FAILED;
667
+ }
668
+ let body: { readonly subject: string; readonly text: string; readonly html: string | null; readonly kind: string } = { ...TEST_MAIL, html: null, kind: TRANSACTIONAL_KIND };
669
+ if (command.contentFile !== null) {
670
+ const content = await loadCampaignContent(command.contentFile, createInputReader(options.cwd ?? process.cwd(), options.readStdin ?? readProcessStdin), config);
671
+ if (typeof content === "string") {
672
+ output.error(`softure-mail test: ${content}`);
673
+ return EXIT_FAILED;
674
+ }
675
+ body = content;
676
+ }
677
+ const kind = command.mailKind === null ? body.kind : resolveMailKind(config, command.mailKind);
678
+ const mail: OutgoingMail = { to: testAddress, subject: body.subject, text: body.text, kind, ...(body.html === null ? {} : { html: body.html }) };
679
+ const masked = maskAddress(testAddress);
680
+ if (command.preview) {
681
+ const preview = previewMail(config, mail, { env: options.env ?? process.env });
682
+ if (!preview.ok) {
683
+ output.error(`softure-mail test: ${preview.error === "mailing.invalid_input" ? `the mail is invalid (${preview.fields.join(", ")})` : `set ${UNSUBSCRIBE_SECRET_ENV} to sign the unsubscribe link of list mail`}`);
684
+ return EXIT_FAILED;
685
+ }
686
+ printPreview(preview.value, output);
687
+ return EXIT_OK;
688
+ }
689
+ if (kind === TRANSACTIONAL_KIND) return reportTestSend(await sendMail({ config }, mail), { masked, kind }, output);
690
+ if (config.database === null) {
691
+ output.error("softure-mail test: list mail needs the database (its suppression list); set database.url in softure.config");
692
+ return EXIT_FAILED;
693
+ }
694
+ let opened: CommandDatabase;
695
+ try {
696
+ opened = await openDatabase(config.database, options.openDatabase);
697
+ } catch (error) {
698
+ output.error(`softure-mail test: ${describeError(error)}`);
699
+ return EXIT_FAILED;
700
+ }
701
+ try {
702
+ return reportTestSend(await sendMail({ config, db: opened.handle.db }, mail), { masked, kind }, output);
703
+ } finally {
704
+ await opened.close();
705
+ }
706
+ }
707
+
708
+ function reportTestSend(result: Awaited<ReturnType<typeof sendMail>>, sent: { readonly masked: string; readonly kind: string }, output: CliOutput): number {
709
+ if (result.ok) {
710
+ output.log(`test mail (${sent.kind}) sent to ${sent.masked}; provider message id ${result.value.id}`);
711
+ return EXIT_OK;
712
+ }
713
+ const status = result.httpStatus === undefined ? "" : ` (HTTP ${String(result.httpStatus)})`;
714
+ output.error(`test mail (${sent.kind}) to ${sent.masked} not sent: ${result.error.slice("mailing.".length)}${status}`);
715
+ return EXIT_FAILED;
716
+ }
717
+
540
718
  async function reportSend(
541
719
  ctx: DeliveryContext,
542
720
  loaded: LoadedCampaign,
543
- pace: { readonly pauseMs: number; readonly sleep: ((ms: number) => Promise<void>) | undefined; readonly retakeUncertain: boolean },
721
+ pace: { readonly pauseMs: number; readonly sleep: ((ms: number) => Promise<void>) | undefined; readonly retakeUncertain: boolean; readonly limit: number | null },
544
722
  output: CliOutput,
545
723
  ): Promise<number> {
546
724
  let handled = 0;
@@ -548,6 +726,7 @@ async function reportSend(
548
726
  const result = await sendCampaign(ctx, loaded, {
549
727
  pauseMs: pace.pauseMs,
550
728
  retakeUncertain: pace.retakeUncertain,
729
+ ...(pace.limit === null ? {} : { limit: pace.limit }),
551
730
  ...(pace.sleep === undefined ? {} : { sleep: pace.sleep }),
552
731
  onDelivery: () => {
553
732
  handled += 1;
@@ -560,6 +739,7 @@ async function reportSend(
560
739
  }
561
740
  const summary = result.value;
562
741
  output.log(`campaign ${loaded.campaign.id} (${loaded.campaign.kind}): ${formatSummary(summary)}`);
742
+ if (summary.remaining !== null && summary.remaining > 0) output.log(`limit reached: ${String(summary.remaining)} recipient(s) left for the next run`);
563
743
  let code = EXIT_OK;
564
744
  if (summary.halted !== null) {
565
745
  const status = summary.halted.httpStatus === undefined ? "" : ` (HTTP ${String(summary.halted.httpStatus)})`;
package/src/contract.ts CHANGED
@@ -89,12 +89,18 @@ export type LegacyUnsubscribeParams = readonly string[] | { readonly required: r
89
89
  * Unsubscribe links an app sent before it adopted the module, in its own scheme. `params` are the query names of
90
90
  * such a link; `verify` receives the values the link carried (every required name, the optional names that are
91
91
  * present and non-empty, each at most 512 characters) and returns the recipient's address when the link is genuine,
92
- * else `null`. It must check the link's signature itself: whatever address it returns is unsubscribed. A throw
93
- * reads as a failure (the person may try again), not as a bad link.
92
+ * else `null`. It must check the link's signature itself: whatever address it returns is unsubscribed. `env` is the
93
+ * environment `unsubscribe` was given (`process.env` in the page and the route), so the old signing key is read
94
+ * from it and a test passes its own instead of stubbing `process.env`. A throw reads as a failure (the person may
95
+ * try again), not as a bad link.
94
96
  */
95
97
  export interface LegacyUnsubscribe {
96
98
  readonly params: LegacyUnsubscribeParams;
97
- readonly verify: (values: Readonly<Record<string, string>>, ctx: ModuleContext<Queryable>) => Promise<string | null>;
99
+ readonly verify: (
100
+ values: Readonly<Record<string, string>>,
101
+ ctx: ModuleContext<Queryable>,
102
+ env: Readonly<Record<string, string | undefined>>,
103
+ ) => Promise<string | null>;
98
104
  }
99
105
 
100
106
  /** One mail to one recipient. A mail to a list is a loop over recipients, never a list in `to`. */
package/src/index.ts CHANGED
@@ -17,7 +17,7 @@ export const MODULE_ID = "mailing";
17
17
  export const mailing = defineModule({
18
18
  manifest: {
19
19
  id: MODULE_ID,
20
- version: "0.1.10",
20
+ version: "0.1.12",
21
21
  dependsOn: {},
22
22
  dbSchema: "mailing",
23
23
  tables: ["suppressions", "campaigns", "deliveries"],
package/src/next/index.ts CHANGED
@@ -5,7 +5,9 @@ export { unsubscribeAction } from "./actions.js";
5
5
  export { deliverOnce } from "./deliver-once.js";
6
6
  export { importDeliveries } from "./import-deliveries.js";
7
7
  export { getMailingMessages } from "./messages.js";
8
- export { UnsubscribePage, type UnsubscribePageProps } from "./pages.js";
8
+ export { createUnsubscribePage, UnsubscribePage, type UnsubscribeLayoutProps, type UnsubscribePageOptions, type UnsubscribePageProps, type UnsubscribePageSlot } from "./pages.js";
9
+ export { previewMail } from "./preview-mail.js";
9
10
  export { UNSUBSCRIBE_STATUS_PARAM, type UnsubscribeStatus } from "./params.js";
10
11
  export { getUnsubscribeRoute, postUnsubscribeRoute } from "./route.js";
12
+ export { runDeliveries } from "./run-deliveries.js";
11
13
  export { sendMail } from "./send-mail.js";
@@ -6,8 +6,8 @@
6
6
  // module's signed links and, with `mailing({ legacyUnsubscribe })`, the app's older ones: mount it
7
7
  // at the old links' path too.
8
8
  import { getSoftureConfig } from "@softure-ai/core/next";
9
- import { Button, Card, FormError } from "@softure-ai/ui";
10
- import type { ReactNode } from "react";
9
+ import { Button, Card, createSlotClassGetter, FormError, type ClassNames } from "@softure-ai/ui";
10
+ import type { ComponentType, ReactNode } from "react";
11
11
  import { getUnsubscribeLinkParams, readUnsubscribeLink } from "../server/unsubscribe-link.js";
12
12
  import { unsubscribeAction } from "./actions.js";
13
13
  import { getMailingMessages } from "./messages.js";
@@ -19,50 +19,105 @@ export interface UnsubscribePageProps {
19
19
  readonly searchParams?: SearchParams;
20
20
  }
21
21
 
22
- const LAYOUT_CLASS = "sft:mx-auto sft:box-border sft:w-full sft:sm:max-w-md sft:px-4 sft:py-4";
23
- const FORM_CLASS = "sft:flex sft:flex-col sft:gap-3 sft:font-sans";
22
+ /** The page's parts an app can style: the `main` wrapper, the card, the form and the submit button. */
23
+ export type UnsubscribePageSlot = "root" | "card" | "form" | "submit";
24
+
25
+ /** What a custom layout receives: the page's copy, and the form (none on the outcome pages). */
26
+ export interface UnsubscribeLayoutProps {
27
+ readonly title: string;
28
+ readonly lead: string;
29
+ readonly children?: ReactNode;
30
+ }
31
+
32
+ export interface UnsubscribePageOptions {
33
+ /** Classes per slot, added to the defaults (or alone with `unstyled`). */
34
+ readonly classNames?: ClassNames<UnsubscribePageSlot>;
35
+ readonly unstyled?: boolean;
36
+ /**
37
+ * Replaces the `main` wrapper and the card, e.g. with the app's header and footer around the copy; the `root` and
38
+ * `card` classes then do not apply. The form inside stays the module's.
39
+ */
40
+ readonly Layout?: ComponentType<UnsubscribeLayoutProps>;
41
+ }
42
+
43
+ const DEFAULT_CLASSES: Readonly<Record<UnsubscribePageSlot, string>> = {
44
+ root: "sft:mx-auto sft:box-border sft:w-full sft:sm:max-w-md sft:px-4 sft:py-4",
45
+ card: "",
46
+ form: "sft:flex sft:flex-col sft:gap-3 sft:font-sans",
47
+ submit: "",
48
+ };
24
49
 
25
50
  function readParam(params: Record<string, string | string[] | undefined>, name: string): string | undefined {
26
51
  const value = params[name];
27
52
  return Array.isArray(value) ? value[0] : value;
28
53
  }
29
54
 
55
+ type SlotClass = (slot: UnsubscribePageSlot) => string | undefined;
56
+
30
57
  /**
31
58
  * The link's token stays out of other sites' Referer headers; `same-origin` (not `no-referrer`)
32
- * keeps the Origin header Next checks on server actions.
59
+ * keeps the Origin header Next checks on server actions. The tag is rendered whatever the layout.
33
60
  */
34
- function UnsubscribeLayout({ title, lead, children }: { title: string; lead: string; children?: ReactNode }) {
61
+ function UnsubscribeLayout({ title, lead, children, slot, options }: UnsubscribeLayoutProps & { readonly slot: SlotClass; readonly options: UnsubscribePageOptions }) {
62
+ const { Layout } = options;
63
+ if (Layout !== undefined) {
64
+ return (
65
+ <>
66
+ <meta name="referrer" content="same-origin" />
67
+ <Layout title={title} lead={lead}>
68
+ {children}
69
+ </Layout>
70
+ </>
71
+ );
72
+ }
73
+ const card = slot("card");
35
74
  return (
36
- <main className={LAYOUT_CLASS}>
75
+ <main className={slot("root")}>
37
76
  <meta name="referrer" content="same-origin" />
38
- <Card title={title} subtitle={lead}>
77
+ <Card title={title} subtitle={lead} unstyled={options.unstyled} {...(card === undefined ? {} : { classNames: { root: card } })}>
39
78
  {children}
40
79
  </Card>
41
80
  </main>
42
81
  );
43
82
  }
44
83
 
84
+ /**
85
+ * The unsubscribe page with the app's classes or layout:
86
+ * `export default createUnsubscribePage({ classNames: { root: "app-narrow" }, Layout: AppShell })`.
87
+ */
88
+ export function createUnsubscribePage(options: UnsubscribePageOptions = {}) {
89
+ return async function UnsubscribePageWithOptions({ searchParams }: UnsubscribePageProps) {
90
+ return renderUnsubscribePage(searchParams, options);
91
+ };
92
+ }
93
+
45
94
  export async function UnsubscribePage({ searchParams }: UnsubscribePageProps) {
95
+ return renderUnsubscribePage(searchParams, {});
96
+ }
97
+
98
+ async function renderUnsubscribePage(searchParams: SearchParams | undefined, options: UnsubscribePageOptions) {
46
99
  const config = getSoftureConfig();
47
100
  const messages = getMailingMessages(config).unsubscribe;
101
+ const slot = createSlotClassGetter({ defaults: DEFAULT_CLASSES, classNames: options.classNames, unstyled: options.unstyled });
48
102
  const params = (await searchParams) ?? {};
49
103
  const status = readParam(params, UNSUBSCRIBE_STATUS_PARAM);
50
- if (status === "done") return <UnsubscribeLayout title={messages.doneTitle} lead={messages.doneBody} />;
104
+ if (status === "done") return <UnsubscribeLayout title={messages.doneTitle} lead={messages.doneBody} slot={slot} options={options} />;
51
105
 
52
106
  const link = readUnsubscribeLink({ get: (name) => readParam(params, name) ?? null }, config);
53
107
  const linkParams = link === null ? {} : getUnsubscribeLinkParams(link);
54
108
  if (status === "invalid" || link === null || Object.values(linkParams).some((value) => value === "")) {
55
- return <UnsubscribeLayout title={messages.invalidTitle} lead={messages.invalidBody} />;
109
+ return <UnsubscribeLayout title={messages.invalidTitle} lead={messages.invalidBody} slot={slot} options={options} />;
56
110
  }
111
+ const submit = slot("submit");
57
112
  return (
58
- <UnsubscribeLayout title={messages.title} lead={messages.lead}>
59
- <form action={unsubscribeAction} className={FORM_CLASS}>
113
+ <UnsubscribeLayout title={messages.title} lead={messages.lead} slot={slot} options={options}>
114
+ <form action={unsubscribeAction} className={slot("form")}>
60
115
  {Object.entries(linkParams).map(([name, value]) => (
61
116
  <input key={name} type="hidden" name={name} value={value} />
62
117
  ))}
63
- <FormError message={status === "failed" ? messages.failed : undefined} />
118
+ <FormError message={status === "failed" ? messages.failed : undefined} unstyled={options.unstyled} />
64
119
  <div>
65
- <Button type="submit" variant="primary">
120
+ <Button type="submit" variant="primary" unstyled={options.unstyled} {...(submit === undefined ? {} : { classNames: { root: submit } })}>
66
121
  {messages.submit}
67
122
  </Button>
68
123
  </div>