@softure-ai/mailing 0.1.11 → 0.1.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/README.md +173 -6
- package/dist/cli/run.d.ts +1 -1
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +176 -24
- package/dist/cli/run.js.map +1 -1
- package/dist/contract.d.ts +5 -3
- package/dist/contract.d.ts.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/next/index.d.ts +3 -1
- package/dist/next/index.d.ts.map +1 -1
- package/dist/next/index.js +3 -1
- package/dist/next/index.js.map +1 -1
- package/dist/next/pages.d.ts +25 -0
- package/dist/next/pages.d.ts.map +1 -1
- package/dist/next/pages.js +33 -10
- package/dist/next/pages.js.map +1 -1
- package/dist/next/preview-mail.d.ts +5 -0
- package/dist/next/preview-mail.d.ts.map +1 -0
- package/dist/next/preview-mail.js +8 -0
- package/dist/next/preview-mail.js.map +1 -0
- package/dist/next/run-deliveries.d.ts +4 -0
- package/dist/next/run-deliveries.d.ts.map +1 -0
- package/dist/next/run-deliveries.js +7 -0
- package/dist/next/run-deliveries.js.map +1 -0
- package/dist/options.d.ts +3 -1
- package/dist/options.d.ts.map +1 -1
- package/dist/options.js +16 -1
- package/dist/options.js.map +1 -1
- package/dist/server/campaign-file.d.ts +7 -2
- package/dist/server/campaign-file.d.ts.map +1 -1
- package/dist/server/campaign-file.js +35 -15
- package/dist/server/campaign-file.js.map +1 -1
- package/dist/server/campaigns.d.ts.map +1 -1
- package/dist/server/campaigns.js +4 -4
- package/dist/server/campaigns.js.map +1 -1
- package/dist/server/deliveries.d.ts +19 -0
- package/dist/server/deliveries.d.ts.map +1 -1
- package/dist/server/deliveries.js +29 -0
- package/dist/server/deliveries.js.map +1 -1
- package/dist/server/dns.d.ts +15 -1
- package/dist/server/dns.d.ts.map +1 -1
- package/dist/server/dns.js +45 -4
- package/dist/server/dns.js.map +1 -1
- package/dist/server/index.d.ts +6 -4
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +6 -4
- package/dist/server/index.js.map +1 -1
- package/dist/server/operator-output.d.ts +5 -0
- package/dist/server/operator-output.d.ts.map +1 -0
- package/dist/server/operator-output.js +19 -0
- package/dist/server/operator-output.js.map +1 -0
- package/dist/server/options.d.ts +2 -0
- package/dist/server/options.d.ts.map +1 -1
- package/dist/server/options.js +5 -0
- package/dist/server/options.js.map +1 -1
- package/dist/server/run-deliveries.d.ts +55 -0
- package/dist/server/run-deliveries.d.ts.map +1 -0
- package/dist/server/run-deliveries.js +132 -0
- package/dist/server/run-deliveries.js.map +1 -0
- package/dist/server/send-mail.d.ts +21 -2
- package/dist/server/send-mail.d.ts.map +1 -1
- package/dist/server/send-mail.js +50 -30
- package/dist/server/send-mail.js.map +1 -1
- package/dist/server/suppressions.d.ts +5 -0
- package/dist/server/suppressions.d.ts.map +1 -1
- package/dist/server/suppressions.js +27 -1
- package/dist/server/suppressions.js.map +1 -1
- package/migrations/0005_add_sql_surface.sql +44 -0
- package/module.json +1 -1
- package/package.json +1 -1
- package/src/cli/run.ts +179 -24
- package/src/contract.ts +9 -3
- package/src/index.ts +1 -1
- package/src/next/index.ts +3 -1
- package/src/next/pages.tsx +69 -14
- package/src/next/preview-mail.ts +9 -0
- package/src/next/run-deliveries.ts +10 -0
- package/src/options.ts +18 -1
- package/src/server/campaign-file.ts +36 -2
- package/src/server/campaigns.ts +5 -5
- package/src/server/deliveries.ts +33 -0
- package/src/server/dns.ts +60 -4
- package/src/server/index.ts +7 -3
- package/src/server/operator-output.ts +20 -0
- package/src/server/options.ts +6 -0
- package/src/server/run-deliveries.ts +184 -0
- package/src/server/send-mail.ts +63 -37
- package/src/server/suppressions.ts +28 -1
|
@@ -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
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// failure about the sending account (refused key, spent quota), leaving the rest for the next run, and
|
|
5
5
|
// after `limit` mails reached the provider, counting the rest for the next run.
|
|
6
6
|
import { createHash } from "node:crypto";
|
|
7
|
-
import { err, ok, type Result } from "@softure-ai/core";
|
|
7
|
+
import { err, ok, type Result, type SoftureConfig } from "@softure-ai/core";
|
|
8
8
|
import { and, eq, inArray } from "drizzle-orm";
|
|
9
9
|
import type { CampaignRecipient, MailingErrorCode } from "../contract.js";
|
|
10
10
|
import { campaigns, deliveries } from "../schema.js";
|
|
@@ -79,7 +79,7 @@ export function getCampaignContentHash(content: CampaignContent): string {
|
|
|
79
79
|
* database failure and for content `getCampaignProblems` refuses.
|
|
80
80
|
*/
|
|
81
81
|
export async function registerCampaign(ctx: DeliveryContext, content: CampaignContent): Promise<Result<undefined, CampaignErrorCode>> {
|
|
82
|
-
assertCampaign(content);
|
|
82
|
+
assertCampaign(content, ctx.config);
|
|
83
83
|
const contentHash = getCampaignContentHash(content);
|
|
84
84
|
await ctx.db
|
|
85
85
|
.insert(campaigns)
|
|
@@ -202,7 +202,7 @@ export async function planCampaign(
|
|
|
202
202
|
options: Pick<DeliverOptions, "uncertainClaimMs" | "retakeUncertain"> = {},
|
|
203
203
|
): Promise<CampaignPlan> {
|
|
204
204
|
const { campaign } = input;
|
|
205
|
-
assertCampaign(campaign);
|
|
205
|
+
assertCampaign(campaign, ctx.config);
|
|
206
206
|
const stored = await ctx.db.select({ contentHash: campaigns.contentHash }).from(campaigns).where(eq(campaigns.id, campaign.id)).limit(1);
|
|
207
207
|
const contentChanged = stored[0] !== undefined && stored[0].contentHash !== getCampaignContentHash(campaign);
|
|
208
208
|
|
|
@@ -258,8 +258,8 @@ async function countPending(
|
|
|
258
258
|
return { done: closed.size, suppressed, filtered, uncertain, toSend };
|
|
259
259
|
}
|
|
260
260
|
|
|
261
|
-
function assertCampaign(content: CampaignContent): void {
|
|
262
|
-
const problems = getCampaignProblems(content);
|
|
261
|
+
function assertCampaign(content: CampaignContent, config: SoftureConfig): void {
|
|
262
|
+
const problems = getCampaignProblems(content, config);
|
|
263
263
|
if (problems.length > 0) throw new Error(`@softure-ai/mailing: campaign "${content.id}" cannot be sent: ${problems.join("; ")}`);
|
|
264
264
|
}
|
|
265
265
|
|
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
|
|
|
@@ -115,6 +117,37 @@ export async function deliverOnce(ctx: DeliveryContext, delivery: Delivery, opti
|
|
|
115
117
|
return { status: "rejected", reason: result.error, ...httpStatus };
|
|
116
118
|
}
|
|
117
119
|
|
|
120
|
+
/** What `deliverOnce` would do with a delivery now, read without writing or sending. */
|
|
121
|
+
export type DeliveryForecast =
|
|
122
|
+
| { readonly status: "send" }
|
|
123
|
+
| { readonly status: "rejected"; readonly reason: "mailing.invalid_input" | "mailing.suppressed" }
|
|
124
|
+
| { readonly status: "done" }
|
|
125
|
+
| { readonly status: "in-flight" }
|
|
126
|
+
| { readonly status: "uncertain" };
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Reads the ledger, checks the mail and, for list mail, the suppression list, as `deliverOnce` would, and writes
|
|
130
|
+
* and sends nothing. A provider's answer cannot be foreseen: `send` means the mail would reach it. Throws like
|
|
131
|
+
* `deliverOnce` for a malformed scope or kind, and on a database failure.
|
|
132
|
+
*/
|
|
133
|
+
export async function forecastDelivery(ctx: DeliveryContext, delivery: Delivery, options: DeliverOptions = {}): Promise<DeliveryForecast> {
|
|
134
|
+
const windows = resolveClaimWindows(ctx, options);
|
|
135
|
+
const kind = delivery.mail.kind ?? TRANSACTIONAL_KIND;
|
|
136
|
+
assertDelivery(delivery, kind);
|
|
137
|
+
const key = { scope: delivery.scope, recipientKey: getRecipientKey(delivery.mail.to) };
|
|
138
|
+
const rows = await ctx.db.select({ status: deliveries.status, claimedAt: deliveries.claimedAt }).from(deliveries).where(matchKey(key)).limit(1);
|
|
139
|
+
const row = rows[0];
|
|
140
|
+
if (row?.status === "sent" || row?.status === "rejected") return { status: "done" };
|
|
141
|
+
if (row?.status === "claimed") {
|
|
142
|
+
const age = ctx.clock.now().getTime() - row.claimedAt.getTime();
|
|
143
|
+
if (age <= windows.staleClaimMs) return { status: "in-flight" };
|
|
144
|
+
if (age > windows.uncertainClaimMs && !windows.retakeUncertain) return { status: "uncertain" };
|
|
145
|
+
}
|
|
146
|
+
if (!validateMail(delivery.mail).ok) return { status: "rejected", reason: "mailing.invalid_input" };
|
|
147
|
+
if (kind !== TRANSACTIONAL_KIND && (await isSuppressed(ctx, delivery.mail.to))) return { status: "rejected", reason: "mailing.suppressed" };
|
|
148
|
+
return { status: "send" };
|
|
149
|
+
}
|
|
150
|
+
|
|
118
151
|
function isHaltingCode(code: MailingErrorCode): code is HaltingErrorCode {
|
|
119
152
|
return HALTING_ERROR_CODES.has(code);
|
|
120
153
|
}
|
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
|
@@ -27,6 +27,7 @@ export {
|
|
|
27
27
|
} from "./deliveries.js";
|
|
28
28
|
export {
|
|
29
29
|
checkSenderDns,
|
|
30
|
+
CLOUDFLARE_INBOUND,
|
|
30
31
|
DEFAULT_DKIM_SELECTORS,
|
|
31
32
|
getSenderDomain,
|
|
32
33
|
RESEND_RETURN_PATH_DOMAIN,
|
|
@@ -38,6 +39,7 @@ export {
|
|
|
38
39
|
type DnsCheck,
|
|
39
40
|
type DnsCheckStatus,
|
|
40
41
|
type DnsFinding,
|
|
42
|
+
type InboundService,
|
|
41
43
|
type MxRecord,
|
|
42
44
|
type ResolveCname,
|
|
43
45
|
type ResolveMx,
|
|
@@ -57,9 +59,11 @@ export {
|
|
|
57
59
|
type ImportSummary,
|
|
58
60
|
} from "./import-deliveries.js";
|
|
59
61
|
export { addHtmlFooter, addTextFooter, getListUnsubscribeHeaders, SIGNATURE_SEPARATOR } from "./list-mail.js";
|
|
60
|
-
export {
|
|
61
|
-
export {
|
|
62
|
-
export {
|
|
62
|
+
export { maskAddress, redactUnsubscribeSignatures } from "./operator-output.js";
|
|
63
|
+
export { getMailingModule, getMailingOptions, getMailingRoutes, resolveMailKind, type MailingRoutes } from "./options.js";
|
|
64
|
+
export { runDeliveries, type DeliveryRunInput, type DeliveryRunSummary, type RunDeliveriesOptions } from "./run-deliveries.js";
|
|
65
|
+
export { previewMail, sendMail, type MailContext, type MailPreviewFailure, type MailPreviewResult, type PreviewMailOptions } from "./send-mail.js";
|
|
66
|
+
export { findSuppressedAddresses, isSuppressed, liftSuppression, suppressRecipient, unsubscribe, type SuppressionContext } from "./suppressions.js";
|
|
63
67
|
export {
|
|
64
68
|
buildUnsubscribeLinks,
|
|
65
69
|
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
|
+
}
|
|
@@ -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
|
+
}
|