@softure-ai/mailing 0.1.11 → 0.1.13
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 +42 -0
- package/README.md +182 -9
- package/dist/cli/run.d.ts +1 -1
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +179 -25
- 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 +13 -8
- package/dist/server/campaigns.d.ts.map +1 -1
- package/dist/server/campaigns.js +15 -14
- package/dist/server/campaigns.js.map +1 -1
- package/dist/server/deliveries.d.ts +32 -0
- package/dist/server/deliveries.d.ts.map +1 -1
- package/dist/server/deliveries.js +36 -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 +7 -5
- 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 +62 -0
- package/dist/server/run-deliveries.d.ts.map +1 -0
- package/dist/server/run-deliveries.js +133 -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 +184 -27
- 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 +25 -21
- package/src/server/deliveries.ts +53 -0
- package/src/server/dns.ts +60 -4
- package/src/server/index.ts +9 -3
- package/src/server/operator-output.ts +20 -0
- package/src/server/options.ts +6 -0
- package/src/server/run-deliveries.ts +193 -0
- package/src/server/send-mail.ts +63 -37
- package/src/server/suppressions.ts +28 -1
package/src/options.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// The options an app passes to `mailing({ ... })` in softure.config.ts, parsed at startup.
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import { isMailbox, isSingleAddress } from "./address.js";
|
|
3
|
+
import { isMailbox, isMailKind, isSingleAddress } from "./address.js";
|
|
4
4
|
import type { CampaignRecipientFilter, CampaignRecipientSource, LegacyUnsubscribe, MailProvider, OnUnsubscribedHook } from "./contract.js";
|
|
5
5
|
|
|
6
6
|
export const DEFAULT_TIMEOUT_MS = 10_000;
|
|
@@ -42,6 +42,13 @@ const legacyUnsubscribeSchema = z.strictObject({
|
|
|
42
42
|
|
|
43
43
|
const HOUR_MS = 60 * 60_000;
|
|
44
44
|
|
|
45
|
+
const mailKind = z.string().refine(isMailKind, "must be a kebab-case mail kind such as newsletter");
|
|
46
|
+
|
|
47
|
+
/** Alias to kind; an alias never names a kind another alias points at, so one lookup resolves it. */
|
|
48
|
+
const kindAliasesSchema = z
|
|
49
|
+
.record(mailKind, mailKind)
|
|
50
|
+
.refine((aliases) => Object.keys(aliases).every((alias) => !Object.values(aliases).includes(alias)), "must not use a kind as an alias: aliases resolve in one step");
|
|
51
|
+
|
|
45
52
|
function isMailProvider(value: unknown): value is MailProvider {
|
|
46
53
|
if (typeof value !== "object" || value === null) return false;
|
|
47
54
|
const candidate = value as { name?: unknown; send?: unknown };
|
|
@@ -56,6 +63,16 @@ export const mailingOptionsSchema = z.strictObject({
|
|
|
56
63
|
from: z.string().trim().refine(isMailbox, "must be an address or Name <address>, e.g. Plan <hello@example.com>"),
|
|
57
64
|
/** Where replies go; may sit on another domain than `from` (DMARC does not check it). */
|
|
58
65
|
replyTo: z.string().trim().refine(isSingleAddress, "must be one address, e.g. support@example.com").optional(),
|
|
66
|
+
/**
|
|
67
|
+
* The only address `softure-mail test` sends to, e.g. the operator's inbox. Without it the command refuses to send,
|
|
68
|
+
* so a test never reaches a customer.
|
|
69
|
+
*/
|
|
70
|
+
testAddress: z.string().trim().refine(isSingleAddress, "must be one address, e.g. ops@example.com").optional(),
|
|
71
|
+
/**
|
|
72
|
+
* Short names for mail kinds in campaign files and `softure-mail test --kind`, e.g. `{ news: "newsletter" }`. The
|
|
73
|
+
* campaign is stored and sent under the kind the alias names.
|
|
74
|
+
*/
|
|
75
|
+
kindAliases: kindAliasesSchema.optional(),
|
|
59
76
|
/** The adapter that delivers mail: `resend()`, or `fakeMailProvider()` from `/testing`. */
|
|
60
77
|
provider: z.custom<MailProvider>(isMailProvider, "must be a mail provider such as resend()"),
|
|
61
78
|
/** How long one send may take before it reads as `mailing.unavailable`. */
|
|
@@ -11,8 +11,12 @@
|
|
|
11
11
|
//
|
|
12
12
|
// `html` is optional and names a file next to the content file, rendered however the operator
|
|
13
13
|
// likes. There is no markdown: the text body is sent as written.
|
|
14
|
+
import type { SoftureConfig } from "@softure-ai/core";
|
|
14
15
|
import { isMailKind, MAX_SUBJECT_LENGTH } from "../address.js";
|
|
15
16
|
import { TRANSACTIONAL_KIND } from "../contract.js";
|
|
17
|
+
import type { MailingMessages } from "../messages/index.js";
|
|
18
|
+
import { getMailingModule, getMailingRoutes } from "./options.js";
|
|
19
|
+
import { RECIPIENT_PARAM, SIGNATURE_PARAM } from "./unsubscribe-link.js";
|
|
16
20
|
|
|
17
21
|
/** A campaign's content, as `sendCampaign` sends it. */
|
|
18
22
|
export interface CampaignContent {
|
|
@@ -75,8 +79,12 @@ export function parseCampaignFile(source: string): CampaignFileResult {
|
|
|
75
79
|
return problems.length === 0 ? { ok: true, value: file } : { ok: false, problems };
|
|
76
80
|
}
|
|
77
81
|
|
|
78
|
-
/**
|
|
79
|
-
|
|
82
|
+
/**
|
|
83
|
+
* What makes `content` unsendable as a campaign; empty when it is fine. With the app's config it also refuses a
|
|
84
|
+
* body that carries an unsubscribe link or footer pasted from a sent mail: the module adds its own to every mail,
|
|
85
|
+
* and a pasted link is a credential that would let every recipient unsubscribe the person it was signed for.
|
|
86
|
+
*/
|
|
87
|
+
export function getCampaignProblems(content: CampaignContent, config?: SoftureConfig): string[] {
|
|
80
88
|
const problems: string[] = [];
|
|
81
89
|
if (content.id.length > MAX_CAMPAIGN_ID_LENGTH || !CAMPAIGN_ID.test(content.id)) {
|
|
82
90
|
problems.push("id: kebab-case, at most 64 characters, e.g. 2026-10-launch");
|
|
@@ -89,6 +97,32 @@ export function getCampaignProblems(content: CampaignContent): string[] {
|
|
|
89
97
|
}
|
|
90
98
|
if (content.text.trim() === "") problems.push("the text body under the frontmatter is empty");
|
|
91
99
|
if (content.html?.trim() === "") problems.push("the HTML body is empty");
|
|
100
|
+
if (config !== undefined) problems.push(...getPastedFooterProblems(content, config));
|
|
101
|
+
return problems;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A signed pair of link parameters, raw or HTML-escaped (`&`). */
|
|
105
|
+
const SIGNED_LINK = new RegExp(`[?&](?:amp;)?${RECIPIENT_PARAM}=[A-Za-z0-9_-]{43}&(?:amp;)?${SIGNATURE_PARAM}=`);
|
|
106
|
+
|
|
107
|
+
function getPastedFooterProblems(content: CampaignContent, config: SoftureConfig): string[] {
|
|
108
|
+
const routes = getMailingRoutes(config);
|
|
109
|
+
// The app's own links only: a link to another site's unsubscribe page is the author's business.
|
|
110
|
+
const linkMarkers = [`${config.appOrigin}${routes.unsubscribe}?`, `${config.appOrigin}${routes.oneClick}?`];
|
|
111
|
+
// Every locale's footer copy, the app's overrides included: a mail pasted from another locale counts too.
|
|
112
|
+
const footers = Object.values(getMailingModule(config).messages).map((messages) => (messages as MailingMessages).footer);
|
|
113
|
+
const bodies: [string, string | null][] = [
|
|
114
|
+
["the text body", content.text],
|
|
115
|
+
["the HTML body", content.html],
|
|
116
|
+
];
|
|
117
|
+
const problems: string[] = [];
|
|
118
|
+
for (const [name, body] of bodies) {
|
|
119
|
+
if (body === null) continue;
|
|
120
|
+
if (SIGNED_LINK.test(body) || linkMarkers.some((marker) => body.includes(marker))) {
|
|
121
|
+
problems.push(`${name} carries an unsubscribe link; the module adds a link signed for each recipient, remove the pasted one`);
|
|
122
|
+
} else if (footers.some((footer) => body.includes(footer.text) || body.includes(`${footer.htmlLead} <a `))) {
|
|
123
|
+
problems.push(`${name} carries the unsubscribe footer; the module adds it to every mail, remove the pasted one`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
92
126
|
return problems;
|
|
93
127
|
}
|
|
94
128
|
|
package/src/server/campaigns.ts
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
// Campaigns: one list mail to many recipients through the delivery ledger. The campaign row pins
|
|
2
2
|
// the content (a hash of kind, subject and bodies), so a re-run with the same id sends the rest of
|
|
3
3
|
// the same mail and nothing twice; other content under that id is refused. A run stops at the first
|
|
4
|
-
// failure about the sending account (refused key, spent quota),
|
|
5
|
-
//
|
|
4
|
+
// failure about the sending account (refused key, spent quota) and, unless told otherwise, at the first
|
|
5
|
+
// mail the provider could not take now, leaving the rest for the next run, and after `limit` mails
|
|
6
|
+
// reached the provider, counting the rest for the next run.
|
|
6
7
|
import { createHash } from "node:crypto";
|
|
7
|
-
import { err, ok, type Result } from "@softure-ai/core";
|
|
8
|
+
import { err, ok, type Result, type SoftureConfig } from "@softure-ai/core";
|
|
8
9
|
import { and, eq, inArray } from "drizzle-orm";
|
|
9
10
|
import type { CampaignRecipient, MailingErrorCode } from "../contract.js";
|
|
10
11
|
import { campaigns, deliveries } from "../schema.js";
|
|
11
12
|
import { getCampaignProblems, type CampaignContent } from "./campaign-file.js";
|
|
12
|
-
import { deliverOnce, type DeliverOptions, type DeliveryContext, type DeliveryOutcome, type
|
|
13
|
+
import { deliverOnce, getRunHalt, type DeliverOptions, type DeliveryContext, type DeliveryOutcome, type RunHalt } from "./deliveries.js";
|
|
13
14
|
import { getMailingOptions } from "./options.js";
|
|
14
15
|
import { isSuppressed } from "./suppressions.js";
|
|
15
16
|
import { getRecipientKey } from "./unsubscribe-link.js";
|
|
@@ -18,10 +19,7 @@ import { getRecipientKey } from "./unsubscribe-link.js";
|
|
|
18
19
|
const PROVIDER_REASONS: ReadonlySet<MailingErrorCode> = new Set(["mailing.rejected", "mailing.unavailable"]);
|
|
19
20
|
|
|
20
21
|
/** Why a run stopped before the end of the list. */
|
|
21
|
-
export
|
|
22
|
-
readonly reason: HaltingErrorCode;
|
|
23
|
-
readonly httpStatus?: number;
|
|
24
|
-
}
|
|
22
|
+
export type CampaignHalt = RunHalt;
|
|
25
23
|
|
|
26
24
|
/** The campaign id is taken by other content. Give the new content a new id. */
|
|
27
25
|
export type CampaignErrorCode = "mailing.campaign_changed";
|
|
@@ -43,8 +41,9 @@ export interface CampaignSummary {
|
|
|
43
41
|
/** Claimed more than `uncertainClaimMs` ago and never closed: their mail may have gone out. */
|
|
44
42
|
readonly uncertain: number;
|
|
45
43
|
/**
|
|
46
|
-
* Set when the run stopped at a failure about the sending account
|
|
47
|
-
*
|
|
44
|
+
* Set when the run stopped early: at a failure about the sending account (run again once the account can send) or,
|
|
45
|
+
* with `stopOnRetryLater`, at the first mail the provider could not take now (`mailing.unavailable`; run again
|
|
46
|
+
* later). The recipient it happened to and every one after it were left as they were.
|
|
48
47
|
*/
|
|
49
48
|
readonly halted: CampaignHalt | null;
|
|
50
49
|
/**
|
|
@@ -67,6 +66,12 @@ export interface SendCampaignOptions extends DeliverOptions {
|
|
|
67
66
|
* limit.
|
|
68
67
|
*/
|
|
69
68
|
readonly limit?: number;
|
|
69
|
+
/**
|
|
70
|
+
* End the run at the first `retry-later` (the provider is down, timed out or rate limited), reported in `halted` as
|
|
71
|
+
* `mailing.unavailable`. Going on would spend an attempt of every following recipient on the same answer. Default
|
|
72
|
+
* `true`.
|
|
73
|
+
*/
|
|
74
|
+
readonly stopOnRetryLater?: boolean;
|
|
70
75
|
}
|
|
71
76
|
|
|
72
77
|
/** sha256 (hex) of what a recipient reads: kind, subject, text and HTML. */
|
|
@@ -79,7 +84,7 @@ export function getCampaignContentHash(content: CampaignContent): string {
|
|
|
79
84
|
* database failure and for content `getCampaignProblems` refuses.
|
|
80
85
|
*/
|
|
81
86
|
export async function registerCampaign(ctx: DeliveryContext, content: CampaignContent): Promise<Result<undefined, CampaignErrorCode>> {
|
|
82
|
-
assertCampaign(content);
|
|
87
|
+
assertCampaign(content, ctx.config);
|
|
83
88
|
const contentHash = getCampaignContentHash(content);
|
|
84
89
|
await ctx.db
|
|
85
90
|
.insert(campaigns)
|
|
@@ -92,7 +97,8 @@ export async function registerCampaign(ctx: DeliveryContext, content: CampaignCo
|
|
|
92
97
|
/**
|
|
93
98
|
* Sends `campaign` to every recipient that has no outcome yet, one at a time. Recipients who
|
|
94
99
|
* unsubscribed are rejected (`mailing.suppressed`) and never retried; recipients the module's
|
|
95
|
-
* `filterCampaignRecipient` refuses are skipped. Stops at the first `halted` delivery,
|
|
100
|
+
* `filterCampaignRecipient` refuses are skipped. Stops at the first `halted` delivery, unless `stopOnRetryLater` is
|
|
101
|
+
* `false` at the first `retry-later`, and before the recipient
|
|
96
102
|
* after `limit` mails reached the provider. Throws on a database failure, when the filter throws, and (a
|
|
97
103
|
* `RangeError`) for a `limit` that is not a whole number of at least 1.
|
|
98
104
|
*/
|
|
@@ -101,7 +107,7 @@ export async function sendCampaign(
|
|
|
101
107
|
input: { readonly campaign: CampaignContent; readonly recipients: Iterable<string> | AsyncIterable<string> },
|
|
102
108
|
options: SendCampaignOptions = {},
|
|
103
109
|
): Promise<Result<CampaignSummary, CampaignErrorCode>> {
|
|
104
|
-
const { pauseMs = 0, onDelivery, sleep = wait, limit, ...deliverOptions } = options;
|
|
110
|
+
const { pauseMs = 0, onDelivery, sleep = wait, limit, stopOnRetryLater = true, ...deliverOptions } = options;
|
|
105
111
|
if (limit !== undefined && !(Number.isInteger(limit) && limit >= 1)) {
|
|
106
112
|
throw new RangeError(`@softure-ai/mailing: sendCampaign limit must be a whole number of at least 1, got ${String(limit)}`);
|
|
107
113
|
}
|
|
@@ -136,11 +142,9 @@ export async function sendCampaign(
|
|
|
136
142
|
);
|
|
137
143
|
countOutcome(counts, outcome);
|
|
138
144
|
onDelivery?.(outcome);
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
return ok({ recipients: seen.size, ...counts, halted: { reason: outcome.reason, ...httpStatus }, remaining: null });
|
|
143
|
-
}
|
|
145
|
+
const halt = getRunHalt(outcome, stopOnRetryLater);
|
|
146
|
+
// The rest of the list is not even counted as seen: the summary covers what this run reached.
|
|
147
|
+
if (halt !== null) return ok({ recipients: seen.size, ...counts, halted: halt, remaining: null });
|
|
144
148
|
const reachedProvider = outcome.status === "sent" || outcome.status === "retry-later" || (outcome.status === "rejected" && PROVIDER_REASONS.has(outcome.reason));
|
|
145
149
|
if (reachedProvider) handedToProvider += 1;
|
|
146
150
|
if (reachedProvider && pauseMs > 0) await sleep(pauseMs);
|
|
@@ -202,7 +206,7 @@ export async function planCampaign(
|
|
|
202
206
|
options: Pick<DeliverOptions, "uncertainClaimMs" | "retakeUncertain"> = {},
|
|
203
207
|
): Promise<CampaignPlan> {
|
|
204
208
|
const { campaign } = input;
|
|
205
|
-
assertCampaign(campaign);
|
|
209
|
+
assertCampaign(campaign, ctx.config);
|
|
206
210
|
const stored = await ctx.db.select({ contentHash: campaigns.contentHash }).from(campaigns).where(eq(campaigns.id, campaign.id)).limit(1);
|
|
207
211
|
const contentChanged = stored[0] !== undefined && stored[0].contentHash !== getCampaignContentHash(campaign);
|
|
208
212
|
|
|
@@ -258,8 +262,8 @@ async function countPending(
|
|
|
258
262
|
return { done: closed.size, suppressed, filtered, uncertain, toSend };
|
|
259
263
|
}
|
|
260
264
|
|
|
261
|
-
function assertCampaign(content: CampaignContent): void {
|
|
262
|
-
const problems = getCampaignProblems(content);
|
|
265
|
+
function assertCampaign(content: CampaignContent, config: SoftureConfig): void {
|
|
266
|
+
const problems = getCampaignProblems(content, config);
|
|
263
267
|
if (problems.length > 0) throw new Error(`@softure-ai/mailing: campaign "${content.id}" cannot be sent: ${problems.join("; ")}`);
|
|
264
268
|
}
|
|
265
269
|
|
package/src/server/deliveries.ts
CHANGED
|
@@ -14,7 +14,9 @@ import { DEFAULT_MAX_ATTEMPTS, DEFAULT_STALE_CLAIM_MS, DEFAULT_UNCERTAIN_CLAIM_M
|
|
|
14
14
|
import { deliveries } from "../schema.js";
|
|
15
15
|
import { getMailingOptions } from "./options.js";
|
|
16
16
|
import { sendMail } from "./send-mail.js";
|
|
17
|
+
import { isSuppressed } from "./suppressions.js";
|
|
17
18
|
import { getRecipientKey } from "./unsubscribe-link.js";
|
|
19
|
+
import { validateMail } from "./validate-mail.js";
|
|
18
20
|
|
|
19
21
|
export type DeliveryContext = ModuleContext<Queryable>;
|
|
20
22
|
|
|
@@ -56,6 +58,26 @@ export interface DeliverOptions {
|
|
|
56
58
|
/** The codes that stop a run: the sending account, not the mail, is the problem. */
|
|
57
59
|
export type HaltingErrorCode = "mailing.provider_refused" | "mailing.quota_exceeded";
|
|
58
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Why a run of many deliveries (`runDeliveries`, `sendCampaign`) stopped early: the account cannot send
|
|
63
|
+
* (`HaltingErrorCode`), or the provider answered `retry-later` (`mailing.unavailable`: down, timed out or rate
|
|
64
|
+
* limited) and the run stops on it.
|
|
65
|
+
*/
|
|
66
|
+
export type RunHaltReason = HaltingErrorCode | "mailing.unavailable";
|
|
67
|
+
|
|
68
|
+
/** Why a run stopped early, with the provider's HTTP status when it gave one. */
|
|
69
|
+
export interface RunHalt {
|
|
70
|
+
readonly reason: RunHaltReason;
|
|
71
|
+
readonly httpStatus?: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Why a run stops after this outcome: at `halted`, and at `retry-later` when it stops on it; `null` to go on. */
|
|
75
|
+
export function getRunHalt(outcome: DeliveryOutcome, stopOnRetryLater: boolean): RunHalt | null {
|
|
76
|
+
if (outcome.status !== "halted" && !(stopOnRetryLater && outcome.status === "retry-later")) return null;
|
|
77
|
+
const reason = outcome.status === "halted" ? outcome.reason : "mailing.unavailable";
|
|
78
|
+
return outcome.httpStatus === undefined ? { reason } : { reason, httpStatus: outcome.httpStatus };
|
|
79
|
+
}
|
|
80
|
+
|
|
59
81
|
export type DeliveryOutcome =
|
|
60
82
|
/** Sent now; `id` is the provider's message id. */
|
|
61
83
|
| { readonly status: "sent"; readonly id: string }
|
|
@@ -115,6 +137,37 @@ export async function deliverOnce(ctx: DeliveryContext, delivery: Delivery, opti
|
|
|
115
137
|
return { status: "rejected", reason: result.error, ...httpStatus };
|
|
116
138
|
}
|
|
117
139
|
|
|
140
|
+
/** What `deliverOnce` would do with a delivery now, read without writing or sending. */
|
|
141
|
+
export type DeliveryForecast =
|
|
142
|
+
| { readonly status: "send" }
|
|
143
|
+
| { readonly status: "rejected"; readonly reason: "mailing.invalid_input" | "mailing.suppressed" }
|
|
144
|
+
| { readonly status: "done" }
|
|
145
|
+
| { readonly status: "in-flight" }
|
|
146
|
+
| { readonly status: "uncertain" };
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Reads the ledger, checks the mail and, for list mail, the suppression list, as `deliverOnce` would, and writes
|
|
150
|
+
* and sends nothing. A provider's answer cannot be foreseen: `send` means the mail would reach it. Throws like
|
|
151
|
+
* `deliverOnce` for a malformed scope or kind, and on a database failure.
|
|
152
|
+
*/
|
|
153
|
+
export async function forecastDelivery(ctx: DeliveryContext, delivery: Delivery, options: DeliverOptions = {}): Promise<DeliveryForecast> {
|
|
154
|
+
const windows = resolveClaimWindows(ctx, options);
|
|
155
|
+
const kind = delivery.mail.kind ?? TRANSACTIONAL_KIND;
|
|
156
|
+
assertDelivery(delivery, kind);
|
|
157
|
+
const key = { scope: delivery.scope, recipientKey: getRecipientKey(delivery.mail.to) };
|
|
158
|
+
const rows = await ctx.db.select({ status: deliveries.status, claimedAt: deliveries.claimedAt }).from(deliveries).where(matchKey(key)).limit(1);
|
|
159
|
+
const row = rows[0];
|
|
160
|
+
if (row?.status === "sent" || row?.status === "rejected") return { status: "done" };
|
|
161
|
+
if (row?.status === "claimed") {
|
|
162
|
+
const age = ctx.clock.now().getTime() - row.claimedAt.getTime();
|
|
163
|
+
if (age <= windows.staleClaimMs) return { status: "in-flight" };
|
|
164
|
+
if (age > windows.uncertainClaimMs && !windows.retakeUncertain) return { status: "uncertain" };
|
|
165
|
+
}
|
|
166
|
+
if (!validateMail(delivery.mail).ok) return { status: "rejected", reason: "mailing.invalid_input" };
|
|
167
|
+
if (kind !== TRANSACTIONAL_KIND && (await isSuppressed(ctx, delivery.mail.to))) return { status: "rejected", reason: "mailing.suppressed" };
|
|
168
|
+
return { status: "send" };
|
|
169
|
+
}
|
|
170
|
+
|
|
118
171
|
function isHaltingCode(code: MailingErrorCode): code is HaltingErrorCode {
|
|
119
172
|
return HALTING_ERROR_CODES.has(code);
|
|
120
173
|
}
|
package/src/server/dns.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// The sender domain's DNS, as receivers check it: SPF (RFC 7208), DKIM (RFC 6376) and DMARC
|
|
2
2
|
// (RFC 7489). Without all three, list mail lands in spam or is refused outright. On request it also
|
|
3
3
|
// holds DMARC to a required minimum, checks that the reply-to domain accepts mail (MX) and that the
|
|
4
|
-
// provider's return-path hosts resolve
|
|
4
|
+
// provider's return-path hosts resolve, and that an inbound service (Cloudflare Email Routing)
|
|
5
|
+
// receives the reply domain's mail. The check only reads public records; it cannot see whether
|
|
5
6
|
// the provider signs with the published key.
|
|
6
7
|
import { resolveCname as resolveCnameRecords, resolveMx as resolveMxRecords, resolveTxt as resolveTxtRecords } from "node:dns/promises";
|
|
7
8
|
|
|
@@ -18,6 +19,7 @@ export type DnsFinding =
|
|
|
18
19
|
| "weak"
|
|
19
20
|
| "null-mx"
|
|
20
21
|
| "unexpected-target"
|
|
22
|
+
| "missing-include"
|
|
21
23
|
| "lookup-failed";
|
|
22
24
|
|
|
23
25
|
export interface DnsCheck {
|
|
@@ -38,8 +40,13 @@ export interface SenderDnsReport {
|
|
|
38
40
|
readonly replyTo: DnsCheck | null;
|
|
39
41
|
/** One check per `returnPath` host, in the order given. */
|
|
40
42
|
readonly returnPath: readonly DnsCheck[];
|
|
43
|
+
/** The inbound service's MX and SPF checks, in that order, when `inbound` was given; `[]` otherwise. */
|
|
44
|
+
readonly inbound: readonly DnsCheck[];
|
|
41
45
|
}
|
|
42
46
|
|
|
47
|
+
/** A service that receives the domain's mail: `cloudflare` is Cloudflare Email Routing. */
|
|
48
|
+
export type InboundService = "cloudflare";
|
|
49
|
+
|
|
43
50
|
export type ResolveTxt = (hostname: string) => Promise<string[][]>;
|
|
44
51
|
export interface MxRecord {
|
|
45
52
|
readonly exchange: string;
|
|
@@ -87,6 +94,11 @@ export interface CheckSenderDnsOptions {
|
|
|
87
94
|
readonly replyTo?: string;
|
|
88
95
|
/** Hosts that must resolve as a CNAME or with MX records; `resendReturnPath(domain)` gives Resend's. */
|
|
89
96
|
readonly returnPath?: readonly ReturnPathHost[];
|
|
97
|
+
/**
|
|
98
|
+
* Checks that this service receives mail for the reply-to domain (else the checked domain): every MX record is the
|
|
99
|
+
* service's, and the one SPF record includes the service's, so replies arrive and forwarding passes SPF.
|
|
100
|
+
*/
|
|
101
|
+
readonly inbound?: InboundService;
|
|
90
102
|
readonly resolveTxt?: ResolveTxt;
|
|
91
103
|
readonly resolveMx?: ResolveMx;
|
|
92
104
|
readonly resolveCname?: ResolveCname;
|
|
@@ -109,6 +121,11 @@ export function resendReturnPath(domain: string): ReturnPathHost[] {
|
|
|
109
121
|
];
|
|
110
122
|
}
|
|
111
123
|
|
|
124
|
+
/** Cloudflare Email Routing: its MX hosts (`route1.mx.cloudflare.net`, ...) and the SPF include it asks for. */
|
|
125
|
+
export const CLOUDFLARE_INBOUND = { mxDomain: "mx.cloudflare.net", spfInclude: "_spf.mx.cloudflare.net" } as const;
|
|
126
|
+
|
|
127
|
+
const INBOUND_SERVICES: Readonly<Record<InboundService, { readonly mxDomain: string; readonly spfInclude: string }>> = { cloudflare: CLOUDFLARE_INBOUND };
|
|
128
|
+
|
|
112
129
|
/** Reads the SPF, DKIM and DMARC records of `domain`. Never throws: a failed lookup is a finding. */
|
|
113
130
|
export async function checkSenderDns(domain: string, options: CheckSenderDnsOptions = {}): Promise<SenderDnsReport> {
|
|
114
131
|
const name = normalizeHost(domain);
|
|
@@ -116,14 +133,16 @@ export async function checkSenderDns(domain: string, options: CheckSenderDnsOpti
|
|
|
116
133
|
const lookup = (host: string) => lookupTxt(resolveTxt, host);
|
|
117
134
|
const lookupMx = (host: string) => lookupRecords(options.resolveMx ?? resolveMxRecords, host);
|
|
118
135
|
const lookupCname = (host: string) => lookupRecords(options.resolveCname ?? resolveCnameRecords, host);
|
|
119
|
-
const
|
|
136
|
+
const replyDomain = options.replyTo === undefined ? null : normalizeHost(getSenderDomain(options.replyTo));
|
|
137
|
+
const [spf, dkim, dmarc, replyTo, returnPath, inbound] = await Promise.all([
|
|
120
138
|
checkSpf(lookup, options.spfHosts ?? [name]),
|
|
121
139
|
checkDkim(lookup, (options.dkimSelectors ?? DEFAULT_DKIM_SELECTORS).map((selector) => `${selector}._domainkey.${name}`)),
|
|
122
140
|
checkDmarc(lookup, name, options.expectDmarc),
|
|
123
|
-
|
|
141
|
+
replyDomain === null ? null : checkReplyPath(lookup, lookupMx, replyDomain),
|
|
124
142
|
Promise.all((options.returnPath ?? []).map((path) => checkReturnPath(lookupCname, lookupMx, path))),
|
|
143
|
+
options.inbound === undefined ? [] : checkInbound({ lookup, lookupMx }, replyDomain ?? name, INBOUND_SERVICES[options.inbound]),
|
|
125
144
|
]);
|
|
126
|
-
return { domain: name, spf, dkim, dmarc, replyTo, returnPath };
|
|
145
|
+
return { domain: name, spf, dkim, dmarc, replyTo, returnPath, inbound };
|
|
127
146
|
}
|
|
128
147
|
|
|
129
148
|
/** The domain part of a sender (`Plan <hello@mail.example.com>` gives `mail.example.com`). */
|
|
@@ -309,3 +328,40 @@ async function checkReturnPath(lookupCname: CnameLookup, lookupMx: MxLookup, pat
|
|
|
309
328
|
if (mx.kind === "none" || isNullMx(mx.records)) return { status: "fail", finding: "missing", host, record: null };
|
|
310
329
|
return { status: "pass", finding: "found", host, record: formatMx(mx.records) };
|
|
311
330
|
}
|
|
331
|
+
|
|
332
|
+
/** Whether `host` is `domain` or under it. Strings, not patterns: the domain is input. */
|
|
333
|
+
function isUnder(host: string, domain: string): boolean {
|
|
334
|
+
return host === domain || host.endsWith(`.${domain}`);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** The inbound service's MX (every record its own) and SPF (exactly one record, including the service's). */
|
|
338
|
+
async function checkInbound(
|
|
339
|
+
{ lookup, lookupMx }: { readonly lookup: Lookup; readonly lookupMx: MxLookup },
|
|
340
|
+
host: string,
|
|
341
|
+
service: { readonly mxDomain: string; readonly spfInclude: string },
|
|
342
|
+
): Promise<DnsCheck[]> {
|
|
343
|
+
const [mx, txt] = await Promise.all([lookupMx(host), lookup(host)]);
|
|
344
|
+
return [checkInboundMx(mx, host, service.mxDomain), checkInboundSpf(txt, host, service.spfInclude)];
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function checkInboundMx(mx: Answer<MxRecord>, host: string, mxDomain: string): DnsCheck {
|
|
348
|
+
if (mx.kind === "error") return { status: "fail", finding: "lookup-failed", host, record: null };
|
|
349
|
+
if (mx.kind === "none") return { status: "fail", finding: "missing", host, record: null };
|
|
350
|
+
const record = formatMx(mx.records);
|
|
351
|
+
if (isNullMx(mx.records)) return { status: "fail", finding: "null-mx", host, record };
|
|
352
|
+
const isService = mx.records.every((entry) => isUnder(normalizeHost(entry.exchange), mxDomain));
|
|
353
|
+
return isService ? { status: "pass", finding: "found", host, record } : { status: "fail", finding: "unexpected-target", host, record };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function checkInboundSpf(txt: TxtAnswer, host: string, include: string): DnsCheck {
|
|
357
|
+
if (txt.kind === "error") return { status: "fail", finding: "lookup-failed", host, record: null };
|
|
358
|
+
const records = txt.kind === "records" ? txt.records.filter(isSpfRecord) : [];
|
|
359
|
+
if (records.length > 1) return { status: "fail", finding: "multiple", host, record: null };
|
|
360
|
+
const record = records[0];
|
|
361
|
+
if (record === undefined) return { status: "fail", finding: "missing", host, record: null };
|
|
362
|
+
const includes = record
|
|
363
|
+
.trim()
|
|
364
|
+
.split(/\s+/)
|
|
365
|
+
.some((term) => term.toLowerCase().replace(/^\+/, "").replace(/\.$/, "") === `include:${include}`);
|
|
366
|
+
return includes ? { status: "pass", finding: "found", host, record } : { status: "fail", finding: "missing-include", host, record };
|
|
367
|
+
}
|
package/src/server/index.ts
CHANGED
|
@@ -24,9 +24,12 @@ export {
|
|
|
24
24
|
type DeliveryContext,
|
|
25
25
|
type DeliveryOutcome,
|
|
26
26
|
type HaltingErrorCode,
|
|
27
|
+
type RunHalt,
|
|
28
|
+
type RunHaltReason,
|
|
27
29
|
} from "./deliveries.js";
|
|
28
30
|
export {
|
|
29
31
|
checkSenderDns,
|
|
32
|
+
CLOUDFLARE_INBOUND,
|
|
30
33
|
DEFAULT_DKIM_SELECTORS,
|
|
31
34
|
getSenderDomain,
|
|
32
35
|
RESEND_RETURN_PATH_DOMAIN,
|
|
@@ -38,6 +41,7 @@ export {
|
|
|
38
41
|
type DnsCheck,
|
|
39
42
|
type DnsCheckStatus,
|
|
40
43
|
type DnsFinding,
|
|
44
|
+
type InboundService,
|
|
41
45
|
type MxRecord,
|
|
42
46
|
type ResolveCname,
|
|
43
47
|
type ResolveMx,
|
|
@@ -57,9 +61,11 @@ export {
|
|
|
57
61
|
type ImportSummary,
|
|
58
62
|
} from "./import-deliveries.js";
|
|
59
63
|
export { addHtmlFooter, addTextFooter, getListUnsubscribeHeaders, SIGNATURE_SEPARATOR } from "./list-mail.js";
|
|
60
|
-
export {
|
|
61
|
-
export {
|
|
62
|
-
export {
|
|
64
|
+
export { maskAddress, redactUnsubscribeSignatures } from "./operator-output.js";
|
|
65
|
+
export { getMailingModule, getMailingOptions, getMailingRoutes, resolveMailKind, type MailingRoutes } from "./options.js";
|
|
66
|
+
export { runDeliveries, type DeliveryRunInput, type DeliveryRunSummary, type RunDeliveriesOptions } from "./run-deliveries.js";
|
|
67
|
+
export { previewMail, sendMail, type MailContext, type MailPreviewFailure, type MailPreviewResult, type PreviewMailOptions } from "./send-mail.js";
|
|
68
|
+
export { findSuppressedAddresses, isSuppressed, liftSuppression, suppressRecipient, unsubscribe, type SuppressionContext } from "./suppressions.js";
|
|
63
69
|
export {
|
|
64
70
|
buildUnsubscribeLinks,
|
|
65
71
|
getRecipientKey,
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// What an operator's terminal may show of a mail: addresses masked, link signatures redacted. Command output ends
|
|
2
|
+
// up in shell history, CI logs and pasted chat messages; a full address is personal data, and a signed unsubscribe
|
|
3
|
+
// link lets whoever holds it unsubscribe that address.
|
|
4
|
+
import { SIGNATURE_PARAM } from "./unsubscribe-link.js";
|
|
5
|
+
|
|
6
|
+
/** `ada@example.org` becomes `a**@example.org`: the first character of the local part, then one `*` per other. */
|
|
7
|
+
export function maskAddress(address: string): string {
|
|
8
|
+
const trimmed = address.trim();
|
|
9
|
+
const at = trimmed.lastIndexOf("@");
|
|
10
|
+
if (at <= 0) return "*".repeat(Math.max(trimmed.length, 1));
|
|
11
|
+
const local = trimmed.slice(0, at);
|
|
12
|
+
return `${local.slice(0, 1)}${"*".repeat(local.length - 1)}${trimmed.slice(at)}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const SIGNATURE = new RegExp(`([?&](?:amp;)?${SIGNATURE_PARAM}=)[A-Za-z0-9_-]+`, "g");
|
|
16
|
+
|
|
17
|
+
/** `text` with the signature of every unsubscribe link replaced by `<signature>`, so the link no longer works. */
|
|
18
|
+
export function redactUnsubscribeSignatures(text: string): string {
|
|
19
|
+
return text.replace(SIGNATURE, "$1<signature>");
|
|
20
|
+
}
|
package/src/server/options.ts
CHANGED
|
@@ -30,3 +30,9 @@ export function getMailingRoutes(config: SoftureConfig): MailingRoutes {
|
|
|
30
30
|
// The manifest declares both routes; the factory only replaces their paths.
|
|
31
31
|
return getMailingModule(config).routes as unknown as MailingRoutes;
|
|
32
32
|
}
|
|
33
|
+
|
|
34
|
+
/** The kind an alias of `mailing({ kindAliases })` names, or `kind` itself when it is no alias. */
|
|
35
|
+
export function resolveMailKind(config: SoftureConfig, kind: string): string {
|
|
36
|
+
const aliases = getMailingOptions(config).kindAliases ?? {};
|
|
37
|
+
return Object.hasOwn(aliases, kind) ? (aliases[kind] ?? kind) : kind;
|
|
38
|
+
}
|