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