@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/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
- import type { DeliveryContext, HaltingErrorCode } from "../server/deliveries.js";
18
+ import type { DeliveryContext, RunHaltReason } 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[];
@@ -54,10 +57,12 @@ export const DEFAULT_PAUSE_MS = 500;
54
57
  export const MAIL_USAGE = `Usage:
55
58
  softure-mail campaign <content-file> [--recipients <file>] [--dry-run] [--pause-ms <ms>] [--resend-uncertain]
56
59
  [--limit <n>]
60
+ softure-mail test [<content-file>] [--kind <kind>] [--preview]
57
61
  softure-mail import <history-file> [--dry-run]
58
62
  softure-mail dns [--domain <domain>] [--dkim-selector <name>]... [--spf-host <host>]...
59
63
  [--dmarc-policy <p>] [--dmarc-sp <p>] [--dmarc-adkim <a>] [--dmarc-aspf <a>]
60
64
  [--reply-to <address>] [--return-path <host>]... [--resend-return-path]
65
+ [--inbound cloudflare]
61
66
 
62
67
  campaign Sends the campaign in <content-file> (frontmatter id, kind, subject, optional html,
63
68
  then the text body) to every address in the recipients file (one per line), at most
@@ -71,6 +76,17 @@ campaign Sends the campaign in <content-file> (frontmatter id, kind, subject, o
71
76
  (it may have gone out: they may get the mail twice)
72
77
  --limit <n> hand at most n mails to the provider in this run (1 to 1000000);
73
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
74
90
 
75
91
  import Writes deliveries the app made before it adopted the module into the ledger, so
76
92
  later sends skip them. <history-file> holds one JSON object per line: scope,
@@ -93,13 +109,16 @@ dns Checks SPF, DKIM and DMARC for the sender domain (default: the domain
93
109
  (default: replyTo of the config, when --domain is not given)
94
110
  --return-path <host> a return-path host that must be a CNAME or have MX (repeatable)
95
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)
96
114
 
97
115
  Options for all:
98
116
  --config <file> the app's softure.config file (bin only)
99
117
  --help show this help
100
118
 
101
119
  A file given as - is read from standard input (one of them per command), e.g.
102
- 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.`;
103
122
 
104
123
  const consoleOutput: CliOutput = {
105
124
  log: (line) => console.log(line),
@@ -119,6 +138,14 @@ type Command =
119
138
  /** `null`: no limit. */
120
139
  readonly limit: number | null;
121
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;
148
+ }
122
149
  | { readonly kind: "import"; readonly historyFile: string; readonly dryRun: boolean }
123
150
  | {
124
151
  readonly kind: "dns";
@@ -129,6 +156,7 @@ type Command =
129
156
  readonly replyTo: string | undefined;
130
157
  readonly returnPathHosts: readonly string[];
131
158
  readonly resendReturnPath: boolean;
159
+ readonly inbound: "cloudflare" | undefined;
132
160
  };
133
161
 
134
162
  const DMARC_POLICIES: readonly DmarcPolicy[] = ["quarantine", "reject"];
@@ -136,8 +164,10 @@ const DMARC_ALIGNMENTS: readonly DmarcAlignment[] = ["r", "s"];
136
164
  /** A file name that means standard input. */
137
165
  const STDIN = "-";
138
166
  const CAMPAIGN_ONLY_OPTIONS = ["recipients", "pause-ms", "resend-uncertain", "limit"] as const;
167
+ const TEST_ONLY_OPTIONS = ["kind", "preview"] as const;
139
168
  const MAX_LIMIT = 1_000_000;
140
- 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;
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;
141
171
 
142
172
  /** Runs the command and returns the process exit code: 0 done, 1 failed or incomplete, 2 usage error. */
