@softure-ai/mailing 0.1.7 → 0.1.9

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 (77) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +131 -23
  3. package/dist/cli/run.d.ts +6 -2
  4. package/dist/cli/run.d.ts.map +1 -1
  5. package/dist/cli/run.js +297 -40
  6. package/dist/cli/run.js.map +1 -1
  7. package/dist/contract.d.ts +35 -5
  8. package/dist/contract.d.ts.map +1 -1
  9. package/dist/index.d.ts +14 -2
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +1 -1
  12. package/dist/index.js.map +1 -1
  13. package/dist/next/import-deliveries.d.ts +4 -0
  14. package/dist/next/import-deliveries.d.ts.map +1 -0
  15. package/dist/next/import-deliveries.js +9 -0
  16. package/dist/next/import-deliveries.js.map +1 -0
  17. package/dist/next/index.d.ts +1 -0
  18. package/dist/next/index.d.ts.map +1 -1
  19. package/dist/next/index.js +1 -0
  20. package/dist/next/index.js.map +1 -1
  21. package/dist/next/route.d.ts +3 -2
  22. package/dist/next/route.d.ts.map +1 -1
  23. package/dist/next/route.js +5 -4
  24. package/dist/next/route.js.map +1 -1
  25. package/dist/options.d.ts +19 -2
  26. package/dist/options.d.ts.map +1 -1
  27. package/dist/options.js +30 -6
  28. package/dist/options.js.map +1 -1
  29. package/dist/schema.d.ts +17 -0
  30. package/dist/schema.d.ts.map +1 -1
  31. package/dist/schema.js +2 -0
  32. package/dist/schema.js.map +1 -1
  33. package/dist/server/campaigns.d.ts +5 -0
  34. package/dist/server/campaigns.d.ts.map +1 -1
  35. package/dist/server/campaigns.js +10 -0
  36. package/dist/server/campaigns.js.map +1 -1
  37. package/dist/server/deliveries.d.ts +9 -5
  38. package/dist/server/deliveries.d.ts.map +1 -1
  39. package/dist/server/deliveries.js +9 -7
  40. package/dist/server/deliveries.js.map +1 -1
  41. package/dist/server/dns.d.ts +48 -1
  42. package/dist/server/dns.d.ts.map +1 -1
  43. package/dist/server/dns.js +124 -15
  44. package/dist/server/dns.js.map +1 -1
  45. package/dist/server/import-deliveries.d.ts +68 -0
  46. package/dist/server/import-deliveries.d.ts.map +1 -0
  47. package/dist/server/import-deliveries.js +105 -0
  48. package/dist/server/import-deliveries.js.map +1 -0
  49. package/dist/server/index.d.ts +4 -3
  50. package/dist/server/index.d.ts.map +1 -1
  51. package/dist/server/index.js +4 -3
  52. package/dist/server/index.js.map +1 -1
  53. package/dist/server/suppressions.d.ts.map +1 -1
  54. package/dist/server/suppressions.js +9 -6
  55. package/dist/server/suppressions.js.map +1 -1
  56. package/dist/server/unsubscribe-link.d.ts +3 -2
  57. package/dist/server/unsubscribe-link.d.ts.map +1 -1
  58. package/dist/server/unsubscribe-link.js +12 -3
  59. package/dist/server/unsubscribe-link.js.map +1 -1
  60. package/migrations/0004_allow_imported_deliveries.sql +20 -0
  61. package/module.json +1 -1
  62. package/package.json +1 -1
  63. package/src/cli/run.ts +311 -39
  64. package/src/contract.ts +30 -5
  65. package/src/index.ts +4 -1
  66. package/src/next/import-deliveries.ts +9 -0
  67. package/src/next/index.ts +1 -0
  68. package/src/next/route.ts +5 -4
  69. package/src/options.ts +36 -7
  70. package/src/schema.ts +2 -0
  71. package/src/server/campaigns.ts +13 -0
  72. package/src/server/deliveries.ts +15 -8
  73. package/src/server/dns.ts +174 -16
  74. package/src/server/import-deliveries.ts +149 -0
  75. package/src/server/index.ts +21 -0
  76. package/src/server/suppressions.ts +15 -8
  77. package/src/server/unsubscribe-link.ts +10 -3
package/src/cli/run.ts CHANGED
@@ -1,6 +1,6 @@
1
- // `softure-mail`: sends a campaign from a content file and checks the sender domain's DNS. The app
2
- // passes its validated config, so the same function serves the bin and an app script that a
3
- // bundler packs for a container:
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
3
+ // function serves the bin and an app script that a bundler packs for a container:
4
4
  //
5
5
  // // scripts/mail.ts
6
6
  // import { runMailCli } from "@softure-ai/mailing/cli";
@@ -10,11 +10,13 @@ import { readFile } from "node:fs/promises";
10
10
  import { dirname, resolve } from "node:path";
11
11
  import { parseArgs } from "node:util";
12
12
  import { systemClock, type SoftureConfig, type SoftureDatabaseConfig } from "@softure-ai/core";
13
+ import { z } from "zod";
13
14
  import { openCommandDatabase, type CommandDatabase, type DatabaseHandle } from "@softure-ai/db";
14
15
  import { parseCampaignFile, parseRecipientList, type CampaignContent } from "../server/campaign-file.js";
15
- import { planCampaign, sendCampaign, type CampaignSummary } from "../server/campaigns.js";
16
+ import { listConfiguredCampaignRecipients, planCampaign, sendCampaign, type CampaignSummary } from "../server/campaigns.js";
16
17
  import type { DeliveryContext, HaltingErrorCode } from "../server/deliveries.js";
