@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
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// A run of lifecycle mail: one delivery per recipient through the ledger (`deliverOnce`), e.g. every subscription
|
|
2
|
+
// whose trial ends this week. The app lists the recipients and builds each mail; the run keeps the ledger's
|
|
3
|
+
// at-most-once rule, stops at the first failure about the sending account (refused key, spent quota) and after
|
|
4
|
+
// `limit` mails reached the provider, and counts what it did. A dry run reads the same state and writes nothing.
|
|
5
|
+
import { TRANSACTIONAL_KIND, type MailingErrorCode } from "../contract.js";
|
|
6
|
+
import { deliverOnce, forecastDelivery, type DeliverOptions, type Delivery, type DeliveryContext, type DeliveryForecast, type DeliveryOutcome, type HaltingErrorCode } from "./deliveries.js";
|
|
7
|
+
import { getRecipientKey, MIN_UNSUBSCRIBE_SECRET_LENGTH, readUnsubscribeSecrets, UNSUBSCRIBE_SECRET_ENV } from "./unsubscribe-link.js";
|
|
8
|
+
|
|
9
|
+
/** Rejections the provider answered (the others never left the process). */
|
|
10
|
+
const PROVIDER_REASONS: ReadonlySet<MailingErrorCode> = new Set(["mailing.rejected", "mailing.unavailable"]);
|
|
11
|
+
|
|
12
|
+
export interface DeliveryRunInput<Recipient> {
|
|
13
|
+
readonly recipients: Iterable<Recipient> | AsyncIterable<Recipient>;
|
|
14
|
+
/** The delivery for one recipient (its scope and mail), or `null` to skip them. A throw stops the run. */
|
|
15
|
+
readonly build: (recipient: Recipient) => Delivery | null | Promise<Delivery | null>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface RunDeliveriesOptions extends DeliverOptions {
|
|
19
|
+
/** Pause after every mail that reached the provider, for its rate limit. Default 0. */
|
|
20
|
+
readonly pauseMs?: number;
|
|
21
|
+
/**
|
|
22
|
+
* Most mails this run hands to the provider (sent, rejected by it, or not taken now). Once reached, the run reads
|
|
23
|
+
* the rest only to count `remaining`. A whole number of at least 1. Default: no limit.
|
|
24
|
+
*/
|
|
25
|
+
readonly limit?: number;
|
|
26
|
+
/** Reads the ledger and the suppression list and sends and writes nothing; `sent` then counts what would go out. */
|
|
27
|
+
readonly dryRun?: boolean;
|
|
28
|
+
/** Called after every delivery of a real run, e.g. for progress output. Never receives the address. */
|
|
29
|
+
readonly onDelivery?: (outcome: DeliveryOutcome) => void;
|
|
30
|
+
readonly sleep?: (ms: number) => Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface DeliveryRunSummary {
|
|
34
|
+
readonly dryRun: boolean;
|
|
35
|
+
/** Recipients read (past the limit too, but not past a halt). */
|
|
36
|
+
readonly recipients: number;
|
|
37
|
+
/** `build` returned `null`; nothing stored. */
|
|
38
|
+
readonly skipped: number;
|
|
39
|
+
/** Sent in this run; in a dry run, the mails that would reach the provider. */
|
|
40
|
+
readonly sent: number;
|
|
41
|
+
/** Rejected in this run, by reason; suppressed list mail is under `mailing.suppressed`. */
|
|
42
|
+
readonly rejected: Readonly<Record<MailingErrorCode, number>>;
|
|
43
|
+
/** Closed by an earlier run (or, in a dry run, by a delivery earlier in this one); nothing sent. */
|
|
44
|
+
readonly done: number;
|
|
45
|
+
/** Claimed by another run that is still sending. */
|
|
46
|
+
readonly inFlight: number;
|
|
47
|
+
/** The provider was unavailable, or the run halted on it; the next run tries again. */
|
|
48
|
+
readonly retryLater: number;
|
|
49
|
+
/** Claimed more than `uncertainClaimMs` ago and never closed: their mail may have gone out. */
|
|
50
|
+
readonly uncertain: number;
|
|
51
|
+
/** Set when the run stopped at a failure about the sending account; run again once the account can send. */
|
|
52
|
+
readonly halted: { readonly reason: HaltingErrorCode; readonly httpStatus?: number } | null;
|
|
53
|
+
/** Deliveries past the `limit` cut that the next run would send; 0 without a cut, `null` after a halt. */
|
|
54
|
+
readonly remaining: number | null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
type Counts = { -readonly [Key in Exclude<keyof DeliveryRunSummary, "dryRun" | "halted" | "remaining" | "rejected">]: number } & { rejected: Record<MailingErrorCode, number> };
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Builds and delivers one mail per recipient, in order, each at most once per scope and recipient. Throws on a
|
|
61
|
+
* database failure, when `build` throws, like `deliverOnce` for a malformed delivery, at the first list mail when
|
|
62
|
+
* `MAILING_UNSUBSCRIBE_SECRET` is not set (each send would fail and use up an attempt), and (a `RangeError`) for a
|
|
63
|
+
* `limit` that is not a whole number of at least 1.
|
|
64
|
+
*/
|
|
65
|
+
export async function runDeliveries<Recipient>(ctx: DeliveryContext, input: DeliveryRunInput<Recipient>, options: RunDeliveriesOptions = {}): Promise<DeliveryRunSummary> {
|
|
66
|
+
const { pauseMs = 0, limit, dryRun = false, onDelivery, sleep = wait, ...deliverOptions } = options;
|
|
67
|
+
if (limit !== undefined && !(Number.isInteger(limit) && limit >= 1)) {
|
|
68
|
+
throw new RangeError(`@softure-ai/mailing: runDeliveries limit must be a whole number of at least 1, got ${String(limit)}`);
|
|
69
|
+
}
|
|
70
|
+
const counts: Counts = { recipients: 0, skipped: 0, sent: 0, done: 0, inFlight: 0, retryLater: 0, uncertain: 0, rejected: emptyRejections() };
|
|
71
|
+
// Deliveries this run already sent or would send, so a dry run (and the count past the limit) sees its own effect.
|
|
72
|
+
const reached = new Set<string>();
|
|
73
|
+
let handedToProvider = 0;
|
|
74
|
+
let remaining = 0;
|
|
75
|
+
for await (const recipient of input.recipients) {
|
|
76
|
+
counts.recipients += 1;
|
|
77
|
+
const delivery = await input.build(recipient);
|
|
78
|
+
if (delivery === null) {
|
|
79
|
+
counts.skipped += 1;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
assertListMailCanBeSigned(delivery);
|
|
83
|
+
const isPastLimit = limit !== undefined && handedToProvider >= limit;
|
|
84
|
+
if (dryRun || isPastLimit) {
|
|
85
|
+
const forecast = await forecastOnce(ctx, delivery, { options: deliverOptions, reached });
|
|
86
|
+
if (isPastLimit) {
|
|
87
|
+
if (forecast.status === "send") remaining += 1;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
countForecast(counts, forecast);
|
|
91
|
+
if (forecast.status === "send") handedToProvider += 1;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const outcome = await deliverOnce(ctx, delivery, deliverOptions);
|
|
96
|
+
countOutcome(counts, outcome);
|
|
97
|
+
onDelivery?.(outcome);
|
|
98
|
+
if (outcome.status === "halted") {
|
|
99
|
+
const httpStatus = outcome.httpStatus === undefined ? {} : { httpStatus: outcome.httpStatus };
|
|
100
|
+
return { dryRun, ...counts, halted: { reason: outcome.reason, ...httpStatus }, remaining: null };
|
|
101
|
+
}
|
|
102
|
+
const reachedProvider = outcome.status === "sent" || outcome.status === "retry-later" || (outcome.status === "rejected" && PROVIDER_REASONS.has(outcome.reason));
|
|
103
|
+
if (outcome.status === "sent") reached.add(getDeliveryKey(delivery));
|
|
104
|
+
if (reachedProvider) handedToProvider += 1;
|
|
105
|
+
if (reachedProvider && pauseMs > 0) await sleep(pauseMs);
|
|
106
|
+
}
|
|
107
|
+
return { dryRun, ...counts, halted: null, remaining };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function getDeliveryKey(delivery: Delivery): string {
|
|
111
|
+
return `${delivery.scope}\n${getRecipientKey(delivery.mail.to)}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** List mail without the signing secret is a configuration bug: stop before any claim is taken. */
|
|
115
|
+
function assertListMailCanBeSigned(delivery: Delivery): void {
|
|
116
|
+
if ((delivery.mail.kind ?? TRANSACTIONAL_KIND) === TRANSACTIONAL_KIND || readUnsubscribeSecrets().current !== null) return;
|
|
117
|
+
throw new Error(`@softure-ai/mailing: runDeliveries sends list mail, which needs ${UNSUBSCRIBE_SECRET_ENV} (at least ${String(MIN_UNSUBSCRIBE_SECRET_LENGTH)} characters)`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The forecast, with a delivery this run already sent (or would send) counted as done. */
|
|
121
|
+
async function forecastOnce(
|
|
122
|
+
ctx: DeliveryContext,
|
|
123
|
+
delivery: Delivery,
|
|
124
|
+
{ options, reached }: { readonly options: DeliverOptions; readonly reached: Set<string> },
|
|
125
|
+
): Promise<DeliveryForecast> {
|
|
126
|
+
const key = getDeliveryKey(delivery);
|
|
127
|
+
const forecast = await forecastDelivery(ctx, delivery, options);
|
|
128
|
+
if (forecast.status !== "send") return forecast;
|
|
129
|
+
if (reached.has(key)) return { status: "done" };
|
|
130
|
+
reached.add(key);
|
|
131
|
+
return forecast;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function countForecast(counts: Counts, forecast: DeliveryForecast): void {
|
|
135
|
+
switch (forecast.status) {
|
|
136
|
+
case "send":
|
|
137
|
+
counts.sent += 1;
|
|
138
|
+
return;
|
|
139
|
+
case "rejected":
|
|
140
|
+
counts.rejected[forecast.reason] += 1;
|
|
141
|
+
return;
|
|
142
|
+
case "done":
|
|
143
|
+
counts.done += 1;
|
|
144
|
+
return;
|
|
145
|
+
case "in-flight":
|
|
146
|
+
counts.inFlight += 1;
|
|
147
|
+
return;
|
|
148
|
+
case "uncertain":
|
|
149
|
+
counts.uncertain += 1;
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function countOutcome(counts: Counts, outcome: DeliveryOutcome): void {
|
|
155
|
+
switch (outcome.status) {
|
|
156
|
+
case "sent":
|
|
157
|
+
counts.sent += 1;
|
|
158
|
+
return;
|
|
159
|
+
case "rejected":
|
|
160
|
+
counts.rejected[outcome.reason] += 1;
|
|
161
|
+
return;
|
|
162
|
+
case "done":
|
|
163
|
+
counts.done += 1;
|
|
164
|
+
return;
|
|
165
|
+
case "in-flight":
|
|
166
|
+
counts.inFlight += 1;
|
|
167
|
+
return;
|
|
168
|
+
case "retry-later":
|
|
169
|
+
case "halted":
|
|
170
|
+
counts.retryLater += 1;
|
|
171
|
+
return;
|
|
172
|
+
case "uncertain":
|
|
173
|
+
counts.uncertain += 1;
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function emptyRejections(): Record<MailingErrorCode, number> {
|
|
179
|
+
return { "mailing.invalid_input": 0, "mailing.rejected": 0, "mailing.unavailable": 0, "mailing.suppressed": 0, "mailing.provider_refused": 0, "mailing.quota_exceeded": 0 };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function wait(ms: number): Promise<void> {
|
|
183
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
184
|
+
}
|
package/src/server/send-mail.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// or the result, and `headers` cannot replace the envelope or the sender (a mail's own reply-to is
|
|
4
4
|
// the `replyTo` field, checked like `to`). List mail (any kind but `transactional`) also gets a signed unsubscribe
|
|
5
5
|
// link in a footer and the RFC 8058 headers, and is refused for a recipient who unsubscribed.
|
|
6
|
-
import { err, ok, type SoftureConfig } from "@softure-ai/core";
|
|
6
|
+
import { err, ok, type Err, type Ok, type SoftureConfig } from "@softure-ai/core";
|
|
7
7
|
import type { Queryable } from "@softure-ai/db";
|
|
8
8
|
import {
|
|
9
9
|
TRANSACTIONAL_KIND,
|
|
@@ -20,7 +20,7 @@ import type { MailingMessages } from "../messages/index.js";
|
|
|
20
20
|
import { addHtmlFooter, addTextFooter, getListUnsubscribeHeaders } from "./list-mail.js";
|
|
21
21
|
import { getMailingModule, getMailingOptions } from "./options.js";
|
|
22
22
|
import { isSuppressed } from "./suppressions.js";
|
|
23
|
-
import { buildUnsubscribeLinks, MIN_UNSUBSCRIBE_SECRET_LENGTH, readUnsubscribeSecrets, UNSUBSCRIBE_SECRET_ENV } from "./unsubscribe-link.js";
|
|
23
|
+
import { buildUnsubscribeLinks, MIN_UNSUBSCRIBE_SECRET_LENGTH, readUnsubscribeSecrets, UNSUBSCRIBE_SECRET_ENV, type Env } from "./unsubscribe-link.js";
|
|
24
24
|
import { validateMail, type ValidMail } from "./validate-mail.js";
|
|
25
25
|
|
|
26
26
|
/**
|
|
@@ -32,8 +32,6 @@ export interface MailContext {
|
|
|
32
32
|
readonly db?: Queryable;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
type MailContent = Omit<ProviderMessage, "from" | "replyTo" | "idempotencyKey">;
|
|
36
|
-
|
|
37
35
|
/**
|
|
38
36
|
* Sends `mail` and resolves with the provider's message id or a `mailing.*` code:
|
|
39
37
|
* `invalid_input` (fix the input), `rejected` (do not retry), `unavailable` (retry later with the
|
|
@@ -43,26 +41,24 @@ type MailContent = Omit<ProviderMessage, "from" | "replyTo" | "idempotencyKey">;
|
|
|
43
41
|
* Throws only when the module is not enabled, or for list mail without `context.db`.
|
|
44
42
|
*/
|
|
45
43
|
export async function sendMail(context: MailContext, mail: OutgoingMail, options: SendMailOptions = {}): Promise<SendMailResult> {
|
|
46
|
-
const {
|
|
44
|
+
const { provider, timeoutMs } = getMailingOptions(context.config);
|
|
47
45
|
|
|
48
46
|
const validation = validateMail(mail, options);
|
|
49
47
|
if (!validation.ok) {
|
|
50
48
|
logFailure(provider, "mailing.invalid_input", { fields: validation.fields });
|
|
51
49
|
return err("mailing.invalid_input");
|
|
52
50
|
}
|
|
53
|
-
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
return err(listMail.error);
|
|
51
|
+
const valid = validation.value;
|
|
52
|
+
const secret = readUnsubscribeSecrets().current;
|
|
53
|
+
if (valid.kind !== TRANSACTIONAL_KIND) {
|
|
54
|
+
const refusal = await checkListMail(context, valid, secret);
|
|
55
|
+
if (refusal !== null) {
|
|
56
|
+
logFailure(provider, refusal.error, { cause: refusal.cause });
|
|
57
|
+
return err(refusal.error);
|
|
61
58
|
}
|
|
62
|
-
message = listMail.value;
|
|
63
59
|
}
|
|
64
60
|
|
|
65
|
-
const outcome = await callProvider(provider,
|
|
61
|
+
const outcome = await callProvider(provider, composeMessage(context.config, valid, secret), timeoutMs);
|
|
66
62
|
if (outcome.status === "sent") {
|
|
67
63
|
return ok({ id: outcome.id, provider: provider.name });
|
|
68
64
|
}
|
|
@@ -71,6 +67,33 @@ export async function sendMail(context: MailContext, mail: OutgoingMail, options
|
|
|
71
67
|
return outcome.httpStatus === undefined ? err(code) : { ...err(code), httpStatus: outcome.httpStatus };
|
|
72
68
|
}
|
|
73
69
|
|
|
70
|
+
/** Why `previewMail` cannot render a mail: invalid input (with the fields), or list mail without the secret. */
|
|
71
|
+
export type MailPreviewFailure =
|
|
72
|
+
| (Err<"mailing.invalid_input"> & { readonly fields: readonly string[] })
|
|
73
|
+
| (Err<"mailing.unavailable"> & { readonly cause: "no_unsubscribe_secret" });
|
|
74
|
+
|
|
75
|
+
export type MailPreviewResult = Ok<ProviderMessage> | MailPreviewFailure;
|
|
76
|
+
|
|
77
|
+
export interface PreviewMailOptions extends SendMailOptions {
|
|
78
|
+
/** Where the unsubscribe secret is read. Default: `process.env`. */
|
|
79
|
+
readonly env?: Env;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Exactly what `sendMail` would hand the provider for `mail`: the sender, the reply-to, and for list mail the
|
|
84
|
+
* footer with a link signed for `mail.to` and the RFC 8058 headers. Sends nothing, logs nothing and needs no
|
|
85
|
+
* database: it does not check the suppression list, so it renders a mail `sendMail` would refuse as suppressed.
|
|
86
|
+
* The links in it are credentials for `mail.to`. Throws only when the module is not enabled.
|
|
87
|
+
*/
|
|
88
|
+
export function previewMail(config: SoftureConfig, mail: OutgoingMail, options: PreviewMailOptions = {}): MailPreviewResult {
|
|
89
|
+
const { env, ...sendOptions } = options;
|
|
90
|
+
const validation = validateMail(mail, sendOptions);
|
|
91
|
+
if (!validation.ok) return { ...err("mailing.invalid_input"), fields: validation.fields };
|
|
92
|
+
const secret = readUnsubscribeSecrets(env).current;
|
|
93
|
+
if (validation.value.kind !== TRANSACTIONAL_KIND && secret === null) return { ...err("mailing.unavailable"), cause: "no_unsubscribe_secret" };
|
|
94
|
+
return ok(composeMessage(config, validation.value, secret));
|
|
95
|
+
}
|
|
96
|
+
|
|
74
97
|
const FAILURE_CODES: Readonly<Record<ProviderFailureStatus, MailingErrorCode>> = {
|
|
75
98
|
rejected: "mailing.rejected",
|
|
76
99
|
unavailable: "mailing.unavailable",
|
|
@@ -78,44 +101,47 @@ const FAILURE_CODES: Readonly<Record<ProviderFailureStatus, MailingErrorCode>> =
|
|
|
78
101
|
quota_exceeded: "mailing.quota_exceeded",
|
|
79
102
|
};
|
|
80
103
|
|
|
81
|
-
type
|
|
82
|
-
| { readonly ok: true; readonly value: MailContent }
|
|
83
|
-
| { readonly ok: false; readonly error: "mailing.suppressed" | "mailing.unavailable"; readonly cause: string };
|
|
104
|
+
type ListMailRefusal = { readonly error: "mailing.suppressed" | "mailing.unavailable"; readonly cause: string };
|
|
84
105
|
|
|
85
106
|
/**
|
|
86
|
-
*
|
|
87
|
-
*
|
|
107
|
+
* Why a list mail must not go out, or `null` when it may. Fails closed: without the secret, or when the suppression
|
|
108
|
+
* list cannot be read, nothing is sent.
|
|
88
109
|
*/
|
|
89
|
-
async function
|
|
110
|
+
async function checkListMail(context: MailContext, mail: ValidMail, secret: string | null): Promise<ListMailRefusal | null> {
|
|
90
111
|
if (context.db === undefined) {
|
|
91
112
|
throw new Error("@softure-ai/mailing: list mail needs the database handle; call sendMail({ config, db }, ...)");
|
|
92
113
|
}
|
|
93
|
-
const secret = readUnsubscribeSecrets().current;
|
|
94
114
|
if (secret === null) {
|
|
95
115
|
console.error(`mailing: list mail needs ${UNSUBSCRIBE_SECRET_ENV} (at least ${String(MIN_UNSUBSCRIBE_SECRET_LENGTH)} characters) to sign unsubscribe links`);
|
|
96
|
-
return {
|
|
116
|
+
return { error: "mailing.unavailable", cause: "no_unsubscribe_secret" };
|
|
97
117
|
}
|
|
98
|
-
let suppressed: boolean;
|
|
99
118
|
try {
|
|
100
|
-
|
|
119
|
+
return (await isSuppressed({ db: context.db }, mail.to)) ? { error: "mailing.suppressed", cause: "unsubscribed" } : null;
|
|
101
120
|
} catch {
|
|
102
121
|
// The error text can carry the query parameters, the recipient key among them.
|
|
103
|
-
return {
|
|
122
|
+
return { error: "mailing.unavailable", cause: "suppressions_unreadable" };
|
|
104
123
|
}
|
|
105
|
-
|
|
124
|
+
}
|
|
106
125
|
|
|
107
|
-
|
|
126
|
+
/**
|
|
127
|
+
* The message the provider receives: the configured sender, the mail's or the configured reply-to, and for list
|
|
128
|
+
* mail the footer and headers signed with `secret` (which the caller checked is set).
|
|
129
|
+
*/
|
|
130
|
+
function composeMessage(config: SoftureConfig, mail: ValidMail, secret: string | null): ProviderMessage {
|
|
131
|
+
const { from, replyTo } = getMailingOptions(config);
|
|
132
|
+
const { idempotencyKey, kind, replyTo: mailReplyTo, ...content } = mail;
|
|
133
|
+
const envelope = { from, replyTo: mailReplyTo ?? replyTo ?? null, idempotencyKey };
|
|
134
|
+
if (kind === TRANSACTIONAL_KIND || secret === null) return { ...content, ...envelope };
|
|
135
|
+
const links = buildUnsubscribeLinks(config, mail.to, secret);
|
|
108
136
|
// The module factory merged the dictionaries; their shape is the module's own.
|
|
109
|
-
const copy = (getMailingModule(
|
|
137
|
+
const copy = (getMailingModule(config).messages[config.locale] as MailingMessages).footer;
|
|
110
138
|
return {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
headers: { ...mail.headers, ...getListUnsubscribeHeaders(links) },
|
|
118
|
-
},
|
|
139
|
+
...envelope,
|
|
140
|
+
to: mail.to,
|
|
141
|
+
subject: mail.subject,
|
|
142
|
+
text: addTextFooter(mail.text, links, copy),
|
|
143
|
+
html: mail.html === null ? null : addHtmlFooter(mail.html, links, copy),
|
|
144
|
+
headers: { ...mail.headers, ...getListUnsubscribeHeaders(links) },
|
|
119
145
|
};
|
|
120
146
|
}
|
|
121
147
|
|
|
@@ -23,6 +23,33 @@ export async function isSuppressed(ctx: Pick<SuppressionContext, "db">, address:
|
|
|
23
23
|
return rows.length > 0;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/** How many recipient keys one query asks about, so a long list never builds an oversized statement. */
|
|
27
|
+
const SUPPRESSION_LOOKUP_BATCH = 500;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Which of `addresses` unsubscribed, each returned as it was given; e.g. a report that splits a list into
|
|
31
|
+
* active and suppressed recipients. Throws on a database failure: callers decide how to fail.
|
|
32
|
+
*/
|
|
33
|
+
export async function findSuppressedAddresses(ctx: Pick<SuppressionContext, "db">, addresses: readonly string[]): Promise<Set<string>> {
|
|
34
|
+
const byKey = new Map<string, string[]>();
|
|
35
|
+
for (const address of addresses) {
|
|
36
|
+
const key = getRecipientKey(address);
|
|
37
|
+
byKey.set(key, [...(byKey.get(key) ?? []), address]);
|
|
38
|
+
}
|
|
39
|
+
const keys = [...byKey.keys()];
|
|
40
|
+
const suppressed = new Set<string>();
|
|
41
|
+
for (let start = 0; start < keys.length; start += SUPPRESSION_LOOKUP_BATCH) {
|
|
42
|
+
const rows = await ctx.db
|
|
43
|
+
.select({ recipientKey: suppressions.recipientKey })
|
|
44
|
+
.from(suppressions)
|
|
45
|
+
.where(inArray(suppressions.recipientKey, keys.slice(start, start + SUPPRESSION_LOOKUP_BATCH)));
|
|
46
|
+
for (const row of rows) {
|
|
47
|
+
for (const address of byKey.get(row.recipientKey) ?? []) suppressed.add(address);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return suppressed;
|
|
51
|
+
}
|
|
52
|
+
|
|
26
53
|
/** Records an opt-out by recipient key. Throws on a database failure. */
|
|
27
54
|
async function recordSuppression(ctx: SuppressionContext, recipientKey: string, source: SuppressionSource): Promise<void> {
|
|
28
55
|
await ctx.db.insert(suppressions).values({ recipientKey, source, createdAt: ctx.clock.now() }).onConflictDoNothing();
|
|
@@ -93,7 +120,7 @@ async function verifyLink(ctx: SuppressionContext, link: UnsubscribeLink | Unsub
|
|
|
93
120
|
}
|
|
94
121
|
const legacy = getMailingOptions(ctx.config).legacyUnsubscribe;
|
|
95
122
|
if (legacy === undefined) return null;
|
|
96
|
-
const address: unknown = await legacy.verify(link.values, ctx);
|
|
123
|
+
const address: unknown = await legacy.verify(link.values, ctx, env);
|
|
97
124
|
if (typeof address !== "string" || address.trim() === "") return null;
|
|
98
125
|
return { recipientKey: getRecipientKey(address), link: { scheme: "legacy", values: link.values } };
|
|
99
126
|
}
|