@softure-ai/mailing 0.1.11 → 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.
- package/CHANGELOG.md +32 -0
- package/README.md +173 -6
- package/dist/cli/run.d.ts +1 -1
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +176 -24
- package/dist/cli/run.js.map +1 -1
- package/dist/contract.d.ts +5 -3
- package/dist/contract.d.ts.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/next/index.d.ts +3 -1
- package/dist/next/index.d.ts.map +1 -1
- package/dist/next/index.js +3 -1
- package/dist/next/index.js.map +1 -1
- package/dist/next/pages.d.ts +25 -0
- package/dist/next/pages.d.ts.map +1 -1
- package/dist/next/pages.js +33 -10
- package/dist/next/pages.js.map +1 -1
- package/dist/next/preview-mail.d.ts +5 -0
- package/dist/next/preview-mail.d.ts.map +1 -0
- package/dist/next/preview-mail.js +8 -0
- package/dist/next/preview-mail.js.map +1 -0
- package/dist/next/run-deliveries.d.ts +4 -0
- package/dist/next/run-deliveries.d.ts.map +1 -0
- package/dist/next/run-deliveries.js +7 -0
- package/dist/next/run-deliveries.js.map +1 -0
- package/dist/options.d.ts +3 -1
- package/dist/options.d.ts.map +1 -1
- package/dist/options.js +16 -1
- package/dist/options.js.map +1 -1
- package/dist/server/campaign-file.d.ts +7 -2
- package/dist/server/campaign-file.d.ts.map +1 -1
- package/dist/server/campaign-file.js +35 -15
- package/dist/server/campaign-file.js.map +1 -1
- package/dist/server/campaigns.d.ts.map +1 -1
- package/dist/server/campaigns.js +4 -4
- package/dist/server/campaigns.js.map +1 -1
- package/dist/server/deliveries.d.ts +19 -0
- package/dist/server/deliveries.d.ts.map +1 -1
- package/dist/server/deliveries.js +29 -0
- package/dist/server/deliveries.js.map +1 -1
- package/dist/server/dns.d.ts +15 -1
- package/dist/server/dns.d.ts.map +1 -1
- package/dist/server/dns.js +45 -4
- package/dist/server/dns.js.map +1 -1
- package/dist/server/index.d.ts +6 -4
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +6 -4
- package/dist/server/index.js.map +1 -1
- package/dist/server/operator-output.d.ts +5 -0
- package/dist/server/operator-output.d.ts.map +1 -0
- package/dist/server/operator-output.js +19 -0
- package/dist/server/operator-output.js.map +1 -0
- package/dist/server/options.d.ts +2 -0
- package/dist/server/options.d.ts.map +1 -1
- package/dist/server/options.js +5 -0
- package/dist/server/options.js.map +1 -1
- package/dist/server/run-deliveries.d.ts +55 -0
- package/dist/server/run-deliveries.d.ts.map +1 -0
- package/dist/server/run-deliveries.js +132 -0
- package/dist/server/run-deliveries.js.map +1 -0
- package/dist/server/send-mail.d.ts +21 -2
- package/dist/server/send-mail.d.ts.map +1 -1
- package/dist/server/send-mail.js +50 -30
- package/dist/server/send-mail.js.map +1 -1
- package/dist/server/suppressions.d.ts +5 -0
- package/dist/server/suppressions.d.ts.map +1 -1
- package/dist/server/suppressions.js +27 -1
- package/dist/server/suppressions.js.map +1 -1
- package/migrations/0005_add_sql_surface.sql +44 -0
- package/module.json +1 -1
- package/package.json +1 -1
- package/src/cli/run.ts +179 -24
- package/src/contract.ts +9 -3
- package/src/index.ts +1 -1
- package/src/next/index.ts +3 -1
- package/src/next/pages.tsx +69 -14
- package/src/next/preview-mail.ts +9 -0
- package/src/next/run-deliveries.ts +10 -0
- package/src/options.ts +18 -1
- package/src/server/campaign-file.ts +36 -2
- package/src/server/campaigns.ts +5 -5
- package/src/server/deliveries.ts +33 -0
- package/src/server/dns.ts +60 -4
- package/src/server/index.ts +7 -3
- package/src/server/operator-output.ts +20 -0
- package/src/server/options.ts +6 -0
- package/src/server/run-deliveries.ts +184 -0
- package/src/server/send-mail.ts +63 -37
- 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,
|
|
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 {
|
|
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 {
|
|
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 (
|
|
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
|
|
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
|
|
209
|
-
if (contentFile ===
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
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.
|
|
93
|
-
*
|
|
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: (
|
|
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
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";
|
package/src/next/pages.tsx
CHANGED
|
@@ -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
|
-
|
|
23
|
-
|
|
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 }: {
|
|
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={
|
|
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={
|
|
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
|
+
}
|
package/src/options.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// The options an app passes to `mailing({ ... })` in softure.config.ts, parsed at startup.
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import { isMailbox, isSingleAddress } from "./address.js";
|
|
3
|
+
import { isMailbox, isMailKind, isSingleAddress } from "./address.js";
|
|
4
4
|
import type { CampaignRecipientFilter, CampaignRecipientSource, LegacyUnsubscribe, MailProvider, OnUnsubscribedHook } from "./contract.js";
|
|
5
5
|
|
|
6
6
|
export const DEFAULT_TIMEOUT_MS = 10_000;
|
|
@@ -42,6 +42,13 @@ const legacyUnsubscribeSchema = z.strictObject({
|
|
|
42
42
|
|
|
43
43
|
const HOUR_MS = 60 * 60_000;
|
|
44
44
|
|
|
45
|
+
const mailKind = z.string().refine(isMailKind, "must be a kebab-case mail kind such as newsletter");
|
|
46
|
+
|
|
47
|
+
/** Alias to kind; an alias never names a kind another alias points at, so one lookup resolves it. */
|
|
48
|
+
const kindAliasesSchema = z
|
|
49
|
+
.record(mailKind, mailKind)
|
|
50
|
+
.refine((aliases) => Object.keys(aliases).every((alias) => !Object.values(aliases).includes(alias)), "must not use a kind as an alias: aliases resolve in one step");
|
|
51
|
+
|
|
45
52
|
function isMailProvider(value: unknown): value is MailProvider {
|
|
46
53
|
if (typeof value !== "object" || value === null) return false;
|
|
47
54
|
const candidate = value as { name?: unknown; send?: unknown };
|
|
@@ -56,6 +63,16 @@ export const mailingOptionsSchema = z.strictObject({
|
|
|
56
63
|
from: z.string().trim().refine(isMailbox, "must be an address or Name <address>, e.g. Plan <hello@example.com>"),
|
|
57
64
|
/** Where replies go; may sit on another domain than `from` (DMARC does not check it). */
|
|
58
65
|
replyTo: z.string().trim().refine(isSingleAddress, "must be one address, e.g. support@example.com").optional(),
|
|
66
|
+
/**
|
|
67
|
+
* The only address `softure-mail test` sends to, e.g. the operator's inbox. Without it the command refuses to send,
|
|
68
|
+
* so a test never reaches a customer.
|
|
69
|
+
*/
|
|
70
|
+
testAddress: z.string().trim().refine(isSingleAddress, "must be one address, e.g. ops@example.com").optional(),
|
|
71
|
+
/**
|
|
72
|
+
* Short names for mail kinds in campaign files and `softure-mail test --kind`, e.g. `{ news: "newsletter" }`. The
|
|
73
|
+
* campaign is stored and sent under the kind the alias names.
|
|
74
|
+
*/
|
|
75
|
+
kindAliases: kindAliasesSchema.optional(),
|
|
59
76
|
/** The adapter that delivers mail: `resend()`, or `fakeMailProvider()` from `/testing`. */
|
|
60
77
|
provider: z.custom<MailProvider>(isMailProvider, "must be a mail provider such as resend()"),
|
|
61
78
|
/** How long one send may take before it reads as `mailing.unavailable`. */
|