143
173
  export async function runMailCli(options: RunMailCliOptions): Promise<number> {
@@ -156,6 +186,8 @@ export async function runMailCli(options: RunMailCliOptions): Promise<number> {
156
186
  return runDns(command, options, output);
157
187
  case "campaign":
158
188
  return runCampaign(command, options, output);
189
+ case "test":
190
+ return runTest(command, options, output);
159
191
  case "import":
160
192
  return runImport(command, options, output);
161
193
  }
@@ -163,9 +195,9 @@ export async function runMailCli(options: RunMailCliOptions): Promise<number> {
163
195
 
164
196
  function parseCommand(argv: readonly string[]): Command | string {
165
197
  const [name, ...rest] = argv;
166
- if (name === undefined) return "missing command; use campaign, import or dns";
198
+ if (name === undefined) return "missing command; use campaign, test, import or dns";
167
199
  if (name === "--help") return { kind: "help" };
168
- 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`;
169
201
 
170
202
  let parsed: ReturnType<typeof parseCommandArgs>;
171
203
  try {
@@ -177,11 +209,13 @@ function parseCommand(argv: readonly string[]): Command | string {
177
209
  if (values.help === true) return { kind: "help" };
178
210
 
179
211
  if (name === "dns") {
180
- 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);
181
213
  if (misplaced.length > 0) return `dns does not take --${misplaced.join(", --")}`;
182
214
  if (positionals.length > 0) return `dns takes no file, got "${positionals.join(" ")}"`;
183
215
  const expectDmarc = parseDmarcExpectation(values);
184
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 ?? ""}"`;
185
219
  return {
186
220
  kind: "dns",
187
221
  domain: values.domain,
@@ -191,11 +225,12 @@ function parseCommand(argv: readonly string[]): Command | string {
191
225
  replyTo: values["reply-to"],
192
226
  returnPathHosts: values["return-path"] ?? [],
193
227
  resendReturnPath: values["resend-return-path"] === true,
228
+ inbound,
194
229
  };
195
230
  }
196
231
 
197
232
  if (name === "import") {
198
- 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);
199
234
  if (misplaced.length > 0) return `import does not take --${misplaced.join(", --")}`;
200
235
  const [historyFile, ...extra] = positionals;
201
236
  if (historyFile === undefined) return "import needs a history file (- for standard input)";
@@ -203,11 +238,18 @@ function parseCommand(argv: readonly string[]): Command | string {
203
238
  return { kind: "import", historyFile, dryRun: values["dry-run"] === true };
204
239
  }
205
240
 
206
- 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);
207
250
  if (misplaced.length > 0) return `campaign does not take --${misplaced.join(", --")}`;
208
- const [contentFile, ...extra] = positionals;
209
- if (contentFile === undefined) return "campaign needs a content file";
210
- 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";
211
253
  if (contentFile === STDIN && values.recipients === STDIN) return "campaign can read only one of the content file and --recipients from standard input";
212
254
  const pauseMs = values["pause-ms"] === undefined ? DEFAULT_PAUSE_MS : Number(values["pause-ms"]);
213
255
  if (!Number.isInteger(pauseMs) || pauseMs < 0 || pauseMs > 60_000) return `--pause-ms expects whole milliseconds from 0 to 60000, got "${values["pause-ms"] ?? ""}"`;
@@ -243,6 +285,10 @@ function parseCommandArgs(args: readonly string[]) {
243
285
  "reply-to": { type: "string" },
244
286
  "return-path": { type: "string", multiple: true },
245
287
  "resend-return-path": { type: "boolean" },
288
+ inbound: { type: "string" },
289
+ "content-file": { type: "string" },
290
+ kind: { type: "string" },
291
+ preview: { type: "boolean" },
246
292
  help: { type: "boolean" },
247
293
  },
248
294
  strict: true,
@@ -252,6 +298,14 @@ function parseCommandArgs(args: readonly string[]) {
252
298
 
253
299
  type ParsedValues = ReturnType<typeof parseCommandArgs>["values"];
254
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
+
255
309
  /** One choice of a fixed list, case-insensitive; `undefined` when the option was not given. */
256
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 {
257
311
  const given = values[option];
@@ -297,6 +351,7 @@ async function runDns(command: Extract<Command, { kind: "dns" }>, options: RunMa
297
351
  ...(command.expectDmarc === undefined ? {} : { expectDmarc: command.expectDmarc }),
298
352
  ...(replyTo === undefined ? {} : { replyTo }),
299
353
  returnPath,
354
+ ...(command.inbound === undefined ? {} : { inbound: command.inbound }),
300
355
  ...(options.resolveTxt === undefined ? {} : { resolveTxt: options.resolveTxt }),
301
356
  ...(options.resolveMx === undefined ? {} : { resolveMx: options.resolveMx }),
302
357
  ...(options.resolveCname === undefined ? {} : { resolveCname: options.resolveCname }),
@@ -308,9 +363,10 @@ async function runDns(command: Extract<Command, { kind: "dns" }>, options: RunMa
308
363
  ];
309
364
  const reply: [string, DnsCheck][] = report.replyTo === null ? [] : [["REPLY", report.replyTo]];
310
365
  const paths: [string, DnsCheck][] = report.returnPath.map((check) => ["PATH", check]);
366
+ const inbound: [string, DnsCheck][] = report.inbound.map((check) => ["INBOUND", check]);
311
367
  output.log(`sender domain ${report.domain}`);
312
- for (const [name, check] of [...authentication, ...reply, ...paths]) {
313
- 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}`}`;
314
370
  if (check.status === "fail") output.error(line);
315
371
  else output.log(line);
316
372
  }
@@ -318,13 +374,15 @@ async function runDns(command: Extract<Command, { kind: "dns" }>, options: RunMa
318
374
  const failedAuthentication = countFailed(authentication);
319
375
  const failedReply = countFailed(reply);
320
376
  const failedPaths = countFailed(paths);
321
- if (failedAuthentication + failedReply + failedPaths === 0) {
377
+ const failedInbound = countFailed(inbound);
378
+ if (failedAuthentication + failedReply + failedPaths + failedInbound === 0) {
322
379
  output.log("receivers can authenticate mail from this domain");
323
380
  return EXIT_OK;
324
381
  }
325
382
  if (failedAuthentication > 0) output.log(`${String(failedAuthentication)} check(s) failed: list mail from this domain will land in spam or be refused`);
326
383
  if (failedReply > 0) output.log("the reply-to domain does not accept mail: replies will bounce");
327
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");
328
386
  return EXIT_FAILED;
329
387
  }
330
388
 
@@ -336,7 +394,7 @@ async function runCampaign(command: Extract<Command, { kind: "campaign" }>, opti
336
394
  return EXIT_FAILED;
337
395
  }
338
396
  const input = createInputReader(cwd, options.readStdin ?? readProcessStdin);
339
- const content = await loadCampaignContent(command.contentFile, input);
397
+ const content = await loadCampaignContent(command.contentFile, input, config);
340
398
  if (typeof content === "string") {
341
399
  output.error(`softure-mail campaign: ${content}`);
342
400
  return EXIT_FAILED;
@@ -370,7 +428,7 @@ async function runCampaign(command: Extract<Command, { kind: "campaign" }>, opti
370
428
  try {
371
429
  const loaded: LoadedCampaign = { campaign: content, recipients: listed ?? (await collectRecipients(ctx, content)) };
372
430
  return command.dryRun
373
- ? await reportPlan(ctx, loaded, { retakeUncertain: command.resendUncertain, limit: command.limit }, output)
431
+ ? await reportPlan(ctx, loaded, { retakeUncertain: command.resendUncertain, limit: command.limit, env: options.env ?? process.env }, output)
374
432
  : await reportSend(ctx, loaded, { pauseMs: command.pauseMs, sleep: options.sleep, retakeUncertain: command.resendUncertain, limit: command.limit }, output);
375
433
  } catch (error) {
376
434
  // Driver errors: the message only, never a stack, an address or the database URL.
@@ -401,12 +459,16 @@ function createInputReader(cwd: string, readStdin: () => Promise<string>): Input
401
459
  };
402
460
  }
403
461
 
404
- 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> {
405
467
  const source = await input.read(file);
406
468
  if (source.text === null) return `cannot read the content file ${source.name}`;
407
469
  const parsed = parseCampaignFile(source.text);
408
470
  if (!parsed.ok) return `${source.name} is not a campaign:\n ${parsed.problems.join("\n ")}`;
409
- const { htmlPath, ...content } = parsed.value;
471
+ const { htmlPath, kind, ...content } = parsed.value;
410
472
  let html: string | null = null;
411
473
  if (htmlPath !== null) {
412
474
  // Next to the content file; for standard input, the working directory.
@@ -415,7 +477,9 @@ async function loadCampaignContent(file: string, input: InputReader): Promise<Ca
415
477
  if (html === null) return `cannot read the HTML body ${htmlFile}`;
416
478
  if (html.trim() === "") return `the HTML body ${htmlFile} is empty`;
417
479
  }
418
- 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 ")}`;
419
483
  }
420
484
 
421
485
  async function loadRecipientFile(file: string, input: InputReader): Promise<readonly string[] | string> {
@@ -537,7 +601,7 @@ function reportImportProblems(problems: readonly LineProblem[], output: CliOutpu
537
601
  async function reportPlan(
538
602
  ctx: DeliveryContext,
539
603
  loaded: LoadedCampaign,
540
- run: { readonly retakeUncertain: boolean; readonly limit: number | null },
604
+ run: { readonly retakeUncertain: boolean; readonly limit: number | null; readonly env: Env },
541
605
  output: CliOutput,
542
606
  ): Promise<number> {
543
607
  const plan = await planCampaign(ctx, loaded, { retakeUncertain: run.retakeUncertain });
@@ -557,9 +621,100 @@ async function reportPlan(
557
621
  output.error(`campaign ${loaded.campaign.id} was sent with other content; give this content a new id`);
558
622
  return EXIT_FAILED;
559
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(", ")})`);
560
630
  return EXIT_OK;
561
631
  }
562
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
+
563
718
  async function reportSend(
564
719
  ctx: DeliveryContext,
565
720
  loaded: LoadedCampaign,
@@ -588,7 +743,8 @@ async function reportSend(
588
743
  let code = EXIT_OK;
589
744
  if (summary.halted !== null) {
590
745
  const status = summary.halted.httpStatus === undefined ? "" : ` (HTTP ${String(summary.halted.httpStatus)})`;
591
- output.error(`stopped: ${HALT_EXPLANATIONS[summary.halted.reason]}${status}; the remaining recipients were not touched, run the same command again once it is fixed`);
746
+ const when = summary.halted.reason === "mailing.unavailable" ? "later" : "once it is fixed";
747
+ output.error(`stopped: ${HALT_EXPLANATIONS[summary.halted.reason]}${status}; the remaining recipients were not touched, run the same command again ${when}`);
592
748
  code = EXIT_FAILED;
593
749
  }
594
750
  const open = summary.inFlight + summary.retryLater;
@@ -605,9 +761,10 @@ async function reportSend(
605
761
  return code;
606
762
  }
607
763
 
608
- const HALT_EXPLANATIONS: Readonly<Record<HaltingErrorCode, string>> = {
764
+ const HALT_EXPLANATIONS: Readonly<Record<RunHaltReason, string>> = {
609
765
  "mailing.provider_refused": "the provider refused the account (check the API key and the account)",
610
766
  "mailing.quota_exceeded": "the account's sending quota is spent",
767
+ "mailing.unavailable": "the provider is unavailable or rate limiting",
611
768
  };
612
769
 
613
770
  function formatSummary(summary: CampaignSummary): string {
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.11",
20
+ version: "0.1.13",
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>
@@ -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
+ }