@softure-ai/mailing 0.0.0-stage → 0.1.6
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 +9 -0
- package/LICENSE +21 -0
- package/README.md +346 -2
- package/dist/address.d.ts +27 -0
- package/dist/address.d.ts.map +1 -0
- package/dist/address.js +76 -0
- package/dist/address.js.map +1 -0
- package/dist/cli/bin.d.ts +3 -0
- package/dist/cli/bin.d.ts.map +1 -0
- package/dist/cli/bin.js +5 -0
- package/dist/cli/bin.js.map +1 -0
- package/dist/cli/command.d.ts +9 -0
- package/dist/cli/command.d.ts.map +1 -0
- package/dist/cli/command.js +36 -0
- package/dist/cli/command.js.map +1 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +4 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/run.d.ts +31 -0
- package/dist/cli/run.d.ts.map +1 -0
- package/dist/cli/run.js +288 -0
- package/dist/cli/run.js.map +1 -0
- package/dist/contract.d.ts +102 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +3 -0
- package/dist/contract.js.map +1 -0
- package/dist/index.d.ts +47 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +59 -0
- package/dist/index.js.map +1 -0
- package/dist/messages/en.d.ts +26 -0
- package/dist/messages/en.d.ts.map +1 -0
- package/dist/messages/en.js +26 -0
- package/dist/messages/en.js.map +1 -0
- package/dist/messages/index.d.ts +59 -0
- package/dist/messages/index.d.ts.map +1 -0
- package/dist/messages/index.js +11 -0
- package/dist/messages/index.js.map +1 -0
- package/dist/messages/pl.d.ts +3 -0
- package/dist/messages/pl.d.ts.map +1 -0
- package/dist/messages/pl.js +27 -0
- package/dist/messages/pl.js.map +1 -0
- package/dist/next/actions.d.ts +2 -0
- package/dist/next/actions.d.ts.map +1 -0
- package/dist/next/actions.js +35 -0
- package/dist/next/actions.js.map +1 -0
- package/dist/next/context.d.ts +4 -0
- package/dist/next/context.d.ts.map +1 -0
- package/dist/next/context.js +14 -0
- package/dist/next/context.js.map +1 -0
- package/dist/next/deliver-once.d.ts +4 -0
- package/dist/next/deliver-once.d.ts.map +1 -0
- package/dist/next/deliver-once.js +7 -0
- package/dist/next/deliver-once.js.map +1 -0
- package/dist/next/index.d.ts +8 -0
- package/dist/next/index.d.ts.map +1 -0
- package/dist/next/index.js +11 -0
- package/dist/next/index.js.map +1 -0
- package/dist/next/messages.d.ts +5 -0
- package/dist/next/messages.d.ts.map +1 -0
- package/dist/next/messages.js +7 -0
- package/dist/next/messages.js.map +1 -0
- package/dist/next/pages.d.ts +7 -0
- package/dist/next/pages.d.ts.map +1 -0
- package/dist/next/pages.js +40 -0
- package/dist/next/pages.js.map +1 -0
- package/dist/next/params.d.ts +4 -0
- package/dist/next/params.d.ts.map +1 -0
- package/dist/next/params.js +4 -0
- package/dist/next/params.js.map +1 -0
- package/dist/next/route.d.ts +14 -0
- package/dist/next/route.d.ts.map +1 -0
- package/dist/next/route.js +44 -0
- package/dist/next/route.js.map +1 -0
- package/dist/next/send-mail.d.ts +4 -0
- package/dist/next/send-mail.d.ts.map +1 -0
- package/dist/next/send-mail.js +16 -0
- package/dist/next/send-mail.js.map +1 -0
- package/dist/options.d.ts +13 -0
- package/dist/options.d.ts.map +1 -0
- package/dist/options.js +29 -0
- package/dist/options.js.map +1 -0
- package/dist/providers/resend.d.ts +17 -0
- package/dist/providers/resend.d.ts.map +1 -0
- package/dist/providers/resend.js +64 -0
- package/dist/providers/resend.js.map +1 -0
- package/dist/schema.d.ts +350 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +30 -0
- package/dist/schema.js.map +1 -0
- package/dist/server/campaign-file.d.ts +28 -0
- package/dist/server/campaign-file.d.ts.map +1 -0
- package/dist/server/campaign-file.js +87 -0
- package/dist/server/campaign-file.js.map +1 -0
- package/dist/server/campaigns.d.ts +58 -0
- package/dist/server/campaigns.d.ts.map +1 -0
- package/dist/server/campaigns.js +118 -0
- package/dist/server/campaigns.js.map +1 -0
- package/dist/server/deliveries.d.ts +56 -0
- package/dist/server/deliveries.d.ts.map +1 -0
- package/dist/server/deliveries.js +100 -0
- package/dist/server/deliveries.js.map +1 -0
- package/dist/server/dns.d.ts +33 -0
- package/dist/server/dns.d.ts.map +1 -0
- package/dist/server/dns.js +113 -0
- package/dist/server/dns.js.map +1 -0
- package/dist/server/health.d.ts +3 -0
- package/dist/server/health.d.ts.map +1 -0
- package/dist/server/health.js +13 -0
- package/dist/server/health.js.map +1 -0
- package/dist/server/index.d.ts +12 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +14 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/list-mail.d.ts +20 -0
- package/dist/server/list-mail.d.ts.map +1 -0
- package/dist/server/list-mail.js +31 -0
- package/dist/server/list-mail.js.map +1 -0
- package/dist/server/options.d.ts +14 -0
- package/dist/server/options.d.ts.map +1 -0
- package/dist/server/options.js +22 -0
- package/dist/server/options.js.map +1 -0
- package/dist/server/send-mail.d.ts +19 -0
- package/dist/server/send-mail.d.ts.map +1 -0
- package/dist/server/send-mail.js +129 -0
- package/dist/server/send-mail.js.map +1 -0
- package/dist/server/suppressions.d.ts +27 -0
- package/dist/server/suppressions.d.ts.map +1 -0
- package/dist/server/suppressions.js +64 -0
- package/dist/server/suppressions.js.map +1 -0
- package/dist/server/unsubscribe-link.d.ts +51 -0
- package/dist/server/unsubscribe-link.d.ts.map +1 -0
- package/dist/server/unsubscribe-link.js +78 -0
- package/dist/server/unsubscribe-link.js.map +1 -0
- package/dist/server/validate-mail.d.ts +23 -0
- package/dist/server/validate-mail.d.ts.map +1 -0
- package/dist/server/validate-mail.js +52 -0
- package/dist/server/validate-mail.js.map +1 -0
- package/dist/testing/fake-provider.d.ts +24 -0
- package/dist/testing/fake-provider.d.ts.map +1 -0
- package/dist/testing/fake-provider.js +43 -0
- package/dist/testing/fake-provider.js.map +1 -0
- package/dist/testing/index.d.ts +3 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +5 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/testing/outbox.d.ts +6 -0
- package/dist/testing/outbox.d.ts.map +1 -0
- package/dist/testing/outbox.js +32 -0
- package/dist/testing/outbox.js.map +1 -0
- package/migrations/0001_create_suppressions.sql +13 -0
- package/migrations/0002_create_campaigns_and_deliveries.sql +41 -0
- package/module.json +32 -0
- package/package.json +74 -4
- package/src/address.ts +89 -0
- package/src/cli/bin.ts +5 -0
- package/src/cli/command.ts +44 -0
- package/src/cli/index.ts +3 -0
- package/src/cli/run.ts +317 -0
- package/src/contract.ts +108 -0
- package/src/index.ts +85 -0
- package/src/messages/en.ts +25 -0
- package/src/messages/index.ts +15 -0
- package/src/messages/pl.ts +28 -0
- package/src/next/actions.ts +35 -0
- package/src/next/context.ts +15 -0
- package/src/next/deliver-once.ts +10 -0
- package/src/next/index.ts +10 -0
- package/src/next/messages.ts +9 -0
- package/src/next/next-modules.d.ts +12 -0
- package/src/next/pages.tsx +69 -0
- package/src/next/params.ts +6 -0
- package/src/next/route.ts +45 -0
- package/src/next/send-mail.ts +15 -0
- package/src/options.ts +34 -0
- package/src/providers/resend.ts +86 -0
- package/src/schema.ts +40 -0
- package/src/server/campaign-file.ts +101 -0
- package/src/server/campaigns.ts +175 -0
- package/src/server/deliveries.ts +155 -0
- package/src/server/dns.ts +153 -0
- package/src/server/health.ts +14 -0
- package/src/server/index.ts +56 -0
- package/src/server/list-mail.ts +42 -0
- package/src/server/options.ts +32 -0
- package/src/server/send-mail.ts +152 -0
- package/src/server/suppressions.ts +77 -0
- package/src/server/unsubscribe-link.ts +109 -0
- package/src/server/validate-mail.ts +68 -0
- package/src/testing/fake-provider.ts +66 -0
- package/src/testing/index.ts +4 -0
- package/src/testing/outbox.ts +32 -0
package/src/cli/run.ts
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
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:
|
|
4
|
+
//
|
|
5
|
+
// // scripts/mail.ts
|
|
6
|
+
// import { runMailCli } from "@softure-ai/mailing/cli";
|
|
7
|
+
// import config from "../softure.config";
|
|
8
|
+
// process.exitCode = await runMailCli({ config, argv: process.argv.slice(2) });
|
|
9
|
+
import { readFile } from "node:fs/promises";
|
|
10
|
+
import { dirname, resolve } from "node:path";
|
|
11
|
+
import { parseArgs } from "node:util";
|
|
12
|
+
import { systemClock, type SoftureConfig, type SoftureDatabaseConfig } from "@softure-ai/core";
|
|
13
|
+
import { openCommandDatabase, type CommandDatabase, type DatabaseHandle } from "@softure-ai/db";
|
|
14
|
+
import { parseCampaignFile, parseRecipientList, type CampaignContent } from "../server/campaign-file.js";
|
|
15
|
+
import { planCampaign, sendCampaign, type CampaignSummary } from "../server/campaigns.js";
|
|
16
|
+
import type { DeliveryContext } from "../server/deliveries.js";
|
|
17
|
+
import { checkSenderDns, getSenderDomain, type DnsCheck, type ResolveTxt } from "../server/dns.js";
|
|
18
|
+
import { getMailingOptions } from "../server/options.js";
|
|
19
|
+
import { MIN_UNSUBSCRIBE_SECRET_LENGTH, readUnsubscribeSecrets, UNSUBSCRIBE_SECRET_ENV, type Env } from "../server/unsubscribe-link.js";
|
|
20
|
+
|
|
21
|
+
export interface CliOutput {
|
|
22
|
+
readonly log: (line: string) => void;
|
|
23
|
+
readonly error: (line: string) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface RunMailCliOptions {
|
|
27
|
+
/** The app's config. Needed by `campaign`, and by `dns` without `--domain`. */
|
|
28
|
+
readonly config?: SoftureConfig;
|
|
29
|
+
/** The arguments after the executable, e.g. `["campaign", "launch.md", "--recipients", "list.txt"]`. */
|
|
30
|
+
readonly argv: readonly string[];
|
|
31
|
+
/** Relative paths resolve against it. Default: `process.cwd()`. */
|
|
32
|
+
readonly cwd?: string;
|
|
33
|
+
readonly output?: CliOutput;
|
|
34
|
+
/** Opens the campaign's database connection. Default: the config's `database.handle`, else `createDatabase(url, { max: 1 })`. */
|
|
35
|
+
readonly openDatabase?: (url: string) => Promise<DatabaseHandle>;
|
|
36
|
+
readonly resolveTxt?: ResolveTxt;
|
|
37
|
+
readonly sleep?: (ms: number) => Promise<void>;
|
|
38
|
+
readonly env?: Env;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const EXIT_OK = 0;
|
|
42
|
+
export const EXIT_FAILED = 1;
|
|
43
|
+
export const EXIT_USAGE = 2;
|
|
44
|
+
|
|
45
|
+
/** Pause between sends: Resend's default limit is two requests per second. */
|
|
46
|
+
export const DEFAULT_PAUSE_MS = 500;
|
|
47
|
+
|
|
48
|
+
export const MAIL_USAGE = `Usage:
|
|
49
|
+
softure-mail campaign <content-file> --recipients <file> [--dry-run] [--pause-ms <ms>]
|
|
50
|
+
softure-mail dns [--domain <domain>] [--dkim-selector <name>]... [--spf-host <host>]...
|
|
51
|
+
|
|
52
|
+
campaign Sends the campaign in <content-file> (frontmatter id, kind, subject, optional html,
|
|
53
|
+
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.
|
|
55
|
+
--dry-run count what would be sent and change nothing
|
|
56
|
+
--pause-ms <ms> pause after every send (default ${String(DEFAULT_PAUSE_MS)})
|
|
57
|
+
|
|
58
|
+
dns Checks SPF, DKIM and DMARC for the sender domain (default: the domain of "from").
|
|
59
|
+
--domain <domain> check this domain instead
|
|
60
|
+
--dkim-selector <name> DKIM selector to look up (default resend; repeatable)
|
|
61
|
+
--spf-host <host> where SPF is published (default the domain; repeatable;
|
|
62
|
+
Resend publishes it on send.<domain>)
|
|
63
|
+
|
|
64
|
+
Options for both:
|
|
65
|
+
--config <file> the app's softure.config file (bin only)
|
|
66
|
+
--help show this help`;
|
|
67
|
+
|
|
68
|
+
const consoleOutput: CliOutput = {
|
|
69
|
+
log: (line) => console.log(line),
|
|
70
|
+
error: (line) => console.error(line),
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
type Command =
|
|
74
|
+
| { readonly kind: "help" }
|
|
75
|
+
| { readonly kind: "campaign"; readonly contentFile: string; readonly recipientsFile: string; readonly dryRun: boolean; readonly pauseMs: number }
|
|
76
|
+
| { readonly kind: "dns"; readonly domain: string | undefined; readonly dkimSelectors: readonly string[] | undefined; readonly spfHosts: readonly string[] | undefined };
|
|
77
|
+
|
|
78
|
+
/** Runs the command and returns the process exit code: 0 done, 1 failed or incomplete, 2 usage error. */
|
|
79
|
+
export async function runMailCli(options: RunMailCliOptions): Promise<number> {
|
|
80
|
+
const output = options.output ?? consoleOutput;
|
|
81
|
+
const command = parseCommand(options.argv);
|
|
82
|
+
if (typeof command === "string") {
|
|
83
|
+
output.error(`softure-mail: ${command}`);
|
|
84
|
+
output.error(MAIL_USAGE);
|
|
85
|
+
return EXIT_USAGE;
|
|
86
|
+
}
|
|
87
|
+
switch (command.kind) {
|
|
88
|
+
case "help":
|
|
89
|
+
output.log(MAIL_USAGE);
|
|
90
|
+
return EXIT_OK;
|
|
91
|
+
case "dns":
|
|
92
|
+
return runDns(command, options, output);
|
|
93
|
+
case "campaign":
|
|
94
|
+
return runCampaign(command, options, output);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function parseCommand(argv: readonly string[]): Command | string {
|
|
99
|
+
const [name, ...rest] = argv;
|
|
100
|
+
if (name === undefined) return "missing command; use campaign or dns";
|
|
101
|
+
if (name === "--help") return { kind: "help" };
|
|
102
|
+
if (name !== "campaign" && name !== "dns") return `unknown command "${name}"; use campaign or dns`;
|
|
103
|
+
|
|
104
|
+
let parsed: ReturnType<typeof parseCommandArgs>;
|
|
105
|
+
try {
|
|
106
|
+
parsed = parseCommandArgs(rest);
|
|
107
|
+
} catch (error) {
|
|
108
|
+
return describeError(error);
|
|
109
|
+
}
|
|
110
|
+
const { values, positionals } = parsed;
|
|
111
|
+
if (values.help === true) return { kind: "help" };
|
|
112
|
+
|
|
113
|
+
if (name === "dns") {
|
|
114
|
+
const misplaced = (["recipients", "dry-run", "pause-ms"] as const).filter((option) => values[option] !== undefined);
|
|
115
|
+
if (misplaced.length > 0) return `dns does not take --${misplaced.join(", --")}`;
|
|
116
|
+
if (positionals.length > 0) return `dns takes no file, got "${positionals.join(" ")}"`;
|
|
117
|
+
return { kind: "dns", domain: values.domain, dkimSelectors: values["dkim-selector"], spfHosts: values["spf-host"] };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const misplaced = (["domain", "dkim-selector", "spf-host"] as const).filter((option) => values[option] !== undefined);
|
|
121
|
+
if (misplaced.length > 0) return `campaign does not take --${misplaced.join(", --")}`;
|
|
122
|
+
const [contentFile, ...extra] = positionals;
|
|
123
|
+
if (contentFile === undefined) return "campaign needs a content file";
|
|
124
|
+
if (extra.length > 0) return `campaign takes one content file, got also "${extra.join(" ")}"`;
|
|
125
|
+
if (values.recipients === undefined) return "campaign needs --recipients <file>";
|
|
126
|
+
const pauseMs = values["pause-ms"] === undefined ? DEFAULT_PAUSE_MS : Number(values["pause-ms"]);
|
|
127
|
+
if (!Number.isInteger(pauseMs) || pauseMs < 0 || pauseMs > 60_000) return `--pause-ms expects whole milliseconds from 0 to 60000, got "${values["pause-ms"] ?? ""}"`;
|
|
128
|
+
return { kind: "campaign", contentFile, recipientsFile: values.recipients, dryRun: values["dry-run"] === true, pauseMs };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function parseCommandArgs(args: readonly string[]) {
|
|
132
|
+
return parseArgs({
|
|
133
|
+
args: [...args],
|
|
134
|
+
options: {
|
|
135
|
+
recipients: { type: "string" },
|
|
136
|
+
"dry-run": { type: "boolean" },
|
|
137
|
+
"pause-ms": { type: "string" },
|
|
138
|
+
domain: { type: "string" },
|
|
139
|
+
"dkim-selector": { type: "string", multiple: true },
|
|
140
|
+
"spf-host": { type: "string", multiple: true },
|
|
141
|
+
help: { type: "boolean" },
|
|
142
|
+
},
|
|
143
|
+
strict: true,
|
|
144
|
+
allowPositionals: true,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function runDns(command: Extract<Command, { kind: "dns" }>, options: RunMailCliOptions, output: CliOutput): Promise<number> {
|
|
149
|
+
let domain = command.domain;
|
|
150
|
+
if (domain === undefined) {
|
|
151
|
+
if (options.config === undefined) {
|
|
152
|
+
output.error("softure-mail dns: pass --domain <domain>, or run it with the app's config to check the sender's domain");
|
|
153
|
+
return EXIT_FAILED;
|
|
154
|
+
}
|
|
155
|
+
domain = getSenderDomain(getMailingOptions(options.config).from);
|
|
156
|
+
}
|
|
157
|
+
const report = await checkSenderDns(domain, {
|
|
158
|
+
...(command.dkimSelectors === undefined ? {} : { dkimSelectors: command.dkimSelectors }),
|
|
159
|
+
...(command.spfHosts === undefined ? {} : { spfHosts: command.spfHosts }),
|
|
160
|
+
...(options.resolveTxt === undefined ? {} : { resolveTxt: options.resolveTxt }),
|
|
161
|
+
});
|
|
162
|
+
const checks: [string, DnsCheck][] = [
|
|
163
|
+
["SPF", report.spf],
|
|
164
|
+
["DKIM", report.dkim],
|
|
165
|
+
["DMARC", report.dmarc],
|
|
166
|
+
];
|
|
167
|
+
output.log(`sender domain ${report.domain}`);
|
|
168
|
+
for (const [name, check] of checks) {
|
|
169
|
+
const line = `${name.padEnd(6)}${check.status.padEnd(6)}${check.finding.padEnd(15)}${check.host}${check.record === null ? "" : ` ${check.record}`}`;
|
|
170
|
+
if (check.status === "fail") output.error(line);
|
|
171
|
+
else output.log(line);
|
|
172
|
+
}
|
|
173
|
+
const failed = checks.filter(([, check]) => check.status === "fail").length;
|
|
174
|
+
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`);
|
|
175
|
+
return failed === 0 ? EXIT_OK : EXIT_FAILED;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function runCampaign(command: Extract<Command, { kind: "campaign" }>, options: RunMailCliOptions, output: CliOutput): Promise<number> {
|
|
179
|
+
const cwd = options.cwd ?? process.cwd();
|
|
180
|
+
const config = options.config;
|
|
181
|
+
if (config === undefined) {
|
|
182
|
+
output.error("softure-mail campaign: needs the app's config");
|
|
183
|
+
return EXIT_FAILED;
|
|
184
|
+
}
|
|
185
|
+
const loaded = await loadCampaign(resolve(cwd, command.contentFile), resolve(cwd, command.recipientsFile));
|
|
186
|
+
if (typeof loaded === "string") {
|
|
187
|
+
output.error(`softure-mail campaign: ${loaded}`);
|
|
188
|
+
return EXIT_FAILED;
|
|
189
|
+
}
|
|
190
|
+
if (!command.dryRun && readUnsubscribeSecrets(options.env ?? process.env).current === null) {
|
|
191
|
+
output.error(`softure-mail campaign: campaigns are list mail; set ${UNSUBSCRIBE_SECRET_ENV} (at least ${String(MIN_UNSUBSCRIBE_SECRET_LENGTH)} characters) to sign their unsubscribe links`);
|
|
192
|
+
return EXIT_FAILED;
|
|
193
|
+
}
|
|
194
|
+
if (config.database === null) {
|
|
195
|
+
output.error("softure-mail campaign: the config has no database; set database.url in softure.config");
|
|
196
|
+
return EXIT_FAILED;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
let opened: CommandDatabase;
|
|
200
|
+
try {
|
|
201
|
+
opened = await openDatabase(config.database, options.openDatabase);
|
|
202
|
+
} catch (error) {
|
|
203
|
+
output.error(`softure-mail campaign: ${describeError(error)}`);
|
|
204
|
+
return EXIT_FAILED;
|
|
205
|
+
}
|
|
206
|
+
const ctx: DeliveryContext = { db: opened.handle.db, clock: systemClock, config };
|
|
207
|
+
try {
|
|
208
|
+
return command.dryRun ? await reportPlan(ctx, loaded, output) : await reportSend(ctx, loaded, { pauseMs: command.pauseMs, sleep: options.sleep }, output);
|
|
209
|
+
} catch (error) {
|
|
210
|
+
// Driver errors: the message only, never a stack, an address or the database URL.
|
|
211
|
+
output.error(`softure-mail campaign: ${describeError(error)} (did softure migrate run?)`);
|
|
212
|
+
return EXIT_FAILED;
|
|
213
|
+
} finally {
|
|
214
|
+
await opened.close();
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
interface LoadedCampaign {
|
|
219
|
+
readonly campaign: CampaignContent;
|
|
220
|
+
readonly recipients: readonly string[];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function loadCampaign(contentPath: string, recipientsPath: string): Promise<LoadedCampaign | string> {
|
|
224
|
+
const source = await readText(contentPath);
|
|
225
|
+
if (source === null) return `cannot read the content file ${contentPath}`;
|
|
226
|
+
const parsed = parseCampaignFile(source);
|
|
227
|
+
if (!parsed.ok) return `${contentPath} is not a campaign:\n ${parsed.problems.join("\n ")}`;
|
|
228
|
+
const { htmlPath, ...content } = parsed.value;
|
|
229
|
+
let html: string | null = null;
|
|
230
|
+
if (htmlPath !== null) {
|
|
231
|
+
const htmlFile = resolve(dirname(contentPath), htmlPath);
|
|
232
|
+
html = await readText(htmlFile);
|
|
233
|
+
if (html === null) return `cannot read the HTML body ${htmlFile}`;
|
|
234
|
+
if (html.trim() === "") return `the HTML body ${htmlFile} is empty`;
|
|
235
|
+
}
|
|
236
|
+
const list = await readText(recipientsPath);
|
|
237
|
+
if (list === null) return `cannot read the recipients file ${recipientsPath}`;
|
|
238
|
+
return { campaign: { ...content, html }, recipients: parseRecipientList(list) };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function reportPlan(ctx: DeliveryContext, loaded: LoadedCampaign, output: CliOutput): Promise<number> {
|
|
242
|
+
const plan = await planCampaign(ctx, loaded);
|
|
243
|
+
output.log(`campaign ${loaded.campaign.id} (${loaded.campaign.kind}), dry run: nothing sent or written`);
|
|
244
|
+
output.log(`recipients ${String(plan.recipients)}, already done ${String(plan.done)}, unsubscribed ${String(plan.suppressed)}, to send ${String(plan.toSend)}`);
|
|
245
|
+
if (plan.contentChanged) {
|
|
246
|
+
output.error(`campaign ${loaded.campaign.id} was sent with other content; give this content a new id`);
|
|
247
|
+
return EXIT_FAILED;
|
|
248
|
+
}
|
|
249
|
+
return EXIT_OK;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function reportSend(
|
|
253
|
+
ctx: DeliveryContext,
|
|
254
|
+
loaded: LoadedCampaign,
|
|
255
|
+
pace: { readonly pauseMs: number; readonly sleep: ((ms: number) => Promise<void>) | undefined },
|
|
256
|
+
output: CliOutput,
|
|
257
|
+
): Promise<number> {
|
|
258
|
+
let handled = 0;
|
|
259
|
+
const total = loaded.recipients.length;
|
|
260
|
+
const result = await sendCampaign(ctx, loaded, {
|
|
261
|
+
pauseMs: pace.pauseMs,
|
|
262
|
+
...(pace.sleep === undefined ? {} : { sleep: pace.sleep }),
|
|
263
|
+
onDelivery: () => {
|
|
264
|
+
handled += 1;
|
|
265
|
+
if (handled % 100 === 0) output.log(`progress ${String(handled)}/${String(total)}`);
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
if (!result.ok) {
|
|
269
|
+
output.error(`campaign ${loaded.campaign.id} was sent with other content; give this content a new id`);
|
|
270
|
+
return EXIT_FAILED;
|
|
271
|
+
}
|
|
272
|
+
output.log(`campaign ${loaded.campaign.id} (${loaded.campaign.kind}): ${formatSummary(result.value)}`);
|
|
273
|
+
const open = result.value.inFlight + result.value.retryLater;
|
|
274
|
+
if (open > 0) {
|
|
275
|
+
output.error(`${String(open)} recipient(s) have no outcome yet; run the same command again later`);
|
|
276
|
+
return EXIT_FAILED;
|
|
277
|
+
}
|
|
278
|
+
return EXIT_OK;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function formatSummary(summary: CampaignSummary): string {
|
|
282
|
+
const rejected = Object.entries(summary.rejected).filter(([, count]) => count > 0);
|
|
283
|
+
const rejectedTotal = rejected.reduce((sum, [, count]) => sum + count, 0);
|
|
284
|
+
const reasons = rejected.length === 0 ? "" : ` (${rejected.map(([code, count]) => `${code.slice("mailing.".length)} ${String(count)}`).join(", ")})`;
|
|
285
|
+
return [
|
|
286
|
+
`recipients ${String(summary.recipients)}`,
|
|
287
|
+
`sent ${String(summary.sent)}`,
|
|
288
|
+
`rejected ${String(rejectedTotal)}${reasons}`,
|
|
289
|
+
`already done ${String(summary.done)}`,
|
|
290
|
+
`in flight ${String(summary.inFlight)}`,
|
|
291
|
+
`retry later ${String(summary.retryLater)}`,
|
|
292
|
+
].join(", ");
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function openDatabase(database: SoftureDatabaseConfig, open: ((url: string) => Promise<DatabaseHandle>) | undefined): Promise<CommandDatabase> {
|
|
296
|
+
if (open === undefined) return openCommandDatabase(database, { max: 1 });
|
|
297
|
+
const handle = await open(database.url);
|
|
298
|
+
return { handle, close: handle.close };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function readText(path: string): Promise<string | null> {
|
|
302
|
+
try {
|
|
303
|
+
return await readFile(path, "utf8");
|
|
304
|
+
} catch {
|
|
305
|
+
// The caller names the path; the error code adds nothing an operator can act on.
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* The driver's own message. Drizzle wraps it in "Failed query: <sql> params: <values>", and the
|
|
312
|
+
* values hold recipient keys, so the wrapper is dropped.
|
|
313
|
+
*/
|
|
314
|
+
function describeError(error: unknown): string {
|
|
315
|
+
if (!(error instanceof Error)) return String(error);
|
|
316
|
+
return error.cause instanceof Error ? error.cause.message : error.message;
|
|
317
|
+
}
|
package/src/contract.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Result types, error codes and the provider contract of the mailing module. No user-facing copy
|
|
2
|
+
// here: the UI translates codes through `messages` (docs/02-module-standard.md §6).
|
|
3
|
+
import type { ModuleContext, Result } from "@softure-ai/core";
|
|
4
|
+
import type { Queryable } from "@softure-ai/db";
|
|
5
|
+
|
|
6
|
+
export type MailingErrorCode =
|
|
7
|
+
/** The mail or its options were refused before anything left the process. Fix the input; do not retry. */
|
|
8
|
+
| "mailing.invalid_input"
|
|
9
|
+
/** The provider refused the mail. Retrying the same request will not help. */
|
|
10
|
+
| "mailing.rejected"
|
|
11
|
+
/** The provider could not be reached or did not answer in time. Retry later, with the same idempotency key. */
|
|
12
|
+
| "mailing.unavailable"
|
|
13
|
+
/** A list mail to a recipient who unsubscribed. Nothing was sent; do not retry. */
|
|
14
|
+
| "mailing.suppressed";
|
|
15
|
+
|
|
16
|
+
/** Why an unsubscribe link was refused: missing, malformed, or not signed by a current secret. */
|
|
17
|
+
export type UnsubscribeErrorCode = "mailing.invalid_link";
|
|
18
|
+
|
|
19
|
+
/** The kind of every mail that is not list mail: sent whatever the recipient unsubscribed from. */
|
|
20
|
+
export const TRANSACTIONAL_KIND = "transactional";
|
|
21
|
+
|
|
22
|
+
/** How an opt-out arrived: a mail client's one-click POST, the page's button, or an operator. */
|
|
23
|
+
export type SuppressionSource = "one-click" | "page" | "operator";
|
|
24
|
+
|
|
25
|
+
/** What `onUnsubscribed` receives: who opted out (never the address) and how. */
|
|
26
|
+
export interface UnsubscribeEvent {
|
|
27
|
+
/** The recipient key of the link: privacy's email key of the same address (`getEmailKey`). */
|
|
28
|
+
readonly recipientKey: string;
|
|
29
|
+
readonly source: Exclude<SuppressionSource, "operator">;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Called on every verified unsubscribe, in the suppression's transaction (`ctx.db` is that
|
|
34
|
+
* transaction): a throw rolls the opt-out back and the unsubscribe fails, so the consent ledger
|
|
35
|
+
* and the suppression list never disagree. Set in `mailing({ onUnsubscribed })`.
|
|
36
|
+
*/
|
|
37
|
+
export type OnUnsubscribedHook = (event: UnsubscribeEvent, ctx: ModuleContext<Queryable>) => Promise<void>;
|
|
38
|
+
|
|
39
|
+
/** One mail to one recipient. A mail to a list is a loop over recipients, never a list in `to`. */
|
|
40
|
+
export interface OutgoingMail {
|
|
41
|
+
/** Exactly one address: `ada@example.com`. */
|
|
42
|
+
readonly to: string;
|
|
43
|
+
/** One line, 1 to 998 characters. */
|
|
44
|
+
readonly subject: string;
|
|
45
|
+
/** The plain-text body. Required: every mail carries a text part. */
|
|
46
|
+
readonly text: string;
|
|
47
|
+
/** The HTML body, already rendered to a string. */
|
|
48
|
+
readonly html?: string;
|
|
49
|
+
/**
|
|
50
|
+
* Extra headers, for example `List-Unsubscribe`. They cannot replace the envelope, the sender or
|
|
51
|
+
* the body's encoding: reserved names are refused (`RESERVED_HEADERS`).
|
|
52
|
+
*/
|
|
53
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
54
|
+
/**
|
|
55
|
+
* `transactional` (the default) for mail the recipient needs whatever they unsubscribed from:
|
|
56
|
+
* password resets, receipts, account notices. Any other kebab-case name (e.g. `newsletter`) is
|
|
57
|
+
* list mail: it gets a signed unsubscribe link in a footer and the RFC 8058 headers, and is
|
|
58
|
+
* refused with `mailing.suppressed` for a recipient who unsubscribed. Unsubscribing covers every
|
|
59
|
+
* list kind.
|
|
60
|
+
*/
|
|
61
|
+
readonly kind?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface SendMailOptions {
|
|
65
|
+
/**
|
|
66
|
+
* Makes a retry safe: the provider treats requests with the same key as one mail (Resend keeps a
|
|
67
|
+
* key for 24 hours). 1 to 256 visible ASCII characters, e.g. `campaign-42:recipient-7`.
|
|
68
|
+
*/
|
|
69
|
+
readonly idempotencyKey?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A mail the provider accepted. */
|
|
73
|
+
export interface SentMail {
|
|
74
|
+
/** The provider's message id. */
|
|
75
|
+
readonly id: string;
|
|
76
|
+
/** The provider's name, e.g. `resend`. */
|
|
77
|
+
readonly provider: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export type SendMailResult = Result<SentMail, MailingErrorCode>;
|
|
81
|
+
|
|
82
|
+
/** What a provider receives: a validated mail with the sender and reply-to from the configuration. */
|
|
83
|
+
export interface ProviderMessage {
|
|
84
|
+
readonly from: string;
|
|
85
|
+
readonly to: string;
|
|
86
|
+
readonly replyTo: string | null;
|
|
87
|
+
readonly subject: string;
|
|
88
|
+
readonly text: string;
|
|
89
|
+
readonly html: string | null;
|
|
90
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
91
|
+
readonly idempotencyKey: string | null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** What a provider answers. `httpStatus` is logged; the mail never is. */
|
|
95
|
+
export type ProviderOutcome =
|
|
96
|
+
| { readonly status: "sent"; readonly id: string }
|
|
97
|
+
| { readonly status: "rejected"; readonly httpStatus?: number }
|
|
98
|
+
| { readonly status: "unavailable"; readonly httpStatus?: number };
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A mail service adapter. `send` should resolve with an outcome for every failure it knows; a
|
|
102
|
+
* throw reads as `unavailable`. It receives a signal that aborts at the configured timeout.
|
|
103
|
+
*/
|
|
104
|
+
export interface MailProvider {
|
|
105
|
+
/** A short lowercase name for logs and `SentMail.provider`. */
|
|
106
|
+
readonly name: string;
|
|
107
|
+
send(message: ProviderMessage, context: { readonly signal: AbortSignal }): Promise<ProviderOutcome>;
|
|
108
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Public API of @softure-ai/mailing: the module factory for softure.config.ts, the provider
|
|
2
|
+
// contract, the Resend adapter, types, messages and the tables. Sending, the suppression list, the
|
|
3
|
+
// delivery ledger and campaigns are in `@softure-ai/mailing/server` (and `/next` on the registered
|
|
4
|
+
// config, with the unsubscribe page and one-click route); the `softure-mail` command is in `/cli`;
|
|
5
|
+
// the fake provider for tests is in `/testing`.
|
|
6
|
+
import { defineModule, resolveMigrationsDir } from "@softure-ai/core";
|
|
7
|
+
import { mailingMessages } from "./messages/index.js";
|
|
8
|
+
import { mailingOptionsSchema } from "./options.js";
|
|
9
|
+
import { checkMailingTables } from "./server/health.js";
|
|
10
|
+
|
|
11
|
+
export const MODULE_ID = "mailing";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Enables mail in `softure.config.ts`:
|
|
15
|
+
* `mailing({ from: "Plan <hello@mail.example.com>", replyTo: "support@example.com", provider: resend() })`.
|
|
16
|
+
*/
|
|
17
|
+
export const mailing = defineModule({
|
|
18
|
+
manifest: {
|
|
19
|
+
id: MODULE_ID,
|
|
20
|
+
version: "0.1.6",
|
|
21
|
+
dependsOn: {},
|
|
22
|
+
dbSchema: "mailing",
|
|
23
|
+
tables: ["suppressions", "campaigns", "deliveries"],
|
|
24
|
+
env: [
|
|
25
|
+
{
|
|
26
|
+
name: "RESEND_API_KEY",
|
|
27
|
+
required: false,
|
|
28
|
+
description: "API key of the resend() provider, read on every send; not needed with resend({ apiKey }) or another provider.",
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: "MAILING_UNSUBSCRIBE_SECRET",
|
|
32
|
+
required: false,
|
|
33
|
+
description: "Signs and verifies unsubscribe links, at least 32 characters; required to send list mail (any kind but transactional). Never change it without moving the old value to MAILING_UNSUBSCRIBE_SECRET_PREVIOUS.",
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: "MAILING_UNSUBSCRIBE_SECRET_PREVIOUS",
|
|
37
|
+
required: false,
|
|
38
|
+
description: "The secret before a rotation, at least 32 characters: verifies links in mail already sent, never signs.",
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
switches: [],
|
|
42
|
+
routes: { unsubscribe: "/unsubscribe", oneClick: "/api/mailing/unsubscribe" },
|
|
43
|
+
mount: [
|
|
44
|
+
{ kind: "page", path: "app/unsubscribe/page.tsx", export: "UnsubscribePage" },
|
|
45
|
+
{ kind: "route-handler", path: "app/api/mailing/unsubscribe/route.ts", export: "postUnsubscribeRoute" },
|
|
46
|
+
{ kind: "route-handler", path: "app/api/mailing/unsubscribe/route.ts", export: "getUnsubscribeRoute" },
|
|
47
|
+
],
|
|
48
|
+
privacy: { exports: false, deletes: false },
|
|
49
|
+
},
|
|
50
|
+
messages: mailingMessages,
|
|
51
|
+
options: mailingOptionsSchema,
|
|
52
|
+
migrations: { dir: resolveMigrationsDir(import.meta.url, "../migrations/") },
|
|
53
|
+
health: checkMailingTables,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
export {
|
|
57
|
+
isMailbox,
|
|
58
|
+
isMailKind,
|
|
59
|
+
isSingleAddress,
|
|
60
|
+
LIST_MAIL_HEADERS,
|
|
61
|
+
MAX_ADDRESS_LENGTH,
|
|
62
|
+
MAX_IDEMPOTENCY_KEY_LENGTH,
|
|
63
|
+
MAX_MAIL_KIND_LENGTH,
|
|
64
|
+
MAX_SUBJECT_LENGTH,
|
|
65
|
+
RESERVED_HEADERS,
|
|
66
|
+
} from "./address.js";
|
|
67
|
+
export { TRANSACTIONAL_KIND } from "./contract.js";
|
|
68
|
+
export type {
|
|
69
|
+
MailingErrorCode,
|
|
70
|
+
MailProvider,
|
|
71
|
+
OnUnsubscribedHook,
|
|
72
|
+
OutgoingMail,
|
|
73
|
+
ProviderMessage,
|
|
74
|
+
ProviderOutcome,
|
|
75
|
+
SendMailOptions,
|
|
76
|
+
SendMailResult,
|
|
77
|
+
SentMail,
|
|
78
|
+
SuppressionSource,
|
|
79
|
+
UnsubscribeErrorCode,
|
|
80
|
+
UnsubscribeEvent,
|
|
81
|
+
} from "./contract.js";
|
|
82
|
+
export { getMailingErrorMessage, mailingMessages, type MailingMessages } from "./messages/index.js";
|
|
83
|
+
export { DEFAULT_TIMEOUT_MS, type MailingOptions, type MailingOptionsInput } from "./options.js";
|
|
84
|
+
export { resend, RESEND_API_KEY_ENV, RESEND_ENDPOINT, type ResendOptions } from "./providers/resend.js";
|
|
85
|
+
export { campaigns, deliveries, mailingSchema, suppressions, type DeliveryStatus } from "./schema.js";
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export const en = {
|
|
2
|
+
errors: {
|
|
3
|
+
mailing: {
|
|
4
|
+
invalid_input: "This message cannot be sent. Check the address and try again.",
|
|
5
|
+
rejected: "The mail service refused this message.",
|
|
6
|
+
unavailable: "The mail service is not answering. Try again in a moment.",
|
|
7
|
+
suppressed: "This address unsubscribed from these emails, so nothing was sent.",
|
|
8
|
+
},
|
|
9
|
+
},
|
|
10
|
+
footer: {
|
|
11
|
+
text: "Don't want these emails? Unsubscribe here:",
|
|
12
|
+
htmlLead: "Don't want these emails?",
|
|
13
|
+
htmlLink: "Unsubscribe",
|
|
14
|
+
},
|
|
15
|
+
unsubscribe: {
|
|
16
|
+
title: "Unsubscribe",
|
|
17
|
+
lead: "You will stop getting these emails from us. Mail about your account, such as password resets, still arrives.",
|
|
18
|
+
submit: "Unsubscribe me",
|
|
19
|
+
doneTitle: "You are unsubscribed",
|
|
20
|
+
doneBody: "We will not send you these emails any more.",
|
|
21
|
+
invalidTitle: "This link does not work",
|
|
22
|
+
invalidBody: "Open the link from the email again, the whole of it. If it still fails, reply to the email and ask us to stop.",
|
|
23
|
+
failed: "We could not save that. Try again in a moment.",
|
|
24
|
+
},
|
|
25
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { MailingErrorCode } from "../contract.js";
|
|
2
|
+
import { en } from "./en.js";
|
|
3
|
+
import { pl } from "./pl.js";
|
|
4
|
+
|
|
5
|
+
/** Complete default dictionaries; apps pass partial overrides per locale. */
|
|
6
|
+
export const mailingMessages = { en, pl };
|
|
7
|
+
|
|
8
|
+
export type MailingMessages = typeof en;
|
|
9
|
+
|
|
10
|
+
/** The copy for a mailing error code; a code the dictionary does not know reads as `unavailable`. */
|
|
11
|
+
export function getMailingErrorMessage(messages: MailingMessages, code: MailingErrorCode): string {
|
|
12
|
+
const group: Readonly<Record<string, string>> = messages.errors.mailing;
|
|
13
|
+
const name = code.slice("mailing.".length);
|
|
14
|
+
return (Object.hasOwn(group, name) ? group[name] : undefined) ?? messages.errors.mailing.unavailable;
|
|
15
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { en } from "./en.js";
|
|
2
|
+
|
|
3
|
+
// Polish copy lives only in message dictionaries; the language gate exempts `messages/` folders.
|
|
4
|
+
export const pl: typeof en = {
|
|
5
|
+
errors: {
|
|
6
|
+
mailing: {
|
|
7
|
+
invalid_input: "Tej wiadomości nie da się wysłać. Sprawdź adres i spróbuj ponownie.",
|
|
8
|
+
rejected: "Usługa pocztowa odrzuciła tę wiadomość.",
|
|
9
|
+
unavailable: "Usługa pocztowa nie odpowiada. Spróbuj ponownie za chwilę.",
|
|
10
|
+
suppressed: "Ten adres wypisał się z takich wiadomości, więc nic nie wysłano.",
|
|
11
|
+
},
|
|
12
|
+
},
|
|
13
|
+
footer: {
|
|
14
|
+
text: "Nie chcesz więcej takich wiadomości? Wypisz się tutaj:",
|
|
15
|
+
htmlLead: "Nie chcesz więcej takich wiadomości?",
|
|
16
|
+
htmlLink: "Wypisz się",
|
|
17
|
+
},
|
|
18
|
+
unsubscribe: {
|
|
19
|
+
title: "Wypisanie",
|
|
20
|
+
lead: "Przestaniesz dostawać od nas takie wiadomości. Poczta o Twoim koncie, na przykład reset hasła, nadal będzie przychodzić.",
|
|
21
|
+
submit: "Wypisz mnie",
|
|
22
|
+
doneTitle: "Wypisano",
|
|
23
|
+
doneBody: "Nie wyślemy Ci już takich wiadomości.",
|
|
24
|
+
invalidTitle: "Ten odnośnik nie działa",
|
|
25
|
+
invalidBody: "Otwórz jeszcze raz odnośnik z wiadomości, w całości. Jeśli dalej nie działa, odpisz na wiadomość i poproś o wypisanie.",
|
|
26
|
+
failed: "Nie udało się tego zapisać. Spróbuj ponownie za chwilę.",
|
|
27
|
+
},
|
|
28
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use server";
|
|
2
|
+
|
|
3
|
+
// The unsubscribe page's server action. It takes the signed link from the form (the same values
|
|
4
|
+
// the page got in its URL), records the opt-out and redirects to the page's outcome. No session is
|
|
5
|
+
// involved: the signature is the authorization. Next refuses an action whose Origin does not match
|
|
6
|
+
// the host.
|
|
7
|
+
import { errorLogLabel } from "@softure-ai/core";
|
|
8
|
+
import { getSoftureConfig } from "@softure-ai/core/next";
|
|
9
|
+
import { redirect } from "next/navigation";
|
|
10
|
+
import { getMailingRoutes } from "../server/options.js";
|
|
11
|
+
import { unsubscribe } from "../server/suppressions.js";
|
|
12
|
+
import { RECIPIENT_PARAM, readUnsubscribeToken, SIGNATURE_PARAM } from "../server/unsubscribe-link.js";
|
|
13
|
+
import { getMailingContext } from "./context.js";
|
|
14
|
+
import { UNSUBSCRIBE_STATUS_PARAM, type UnsubscribeStatus } from "./params.js";
|
|
15
|
+
|
|
16
|
+
export async function unsubscribeAction(formData: FormData): Promise<void> {
|
|
17
|
+
const config = getSoftureConfig();
|
|
18
|
+
const token = readUnsubscribeToken(formData);
|
|
19
|
+
let status: UnsubscribeStatus;
|
|
20
|
+
try {
|
|
21
|
+
const result = await unsubscribe(await getMailingContext(config), token, "page");
|
|
22
|
+
status = result.ok ? "done" : "invalid";
|
|
23
|
+
} catch (error) {
|
|
24
|
+
// The label names the kind of failure, never its text: that can carry the link's values.
|
|
25
|
+
console.error(`@softure-ai/mailing: unsubscribe failed: ${errorLogLabel(error)}`);
|
|
26
|
+
status = "failed";
|
|
27
|
+
}
|
|
28
|
+
const query = new URLSearchParams({ [UNSUBSCRIBE_STATUS_PARAM]: status });
|
|
29
|
+
if (status === "failed" && token !== null) {
|
|
30
|
+
// The form comes back with the same link, so the person can try again.
|
|
31
|
+
query.set(RECIPIENT_PARAM, token.recipientKey);
|
|
32
|
+
query.set(SIGNATURE_PARAM, token.signature);
|
|
33
|
+
}
|
|
34
|
+
redirect(`${getMailingRoutes(config).unsubscribe}?${query.toString()}`);
|
|
35
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// What the adapter hands the server functions: the registered config, the process-wide database
|
|
2
|
+
// handle and the wall clock.
|
|
3
|
+
import { systemClock, type SoftureConfig } from "@softure-ai/core";
|
|
4
|
+
import { getSoftureConfig } from "@softure-ai/core/next";
|
|
5
|
+
import { getConfiguredDatabase } from "@softure-ai/db";
|
|
6
|
+
import type { SuppressionContext } from "../server/suppressions.js";
|
|
7
|
+
|
|
8
|
+
export async function getMailingContext(config: SoftureConfig = getSoftureConfig()): Promise<SuppressionContext> {
|
|
9
|
+
if (config.database === null) {
|
|
10
|
+
// Unreachable for a validated config: the module has a database schema.
|
|
11
|
+
throw new Error("@softure-ai/mailing: softure.config.ts has no database; mailing needs one");
|
|
12
|
+
}
|
|
13
|
+
const { db } = await getConfiguredDatabase(config.database);
|
|
14
|
+
return { db, clock: systemClock, config };
|
|
15
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// `deliverOnce` on the app's registered configuration and the shared database, for server actions
|
|
2
|
+
// and route handlers that send a mail at most once (a welcome mail, a lifecycle notice).
|
|
3
|
+
import type { Delivery, DeliverOptions, DeliveryOutcome } from "../server/deliveries.js";
|
|
4
|
+
import { deliverOnce as deliverOnceWithContext } from "../server/deliveries.js";
|
|
5
|
+
import { getMailingContext } from "./context.js";
|
|
6
|
+
|
|
7
|
+
/** Sends one mail at most once per scope and recipient; see `deliverOnce` in `@softure-ai/mailing/server`. */
|
|
8
|
+
export async function deliverOnce(delivery: Delivery, options: DeliverOptions = {}): Promise<DeliveryOutcome> {
|
|
9
|
+
return deliverOnceWithContext(await getMailingContext(), delivery, options);
|
|
10
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// The Next.js adapter of @softure-ai/mailing: sending (and sending once, through the ledger) on the
|
|
2
|
+
// registered configuration, the unsubscribe page with its action, and the one-click route
|
|
3
|
+
// (docs/02-module-standard.md §8).
|
|
4
|
+
export { unsubscribeAction } from "./actions.js";
|
|
5
|
+
export { deliverOnce } from "./deliver-once.js";
|
|
6
|
+
export { getMailingMessages } from "./messages.js";
|
|
7
|
+
export { UnsubscribePage, type UnsubscribePageProps } from "./pages.js";
|
|
8
|
+
export { UNSUBSCRIBE_STATUS_PARAM, type UnsubscribeStatus } from "./params.js";
|
|
9
|
+
export { getUnsubscribeRoute, postUnsubscribeRoute } from "./route.js";
|
|
10
|
+
export { sendMail } from "./send-mail.js";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { SoftureConfig } from "@softure-ai/core";
|
|
2
|
+
import type { MailingMessages } from "../messages/index.js";
|
|
3
|
+
import { getMailingModule } from "../server/options.js";
|
|
4
|
+
|
|
5
|
+
/** The module's copy in the app's locale, with the app's overrides applied. */
|
|
6
|
+
export function getMailingMessages(config: SoftureConfig): MailingMessages {
|
|
7
|
+
// The module factory merged the dictionaries; their shape is the module's own.
|
|
8
|
+
return getMailingModule(config).messages[config.locale] as MailingMessages;
|
|
9
|
+
}
|