17
- import { checkSenderDns, getSenderDomain, type DnsCheck, type ResolveTxt } from "../server/dns.js";
18
+ 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
+ import { checkImportedDeliveries, importDeliveries, type ImportedDelivery, type ImportProblem } from "../server/import-deliveries.js";
18
20
  import { getMailingOptions } from "../server/options.js";
19
21
  import { MIN_UNSUBSCRIBE_SECRET_LENGTH, readUnsubscribeSecrets, UNSUBSCRIBE_SECRET_ENV, type Env } from "../server/unsubscribe-link.js";
20
22
 
@@ -24,7 +26,7 @@ export interface CliOutput {
24
26
  }
25
27
 
26
28
  export interface RunMailCliOptions {
27
- /** The app's config. Needed by `campaign`, and by `dns` without `--domain`. */
29
+ /** The app's config. Needed by `campaign` and `import`, and by `dns` without `--domain`. */
28
30
  readonly config?: SoftureConfig;
29
31
  /** The arguments after the executable, e.g. `["campaign", "launch.md", "--recipients", "list.txt"]`. */
30
32
  readonly argv: readonly string[];
@@ -34,8 +36,12 @@ export interface RunMailCliOptions {
34
36
  /** Opens the campaign's database connection. Default: the config's `database.handle`, else `createDatabase(url, { max: 1 })`. */
35
37
  readonly openDatabase?: (url: string) => Promise<DatabaseHandle>;
36
38
  readonly resolveTxt?: ResolveTxt;
39
+ readonly resolveMx?: ResolveMx;
40
+ readonly resolveCname?: ResolveCname;
37
41
  readonly sleep?: (ms: number) => Promise<void>;
38
42
  readonly env?: Env;
43
+ /** Reads all of standard input, for a file given as `-`. Default: `process.stdin`. */
44
+ readonly readStdin?: () => Promise<string>;
39
45
  }
40
46
 
41
47
  export const EXIT_OK = 0;
@@ -46,27 +52,51 @@ export const EXIT_USAGE = 2;
46
52
  export const DEFAULT_PAUSE_MS = 500;
47
53
 
48
54
  export const MAIL_USAGE = `Usage:
49
- softure-mail campaign <content-file> --recipients <file> [--dry-run] [--pause-ms <ms>] [--resend-uncertain]
55
+ softure-mail campaign <content-file> [--recipients <file>] [--dry-run] [--pause-ms <ms>] [--resend-uncertain]
56
+ softure-mail import <history-file> [--dry-run]
50
57
  softure-mail dns [--domain <domain>] [--dkim-selector <name>]... [--spf-host <host>]...
58
+ [--dmarc-policy <p>] [--dmarc-sp <p>] [--dmarc-adkim <a>] [--dmarc-aspf <a>]
59
+ [--reply-to <address>] [--return-path <host>]... [--resend-return-path]
51
60
 
52
61
  campaign Sends the campaign in <content-file> (frontmatter id, kind, subject, optional html,
53
62
  then the text body) to every address in the recipients file (one per line), at most
54
- once each. Re-running sends only to recipients without an outcome yet. A refused
55
- API key or a spent quota stops the run; the rest go out on the next one.
63
+ once each. Without --recipients, the recipients come from listCampaignRecipients in
64
+ the mailing options. Re-running sends only to recipients without an outcome yet. A
65
+ refused API key or a spent quota stops the run; the rest go out on the next one.
66
+ --recipients <file> the recipients, one address per line
56
67
  --dry-run count what would be sent and change nothing
57
68
  --pause-ms <ms> pause after every send (default ${String(DEFAULT_PAUSE_MS)})
58
69
  --resend-uncertain also send to recipients whose send was interrupted long ago
59
70
  (it may have gone out: they may get the mail twice)
60
71
 
72
+ import Writes deliveries the app made before it adopted the module into the ledger, so
73
+ later sends skip them. <history-file> holds one JSON object per line: scope,
74
+ address, status (sent or rejected), finishedAt, and optional providerMessageId,
75
+ reason, kind. Rows already in the ledger are left as they are; any invalid row
76
+ stops the import before anything is written.
77
+ --dry-run check the file and change nothing
78
+
61
79
  dns Checks SPF, DKIM and DMARC for the sender domain (default: the domain of "from").
62
80
  --domain <domain> check this domain instead
63
81
  --dkim-selector <name> DKIM selector to look up (default resend; repeatable)
64
82
  --spf-host <host> where SPF is published (default the domain; repeatable;
65
83
  Resend publishes it on send.<domain>)
84
+ --dmarc-policy <p> require at least this DMARC policy (quarantine or reject)
85
+ and pct=100; a weaker record fails
86
+ --dmarc-sp <p> require at least this subdomain policy (sp, else p)
87
+ --dmarc-adkim <a> require this DKIM alignment (r or s; absent counts as r)
88
+ --dmarc-aspf <a> require this SPF alignment (r or s; absent counts as r)
89
+ --reply-to <address> check that this address's domain accepts mail (MX, one SPF)
90
+ (default: replyTo of the config, when --domain is not given)
91
+ --return-path <host> a return-path host that must be a CNAME or have MX (repeatable)
92
+ --resend-return-path check Resend's send.<domain> and rsend.<domain> CNAMEs
66
93
 
67
- Options for both:
94
+ Options for all:
68
95
  --config <file> the app's softure.config file (bin only)
69
- --help show this help`;
96
+ --help show this help
97
+
98
+ 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`;
70
100
 
71
101
  const consoleOutput: CliOutput = {
72
102
  log: (line) => console.log(line),
@@ -78,12 +108,30 @@ type Command =
78
108
  | {
79
109
  readonly kind: "campaign";
80
110
  readonly contentFile: string;
81
- readonly recipientsFile: string;
111
+ /** `null`: the module's `listCampaignRecipients`. */
112
+ readonly recipientsFile: string | null;
82
113
  readonly dryRun: boolean;
83
114
  readonly pauseMs: number;
84
115
  readonly resendUncertain: boolean;
85
116
  }
86
- | { readonly kind: "dns"; readonly domain: string | undefined; readonly dkimSelectors: readonly string[] | undefined; readonly spfHosts: readonly string[] | undefined };
117
+ | { readonly kind: "import"; readonly historyFile: string; readonly dryRun: boolean }
118
+ | {
119
+ readonly kind: "dns";
120
+ readonly domain: string | undefined;
121
+ readonly dkimSelectors: readonly string[] | undefined;
122
+ readonly spfHosts: readonly string[] | undefined;
123
+ readonly expectDmarc: DmarcExpectation | undefined;
124
+ readonly replyTo: string | undefined;
125
+ readonly returnPathHosts: readonly string[];
126
+ readonly resendReturnPath: boolean;
127
+ };
128
+
129
+ const DMARC_POLICIES: readonly DmarcPolicy[] = ["quarantine", "reject"];
130
+ const DMARC_ALIGNMENTS: readonly DmarcAlignment[] = ["r", "s"];
131
+ /** A file name that means standard input. */
132
+ 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;
87
135
 
88
136
  /** Runs the command and returns the process exit code: 0 done, 1 failed or incomplete, 2 usage error. */
89
137
  export async function runMailCli(options: RunMailCliOptions): Promise<number> {
@@ -102,14 +150,16 @@ export async function runMailCli(options: RunMailCliOptions): Promise<number> {
102
150
  return runDns(command, options, output);
103
151
  case "campaign":
104
152
  return runCampaign(command, options, output);
153
+ case "import":
154
+ return runImport(command, options, output);
105
155
  }
106
156
  }
107
157
 
108
158
  function parseCommand(argv: readonly string[]): Command | string {
109
159
  const [name, ...rest] = argv;
110
- if (name === undefined) return "missing command; use campaign or dns";
160
+ if (name === undefined) return "missing command; use campaign, import or dns";
111
161
  if (name === "--help") return { kind: "help" };
112
- if (name !== "campaign" && name !== "dns") return `unknown command "${name}"; use campaign or dns`;
162
+ if (name !== "campaign" && name !== "import" && name !== "dns") return `unknown command "${name}"; use campaign, import or dns`;
113
163
 
114
164
  let parsed: ReturnType<typeof parseCommandArgs>;
115
165
  try {
@@ -121,21 +171,41 @@ function parseCommand(argv: readonly string[]): Command | string {
121
171
  if (values.help === true) return { kind: "help" };
122
172
 
123
173
  if (name === "dns") {
124
- const misplaced = (["recipients", "dry-run", "pause-ms", "resend-uncertain"] as const).filter((option) => values[option] !== undefined);
174
+ const misplaced = ([...CAMPAIGN_ONLY_OPTIONS, "dry-run"] as const).filter((option) => values[option] !== undefined);
125
175
  if (misplaced.length > 0) return `dns does not take --${misplaced.join(", --")}`;
126
176
  if (positionals.length > 0) return `dns takes no file, got "${positionals.join(" ")}"`;
127
- return { kind: "dns", domain: values.domain, dkimSelectors: values["dkim-selector"], spfHosts: values["spf-host"] };
177
+ const expectDmarc = parseDmarcExpectation(values);
178
+ if (typeof expectDmarc === "string") return expectDmarc;
179
+ return {
180
+ kind: "dns",
181
+ domain: values.domain,
182
+ dkimSelectors: values["dkim-selector"],
183
+ spfHosts: values["spf-host"],
184
+ expectDmarc,
185
+ replyTo: values["reply-to"],
186
+ returnPathHosts: values["return-path"] ?? [],
187
+ resendReturnPath: values["resend-return-path"] === true,
188
+ };
128
189
  }
129
190
 
130
- const misplaced = (["domain", "dkim-selector", "spf-host"] as const).filter((option) => values[option] !== undefined);
191
+ if (name === "import") {
192
+ const misplaced = [...CAMPAIGN_ONLY_OPTIONS, ...DNS_ONLY_OPTIONS].filter((option) => values[option] !== undefined);
193
+ if (misplaced.length > 0) return `import does not take --${misplaced.join(", --")}`;
194
+ const [historyFile, ...extra] = positionals;
195
+ if (historyFile === undefined) return "import needs a history file (- for standard input)";
196
+ if (extra.length > 0) return `import takes one history file, got also "${extra.join(" ")}"`;
197
+ return { kind: "import", historyFile, dryRun: values["dry-run"] === true };
198
+ }
199
+
200
+ const misplaced = DNS_ONLY_OPTIONS.filter((option) => values[option] !== undefined);
131
201
  if (misplaced.length > 0) return `campaign does not take --${misplaced.join(", --")}`;
132
202
  const [contentFile, ...extra] = positionals;
133
203
  if (contentFile === undefined) return "campaign needs a content file";
134
204
  if (extra.length > 0) return `campaign takes one content file, got also "${extra.join(" ")}"`;
135
- if (values.recipients === undefined) return "campaign needs --recipients <file>";
205
+ if (contentFile === STDIN && values.recipients === STDIN) return "campaign can read only one of the content file and --recipients from standard input";
136
206
  const pauseMs = values["pause-ms"] === undefined ? DEFAULT_PAUSE_MS : Number(values["pause-ms"]);
137
207
  if (!Number.isInteger(pauseMs) || pauseMs < 0 || pauseMs > 60_000) return `--pause-ms expects whole milliseconds from 0 to 60000, got "${values["pause-ms"] ?? ""}"`;
138
- return { kind: "campaign", contentFile, recipientsFile: values.recipients, dryRun: values["dry-run"] === true, pauseMs, resendUncertain: values["resend-uncertain"] === true };
208
+ return { kind: "campaign", contentFile, recipientsFile: values.recipients ?? null, dryRun: values["dry-run"] === true, pauseMs, resendUncertain: values["resend-uncertain"] === true };
139
209
  }
140
210
 
141
211
  function parseCommandArgs(args: readonly string[]) {
@@ -149,6 +219,13 @@ function parseCommandArgs(args: readonly string[]) {
149
219
  domain: { type: "string" },
150
220
  "dkim-selector": { type: "string", multiple: true },
151
221
  "spf-host": { type: "string", multiple: true },
222
+ "dmarc-policy": { type: "string" },
223
+ "dmarc-sp": { type: "string" },
224
+ "dmarc-adkim": { type: "string" },
225
+ "dmarc-aspf": { type: "string" },
226
+ "reply-to": { type: "string" },
227
+ "return-path": { type: "string", multiple: true },
228
+ "resend-return-path": { type: "boolean" },
152
229
  help: { type: "boolean" },
153
230
  },
154
231
  strict: true,
@@ -156,34 +233,82 @@ function parseCommandArgs(args: readonly string[]) {
156
233
  });
157
234
  }
158
235
 
236
+ type ParsedValues = ReturnType<typeof parseCommandArgs>["values"];
237
+
238
+ /** One choice of a fixed list, case-insensitive; `undefined` when the option was not given. */
239
+ 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
+ const given = values[option];
241
+ if (given === undefined) return { value: undefined };
242
+ const found = allowed.find((candidate) => candidate === given.trim().toLowerCase());
243
+ return found === undefined ? `--${option} expects ${allowed.join(" or ")}, got "${given}"` : { value: found };
244
+ }
245
+
246
+ function parseDmarcExpectation(values: ParsedValues): DmarcExpectation | undefined | string {
247
+ const policy = pickChoice(values, "dmarc-policy", DMARC_POLICIES);
248
+ if (typeof policy === "string") return policy;
249
+ const subdomainPolicy = pickChoice(values, "dmarc-sp", DMARC_POLICIES);
250
+ if (typeof subdomainPolicy === "string") return subdomainPolicy;
251
+ const adkim = pickChoice(values, "dmarc-adkim", DMARC_ALIGNMENTS);
252
+ if (typeof adkim === "string") return adkim;
253
+ const aspf = pickChoice(values, "dmarc-aspf", DMARC_ALIGNMENTS);
254
+ if (typeof aspf === "string") return aspf;
255
+ const expectation: DmarcExpectation = {
256
+ ...(policy.value === undefined ? {} : { policy: policy.value }),
257
+ ...(subdomainPolicy.value === undefined ? {} : { subdomainPolicy: subdomainPolicy.value }),
258
+ ...(adkim.value === undefined ? {} : { adkim: adkim.value }),
259
+ ...(aspf.value === undefined ? {} : { aspf: aspf.value }),
260
+ };
261
+ return Object.keys(expectation).length === 0 ? undefined : expectation;
262
+ }
263
+
159
264
  async function runDns(command: Extract<Command, { kind: "dns" }>, options: RunMailCliOptions, output: CliOutput): Promise<number> {
160
265
  let domain = command.domain;
266
+ let replyTo = command.replyTo;
161
267
  if (domain === undefined) {
162
268
  if (options.config === undefined) {
163
269
  output.error("softure-mail dns: pass --domain <domain>, or run it with the app's config to check the sender's domain");
164
270
  return EXIT_FAILED;
165
271
  }
166
- domain = getSenderDomain(getMailingOptions(options.config).from);
272
+ const mailing = getMailingOptions(options.config);
273
+ domain = getSenderDomain(mailing.from);
274
+ replyTo ??= mailing.replyTo;
167
275
  }
276
+ const returnPath: ReturnPathHost[] = [...(command.resendReturnPath ? resendReturnPath(domain) : []), ...command.returnPathHosts.map((host) => ({ host }))];
168
277
  const report = await checkSenderDns(domain, {
169
278
  ...(command.dkimSelectors === undefined ? {} : { dkimSelectors: command.dkimSelectors }),
170
279
  ...(command.spfHosts === undefined ? {} : { spfHosts: command.spfHosts }),
280
+ ...(command.expectDmarc === undefined ? {} : { expectDmarc: command.expectDmarc }),
281
+ ...(replyTo === undefined ? {} : { replyTo }),
282
+ returnPath,
171
283
  ...(options.resolveTxt === undefined ? {} : { resolveTxt: options.resolveTxt }),
284
+ ...(options.resolveMx === undefined ? {} : { resolveMx: options.resolveMx }),
285
+ ...(options.resolveCname === undefined ? {} : { resolveCname: options.resolveCname }),
172
286
  });
173
- const checks: [string, DnsCheck][] = [
287
+ const authentication: [string, DnsCheck][] = [
174
288
  ["SPF", report.spf],
175
289
  ["DKIM", report.dkim],
176
290
  ["DMARC", report.dmarc],
177
291
  ];
292
+ const reply: [string, DnsCheck][] = report.replyTo === null ? [] : [["REPLY", report.replyTo]];
293
+ const paths: [string, DnsCheck][] = report.returnPath.map((check) => ["PATH", check]);
178
294
  output.log(`sender domain ${report.domain}`);
179
- for (const [name, check] of checks) {
180
- const line = `${name.padEnd(6)}${check.status.padEnd(6)}${check.finding.padEnd(15)}${check.host}${check.record === null ? "" : ` ${check.record}`}`;
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}`}`;
181
297
  if (check.status === "fail") output.error(line);
182
298
  else output.log(line);
183
299
  }
184
- const failed = checks.filter(([, check]) => check.status === "fail").length;
185
- output.log(failed === 0 ? "receivers can authenticate mail from this domain" : `${String(failed)} check(s) failed: list mail from this domain will land in spam or be refused`);
186
- return failed === 0 ? EXIT_OK : EXIT_FAILED;
300
+ const countFailed = (checks: readonly [string, DnsCheck][]) => checks.filter(([, check]) => check.status === "fail").length;
301
+ const failedAuthentication = countFailed(authentication);
302
+ const failedReply = countFailed(reply);
303
+ const failedPaths = countFailed(paths);
304
+ if (failedAuthentication + failedReply + failedPaths === 0) {
305
+ output.log("receivers can authenticate mail from this domain");
306
+ return EXIT_OK;
307
+ }
308
+ if (failedAuthentication > 0) output.log(`${String(failedAuthentication)} check(s) failed: list mail from this domain will land in spam or be refused`);
309
+ if (failedReply > 0) output.log("the reply-to domain does not accept mail: replies will bounce");
310
+ if (failedPaths > 0) output.log(`${String(failedPaths)} return-path host(s) failed: the provider cannot use them for bounces`);
311
+ return EXIT_FAILED;
187
312
  }
188
313
 
189
314
  async function runCampaign(command: Extract<Command, { kind: "campaign" }>, options: RunMailCliOptions, output: CliOutput): Promise<number> {
@@ -193,9 +318,19 @@ async function runCampaign(command: Extract<Command, { kind: "campaign" }>, opti
193
318
  output.error("softure-mail campaign: needs the app's config");
194
319
  return EXIT_FAILED;
195
320
  }
196
- const loaded = await loadCampaign(resolve(cwd, command.contentFile), resolve(cwd, command.recipientsFile));
197
- if (typeof loaded === "string") {
198
- output.error(`softure-mail campaign: ${loaded}`);
321
+ const input = createInputReader(cwd, options.readStdin ?? readProcessStdin);
322
+ const content = await loadCampaignContent(command.contentFile, input);
323
+ if (typeof content === "string") {
324
+ output.error(`softure-mail campaign: ${content}`);
325
+ return EXIT_FAILED;
326
+ }
327
+ const listed = command.recipientsFile === null ? null : await loadRecipientFile(command.recipientsFile, input);
328
+ if (typeof listed === "string") {
329
+ output.error(`softure-mail campaign: ${listed}`);
330
+ return EXIT_FAILED;
331
+ }
332
+ if (listed === null && getMailingOptions(config).listCampaignRecipients === undefined) {
333
+ output.error("softure-mail campaign: pass --recipients <file>, or set listCampaignRecipients in the mailing options");
199
334
  return EXIT_FAILED;
200
335
  }
201
336
  if (!command.dryRun && readUnsubscribeSecrets(options.env ?? process.env).current === null) {
@@ -216,6 +351,7 @@ async function runCampaign(command: Extract<Command, { kind: "campaign" }>, opti
216
351
  }
217
352
  const ctx: DeliveryContext = { db: opened.handle.db, clock: systemClock, config };
218
353
  try {
354
+ const loaded: LoadedCampaign = { campaign: content, recipients: listed ?? (await collectRecipients(ctx, content)) };
219
355
  return command.dryRun
220
356
  ? await reportPlan(ctx, loaded, command.resendUncertain, output)
221
357
  : await reportSend(ctx, loaded, { pauseMs: command.pauseMs, sleep: options.sleep, retakeUncertain: command.resendUncertain }, output);
@@ -233,22 +369,152 @@ interface LoadedCampaign {
233
369
  readonly recipients: readonly string[];
234
370
  }
235
371
 
236
- async function loadCampaign(contentPath: string, recipientsPath: string): Promise<LoadedCampaign | string> {
237
- const source = await readText(contentPath);
238
- if (source === null) return `cannot read the content file ${contentPath}`;
239
- const parsed = parseCampaignFile(source);
240
- if (!parsed.ok) return `${contentPath} is not a campaign:\n ${parsed.problems.join("\n ")}`;
372
+ /** Reads a named file, or standard input for `-`; `name` is what messages call it. */
373
+ interface InputReader {
374
+ readonly read: (file: string) => Promise<{ readonly text: string | null; readonly name: string; readonly dir: string }>;
375
+ }
376
+
377
+ function createInputReader(cwd: string, readStdin: () => Promise<string>): InputReader {
378
+ return {
379
+ read: async (file) => {
380
+ if (file === STDIN) return { text: await readStdin(), name: "standard input", dir: cwd };
381
+ const path = resolve(cwd, file);
382
+ return { text: await readText(path), name: path, dir: dirname(path) };
383
+ },
384
+ };
385
+ }
386
+
387
+ async function loadCampaignContent(file: string, input: InputReader): Promise<CampaignContent | string> {
388
+ const source = await input.read(file);
389
+ if (source.text === null) return `cannot read the content file ${source.name}`;
390
+ const parsed = parseCampaignFile(source.text);
391
+ if (!parsed.ok) return `${source.name} is not a campaign:\n ${parsed.problems.join("\n ")}`;
241
392
  const { htmlPath, ...content } = parsed.value;
242
393
  let html: string | null = null;
243
394
  if (htmlPath !== null) {
244
- const htmlFile = resolve(dirname(contentPath), htmlPath);
395
+ // Next to the content file; for standard input, the working directory.
396
+ const htmlFile = resolve(source.dir, htmlPath);
245
397
  html = await readText(htmlFile);
246
398
  if (html === null) return `cannot read the HTML body ${htmlFile}`;
247
399
  if (html.trim() === "") return `the HTML body ${htmlFile} is empty`;
248
400
  }
249
- const list = await readText(recipientsPath);
250
- if (list === null) return `cannot read the recipients file ${recipientsPath}`;
251
- return { campaign: { ...content, html }, recipients: parseRecipientList(list) };
401
+ return { ...content, html };
402
+ }
403
+
404
+ async function loadRecipientFile(file: string, input: InputReader): Promise<readonly string[] | string> {
405
+ const list = await input.read(file);
406
+ if (list.text === null) return `cannot read the recipients file ${list.name}`;
407
+ return parseRecipientList(list.text);
408
+ }
409
+
410
+ /** The recipients from `listCampaignRecipients`, which the caller checked is set. */
411
+ async function collectRecipients(ctx: DeliveryContext, campaign: CampaignContent): Promise<readonly string[]> {
412
+ const recipients: string[] = [];
413
+ const source = await listConfiguredCampaignRecipients(ctx, campaign);
414
+ if (source === null) return recipients;
415
+ for await (const address of source) recipients.push(address);
416
+ return recipients;
417
+ }
418
+
419
+ /** One line of a history file. Unknown keys are refused, so a typo does not drop a field. */
420
+ const historyRowSchema = z.strictObject({
421
+ scope: z.string(),
422
+ address: z.string(),
423
+ status: z.enum(["sent", "rejected"]),
424
+ finishedAt: z.string(),
425
+ providerMessageId: z.string().optional(),
426
+ reason: z.enum(["mailing.invalid_input", "mailing.rejected", "mailing.unavailable", "mailing.suppressed"]).optional(),
427
+ kind: z.string().optional(),
428
+ });
429
+
430
+ async function runImport(command: Extract<Command, { kind: "import" }>, options: RunMailCliOptions, output: CliOutput): Promise<number> {
431
+ const config = options.config;
432
+ if (config === undefined) {
433
+ output.error("softure-mail import: needs the app's config");
434
+ return EXIT_FAILED;
435
+ }
436
+ const source = await createInputReader(options.cwd ?? process.cwd(), options.readStdin ?? readProcessStdin).read(command.historyFile);
437
+ if (source.text === null) {
438
+ output.error(`softure-mail import: cannot read the history file ${source.name}`);
439
+ return EXIT_FAILED;
440
+ }
441
+ const parsed = parseHistory(source.text);
442
+ if (parsed.problems.length > 0) return reportImportProblems(parsed.problems, output);
443
+ const rows = parsed.rows.map((row) => row.delivery);
444
+ const lineOf = (problem: ImportProblem) => parsed.rows[problem.index]?.line ?? 0;
445
+
446
+ // Checked before the database is opened, so a bad file costs no connection.
447
+ const checked = checkImportedDeliveries({ clock: systemClock }, rows);
448
+ if (!checked.ok) return reportImportProblems(checked.problems.map((problem) => ({ line: lineOf(problem), problem: problem.problem })), output);
449
+ if (command.dryRun) {
450
+ output.log(`import, dry run: nothing written; rows ${String(rows.length)}, duplicates ${String(checked.value.duplicates)}`);
451
+ return EXIT_OK;
452
+ }
453
+ if (config.database === null) {
454
+ output.error("softure-mail import: the config has no database; set database.url in softure.config");
455
+ return EXIT_FAILED;
456
+ }
457
+ let opened: CommandDatabase;
458
+ try {
459
+ opened = await openDatabase(config.database, options.openDatabase);
460
+ } catch (error) {
461
+ output.error(`softure-mail import: ${describeError(error)}`);
462
+ return EXIT_FAILED;
463
+ }
464
+ try {
465
+ const result = await importDeliveries({ db: opened.handle.db, clock: systemClock, config }, rows);
466
+ if (!result.ok) return reportImportProblems(result.problems.map((problem) => ({ line: lineOf(problem), problem: problem.problem })), output);
467
+ const summary = result.value;
468
+ output.log(`import: rows ${String(summary.rows)}, imported ${String(summary.imported)}, already present ${String(summary.alreadyPresent)}, duplicates ${String(summary.duplicates)}`);
469
+ return EXIT_OK;
470
+ } catch (error) {
471
+ output.error(`softure-mail import: ${describeError(error)} (did softure migrate run?)`);
472
+ return EXIT_FAILED;
473
+ } finally {
474
+ await opened.close();
475
+ }
476
+ }
477
+
478
+ interface LineProblem {
479
+ readonly line: number;
480
+ readonly problem: string;
481
+ }
482
+
483
+ /** The history file's rows with their line numbers; problems name lines and fields, never values. */
484
+ function parseHistory(text: string): { readonly rows: readonly { readonly line: number; readonly delivery: ImportedDelivery }[]; readonly problems: readonly LineProblem[] } {
485
+ const rows: { line: number; delivery: ImportedDelivery }[] = [];
486
+ const problems: LineProblem[] = [];
487
+ text.split(/\r?\n/).forEach((source, index) => {
488
+ const line = index + 1;
489
+ if (source.trim() === "") return;
490
+ let value: unknown;
491
+ try {
492
+ value = JSON.parse(source);
493
+ } catch {
494
+ problems.push({ line, problem: "not a JSON object" });
495
+ return;
496
+ }
497
+ const parsed = historyRowSchema.safeParse(value);
498
+ if (!parsed.success) {
499
+ for (const issue of parsed.error.issues) {
500
+ const field = issue.path.length === 0 ? "row" : issue.path.join(".");
501
+ problems.push({ line, problem: issue.code === "unrecognized_keys" ? `unknown field(s) ${issue.keys.join(", ")}` : `${field}: ${issue.message}` });
502
+ }
503
+ return;
504
+ }
505
+ const { providerMessageId, reason, kind, ...required } = parsed.data;
506
+ rows.push({
507
+ line,
508
+ delivery: { ...required, ...(providerMessageId === undefined ? {} : { providerMessageId }), ...(reason === undefined ? {} : { reason }), ...(kind === undefined ? {} : { kind }) },
509
+ });
510
+ });
511
+ return { rows, problems };
512
+ }
513
+
514
+ function reportImportProblems(problems: readonly LineProblem[], output: CliOutput): number {
515
+ output.error(`softure-mail import: nothing written, ${String(problems.length)} problem(s):`);
516
+ for (const { line, problem } of problems) output.error(` line ${String(line)}: ${problem}`);
517
+ return EXIT_FAILED;
252
518
  }
253
519
 
254
520
  async function reportPlan(ctx: DeliveryContext, loaded: LoadedCampaign, retakeUncertain: boolean, output: CliOutput): Promise<number> {
@@ -341,6 +607,12 @@ async function openDatabase(database: SoftureDatabaseConfig, open: ((url: string
341
607
  return { handle, close: handle.close };
342
608
  }
343
609
 
610
+ async function readProcessStdin(): Promise<string> {
611
+ const chunks: Buffer[] = [];
612
+ for await (const chunk of process.stdin) chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : (chunk as Buffer));
613
+ return Buffer.concat(chunks).toString("utf8");
614
+ }
615
+
344
616
  async function readText(path: string): Promise<string | null> {
345
617
  try {
346
618
  return await readFile(path, "utf8");
package/src/contract.ts CHANGED
@@ -32,11 +32,18 @@ export const TRANSACTIONAL_KIND = "transactional";
32
32
  /** How an opt-out arrived: a mail client's one-click POST, the page's button, or an operator. */
33
33
  export type SuppressionSource = "one-click" | "page" | "operator";
34
34
 
35
- /** What `onUnsubscribed` receives: who opted out (never the address) and how. */
35
+ /**
36
+ * The link an unsubscribe came through, once verified: the module's signed link, or a legacy link with the values
37
+ * the app's `verify` accepted (only the names the link carried), so a hook can find the row that link named.
38
+ */
39
+ export type VerifiedUnsubscribeLink = { readonly scheme: "signed" } | { readonly scheme: "legacy"; readonly values: Readonly<Record<string, string>> };
40
+
41
+ /** What `onUnsubscribed` receives: who opted out (never the address), how, and through which link. */
36
42
  export interface UnsubscribeEvent {
37
43
  /** The recipient key of the link: privacy's email key of the same address (`getEmailKey`). */
38
44
  readonly recipientKey: string;
39
45
  readonly source: Exclude<SuppressionSource, "operator">;
46
+ readonly link: VerifiedUnsubscribeLink;
40
47
  }
41
48
 
42
49
  /**
@@ -61,14 +68,32 @@ export interface CampaignRecipient {
61
68
  */
62
69
  export type CampaignRecipientFilter = (recipient: CampaignRecipient, ctx: ModuleContext<Queryable>) => Promise<boolean>;
63
70
 
71
+ /**
72
+ * Lists the addresses a campaign goes to, from the app's own data (e.g. the people who opted in to its kind), so a
73
+ * campaign can run where the database is without a recipients file. Duplicates are fine; `filterCampaignRecipient`
74
+ * and the suppression list still apply. A throw stops the campaign. Set in `mailing({ listCampaignRecipients })`.
75
+ */
76
+ export type CampaignRecipientSource = (
77
+ campaign: { readonly id: string; readonly kind: string },
78
+ ctx: ModuleContext<Queryable>,
79
+ ) => Iterable<string> | AsyncIterable<string> | Promise<Iterable<string>>;
80
+
81
+ /**
82
+ * The query names of a legacy link. An array lists names the link must all carry. The object form adds names it
83
+ * may carry: a link is legacy when it has every `required` name, and the `optional` ones it has go to `verify` too.
84
+ * At most 8 names in all, none repeated, never `r` or `status`.
85
+ */
86
+ export type LegacyUnsubscribeParams = readonly string[] | { readonly required: readonly string[]; readonly optional?: readonly string[] };
87
+
64
88
  /**
65
89
  * Unsubscribe links an app sent before it adopted the module, in its own scheme. `params` are the query names of
66
- * such a link; `verify` receives their values (all present once, each at most 512 characters) and returns the
67
- * recipient's address when the link is genuine, else `null`. It must check the link's signature itself: whatever
68
- * address it returns is unsubscribed. A throw reads as a failure (the person may try again), not as a bad link.
90
+ * such a link; `verify` receives the values the link carried (every required name, the optional names that are
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.
69
94
  */
70
95
  export interface LegacyUnsubscribe {
71
- readonly params: readonly string[];
96
+ readonly params: LegacyUnsubscribeParams;
72
97
  readonly verify: (values: Readonly<Record<string, string>>, ctx: ModuleContext<Queryable>) => Promise<string | null>;
73
98
  }
74
99
 
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.7",
20
+ version: "0.1.9",
21
21
  dependsOn: {},
22
22
  dbSchema: "mailing",
23
23
  tables: ["suppressions", "campaigns", "deliveries"],
@@ -68,7 +68,9 @@ export { HALTING_ERROR_CODES, TRANSACTIONAL_KIND } from "./contract.js";
68
68
  export type {
69
69
  CampaignRecipient,
70
70
  CampaignRecipientFilter,
71
+ CampaignRecipientSource,
71
72
  LegacyUnsubscribe,
73
+ LegacyUnsubscribeParams,
72
74
  MailingErrorCode,
73
75
  MailProvider,
74
76
  OnUnsubscribedHook,
@@ -83,6 +85,7 @@ export type {
83
85
  SuppressionSource,
84
86
  UnsubscribeErrorCode,
85
87
  UnsubscribeEvent,
88
+ VerifiedUnsubscribeLink,
86
89
  } from "./contract.js";
87
90
  export { getMailingErrorMessage, mailingMessages, type MailingMessages } from "./messages/index.js";
88
91
  export { DEFAULT_STALE_CLAIM_MS, DEFAULT_TIMEOUT_MS, DEFAULT_UNCERTAIN_CLAIM_MS, type MailingOptions, type MailingOptionsInput } from "./options.js";
@@ -0,0 +1,9 @@
1
+ // `importDeliveries` on the app's registered configuration and the shared database, e.g. for a one-off route or
2
+ // script that seeds the ledger from the app's own history.
3
+ import { importDeliveries as importDeliveriesWithContext, type ImportedDelivery, type ImportResult } from "../server/import-deliveries.js";
4
+ import { getMailingContext } from "./context.js";
5
+
6
+ /** Seeds the delivery ledger; see `importDeliveries` in `@softure-ai/mailing/server`. */
7
+ export async function importDeliveries(rows: readonly ImportedDelivery[]): Promise<ImportResult> {
8
+ return importDeliveriesWithContext(await getMailingContext(), rows);
9
+ }
package/src/next/index.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  // (docs/02-module-standard.md §8).
4
4
  export { unsubscribeAction } from "./actions.js";
5
5
  export { deliverOnce } from "./deliver-once.js";
6
+ export { importDeliveries } from "./import-deliveries.js";
6
7
  export { getMailingMessages } from "./messages.js";
7
8
  export { UnsubscribePage, type UnsubscribePageProps } from "./pages.js";
8
9
  export { UNSUBSCRIBE_STATUS_PARAM, type UnsubscribeStatus } from "./params.js";
package/src/next/route.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  // the only write is an idempotent insert.
11
11
  import { errorLogLabel } from "@softure-ai/core";
12
12
  import { getSoftureConfig } from "@softure-ai/core/next";
13
- import { getMailingRoutes } from "../server/options.js";
13
+ import { getMailingOptions, getMailingRoutes } from "../server/options.js";
14
14
  import { unsubscribe } from "../server/suppressions.js";
15
15
  import { readUnsubscribeLink } from "../server/unsubscribe-link.js";
16
16
  import { getMailingContext } from "./context.js";
@@ -20,8 +20,9 @@ const NO_STORE = { "cache-control": "no-store" };
20
20
  /**
21
21
  * Records the opt-out of the link in the URL. The body (`List-Unsubscribe=One-Click`) is not read:
22
22
  * anyone can send it, the signature is the proof. 200 once recorded (also again), 400 for a link
23
- * that does not verify, 500 when the database fails: the client may retry, and 200 would claim an
24
- * opt-out that did not happen. A legacy link (`mailing({ legacyUnsubscribe })`) is handled the same
23
+ * that does not verify (200 with `mailing({ oneClickInvalidLinkStatus: 200 })`, so the answer never
24
+ * tells whether a token is live), 500 when the database fails: the client may retry, and 200 would
25
+ * claim an opt-out that did not happen. A legacy link (`mailing({ legacyUnsubscribe })`) is handled the same
25
26
  * way; mount this route at the old link's path too.
26
27
  */
27
28
  export async function postUnsubscribeRoute(request: Request): Promise<Response> {
@@ -29,7 +30,7 @@ export async function postUnsubscribeRoute(request: Request): Promise<Response>
29
30
  const ctx = await getMailingContext();
30
31
  const link = readUnsubscribeLink(new URL(request.url).searchParams, ctx.config);
31
32
  const result = await unsubscribe(ctx, link, "one-click");
32
- return new Response(null, { status: result.ok ? 200 : 400, headers: NO_STORE });
33
+ return new Response(null, { status: result.ok ? 200 : getMailingOptions(ctx.config).oneClickInvalidLinkStatus, headers: NO_STORE });
33
34
  } catch (error) {
34
35
  console.error(`@softure-ai/mailing: one-click unsubscribe failed: ${errorLogLabel(error)}`);
35
36
  return new Response(null, { status: 500, headers: NO_STORE });