@softure-ai/mailing 0.0.0-stage → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/LICENSE +21 -0
- package/README.md +346 -2
- package/dist/address.d.ts +27 -0
- package/dist/address.d.ts.map +1 -0
- package/dist/address.js +76 -0
- package/dist/address.js.map +1 -0
- package/dist/cli/bin.d.ts +3 -0
- package/dist/cli/bin.d.ts.map +1 -0
- package/dist/cli/bin.js +5 -0
- package/dist/cli/bin.js.map +1 -0
- package/dist/cli/command.d.ts +9 -0
- package/dist/cli/command.d.ts.map +1 -0
- package/dist/cli/command.js +36 -0
- package/dist/cli/command.js.map +1 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +4 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/run.d.ts +31 -0
- package/dist/cli/run.d.ts.map +1 -0
- package/dist/cli/run.js +288 -0
- package/dist/cli/run.js.map +1 -0
- package/dist/contract.d.ts +102 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +3 -0
- package/dist/contract.js.map +1 -0
- package/dist/index.d.ts +47 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +59 -0
- package/dist/index.js.map +1 -0
- package/dist/messages/en.d.ts +26 -0
- package/dist/messages/en.d.ts.map +1 -0
- package/dist/messages/en.js +26 -0
- package/dist/messages/en.js.map +1 -0
- package/dist/messages/index.d.ts +59 -0
- package/dist/messages/index.d.ts.map +1 -0
- package/dist/messages/index.js +11 -0
- package/dist/messages/index.js.map +1 -0
- package/dist/messages/pl.d.ts +3 -0
- package/dist/messages/pl.d.ts.map +1 -0
- package/dist/messages/pl.js +27 -0
- package/dist/messages/pl.js.map +1 -0
- package/dist/next/actions.d.ts +2 -0
- package/dist/next/actions.d.ts.map +1 -0
- package/dist/next/actions.js +35 -0
- package/dist/next/actions.js.map +1 -0
- package/dist/next/context.d.ts +4 -0
- package/dist/next/context.d.ts.map +1 -0
- package/dist/next/context.js +14 -0
- package/dist/next/context.js.map +1 -0
- package/dist/next/deliver-once.d.ts +4 -0
- package/dist/next/deliver-once.d.ts.map +1 -0
- package/dist/next/deliver-once.js +7 -0
- package/dist/next/deliver-once.js.map +1 -0
- package/dist/next/index.d.ts +8 -0
- package/dist/next/index.d.ts.map +1 -0
- package/dist/next/index.js +11 -0
- package/dist/next/index.js.map +1 -0
- package/dist/next/messages.d.ts +5 -0
- package/dist/next/messages.d.ts.map +1 -0
- package/dist/next/messages.js +7 -0
- package/dist/next/messages.js.map +1 -0
- package/dist/next/pages.d.ts +7 -0
- package/dist/next/pages.d.ts.map +1 -0
- package/dist/next/pages.js +40 -0
- package/dist/next/pages.js.map +1 -0
- package/dist/next/params.d.ts +4 -0
- package/dist/next/params.d.ts.map +1 -0
- package/dist/next/params.js +4 -0
- package/dist/next/params.js.map +1 -0
- package/dist/next/route.d.ts +14 -0
- package/dist/next/route.d.ts.map +1 -0
- package/dist/next/route.js +44 -0
- package/dist/next/route.js.map +1 -0
- package/dist/next/send-mail.d.ts +4 -0
- package/dist/next/send-mail.d.ts.map +1 -0
- package/dist/next/send-mail.js +16 -0
- package/dist/next/send-mail.js.map +1 -0
- package/dist/options.d.ts +13 -0
- package/dist/options.d.ts.map +1 -0
- package/dist/options.js +29 -0
- package/dist/options.js.map +1 -0
- package/dist/providers/resend.d.ts +17 -0
- package/dist/providers/resend.d.ts.map +1 -0
- package/dist/providers/resend.js +64 -0
- package/dist/providers/resend.js.map +1 -0
- package/dist/schema.d.ts +350 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +30 -0
- package/dist/schema.js.map +1 -0
- package/dist/server/campaign-file.d.ts +28 -0
- package/dist/server/campaign-file.d.ts.map +1 -0
- package/dist/server/campaign-file.js +87 -0
- package/dist/server/campaign-file.js.map +1 -0
- package/dist/server/campaigns.d.ts +58 -0
- package/dist/server/campaigns.d.ts.map +1 -0
- package/dist/server/campaigns.js +118 -0
- package/dist/server/campaigns.js.map +1 -0
- package/dist/server/deliveries.d.ts +56 -0
- package/dist/server/deliveries.d.ts.map +1 -0
- package/dist/server/deliveries.js +100 -0
- package/dist/server/deliveries.js.map +1 -0
- package/dist/server/dns.d.ts +33 -0
- package/dist/server/dns.d.ts.map +1 -0
- package/dist/server/dns.js +113 -0
- package/dist/server/dns.js.map +1 -0
- package/dist/server/health.d.ts +3 -0
- package/dist/server/health.d.ts.map +1 -0
- package/dist/server/health.js +13 -0
- package/dist/server/health.js.map +1 -0
- package/dist/server/index.d.ts +12 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +14 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/list-mail.d.ts +20 -0
- package/dist/server/list-mail.d.ts.map +1 -0
- package/dist/server/list-mail.js +31 -0
- package/dist/server/list-mail.js.map +1 -0
- package/dist/server/options.d.ts +14 -0
- package/dist/server/options.d.ts.map +1 -0
- package/dist/server/options.js +22 -0
- package/dist/server/options.js.map +1 -0
- package/dist/server/send-mail.d.ts +19 -0
- package/dist/server/send-mail.d.ts.map +1 -0
- package/dist/server/send-mail.js +129 -0
- package/dist/server/send-mail.js.map +1 -0
- package/dist/server/suppressions.d.ts +27 -0
- package/dist/server/suppressions.d.ts.map +1 -0
- package/dist/server/suppressions.js +64 -0
- package/dist/server/suppressions.js.map +1 -0
- package/dist/server/unsubscribe-link.d.ts +51 -0
- package/dist/server/unsubscribe-link.d.ts.map +1 -0
- package/dist/server/unsubscribe-link.js +78 -0
- package/dist/server/unsubscribe-link.js.map +1 -0
- package/dist/server/validate-mail.d.ts +23 -0
- package/dist/server/validate-mail.d.ts.map +1 -0
- package/dist/server/validate-mail.js +52 -0
- package/dist/server/validate-mail.js.map +1 -0
- package/dist/testing/fake-provider.d.ts +24 -0
- package/dist/testing/fake-provider.d.ts.map +1 -0
- package/dist/testing/fake-provider.js +43 -0
- package/dist/testing/fake-provider.js.map +1 -0
- package/dist/testing/index.d.ts +3 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +5 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/testing/outbox.d.ts +6 -0
- package/dist/testing/outbox.d.ts.map +1 -0
- package/dist/testing/outbox.js +32 -0
- package/dist/testing/outbox.js.map +1 -0
- package/migrations/0001_create_suppressions.sql +13 -0
- package/migrations/0002_create_campaigns_and_deliveries.sql +41 -0
- package/module.json +32 -0
- package/package.json +74 -4
- package/src/address.ts +89 -0
- package/src/cli/bin.ts +5 -0
- package/src/cli/command.ts +44 -0
- package/src/cli/index.ts +3 -0
- package/src/cli/run.ts +317 -0
- package/src/contract.ts +108 -0
- package/src/index.ts +85 -0
- package/src/messages/en.ts +25 -0
- package/src/messages/index.ts +15 -0
- package/src/messages/pl.ts +28 -0
- package/src/next/actions.ts +35 -0
- package/src/next/context.ts +15 -0
- package/src/next/deliver-once.ts +10 -0
- package/src/next/index.ts +10 -0
- package/src/next/messages.ts +9 -0
- package/src/next/next-modules.d.ts +12 -0
- package/src/next/pages.tsx +69 -0
- package/src/next/params.ts +6 -0
- package/src/next/route.ts +45 -0
- package/src/next/send-mail.ts +15 -0
- package/src/options.ts +34 -0
- package/src/providers/resend.ts +86 -0
- package/src/schema.ts +40 -0
- package/src/server/campaign-file.ts +101 -0
- package/src/server/campaigns.ts +175 -0
- package/src/server/deliveries.ts +155 -0
- package/src/server/dns.ts +153 -0
- package/src/server/health.ts +14 -0
- package/src/server/index.ts +56 -0
- package/src/server/list-mail.ts +42 -0
- package/src/server/options.ts +32 -0
- package/src/server/send-mail.ts +152 -0
- package/src/server/suppressions.ts +77 -0
- package/src/server/unsubscribe-link.ts +109 -0
- package/src/server/validate-mail.ts +68 -0
- package/src/testing/fake-provider.ts +66 -0
- package/src/testing/index.ts +4 -0
- package/src/testing/outbox.ts +32 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Campaigns: one list mail to many recipients through the delivery ledger. The campaign row pins
|
|
2
|
+
// the content (a hash of kind, subject and bodies), so a re-run with the same id sends the rest of
|
|
3
|
+
// the same mail and nothing twice; other content under that id is refused.
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { err, ok } from "@softure-ai/core";
|
|
6
|
+
import { and, eq, inArray } from "drizzle-orm";
|
|
7
|
+
import { campaigns, deliveries } from "../schema.js";
|
|
8
|
+
import { getCampaignProblems } from "./campaign-file.js";
|
|
9
|
+
import { deliverOnce } from "./deliveries.js";
|
|
10
|
+
import { isSuppressed } from "./suppressions.js";
|
|
11
|
+
import { getRecipientKey } from "./unsubscribe-link.js";
|
|
12
|
+
/** Rejections the provider answered (the others never left the process). */
|
|
13
|
+
const PROVIDER_REASONS = new Set(["mailing.rejected", "mailing.unavailable"]);
|
|
14
|
+
/** sha256 (hex) of what a recipient reads: kind, subject, text and HTML. */
|
|
15
|
+
export function getCampaignContentHash(content) {
|
|
16
|
+
return createHash("sha256").update(JSON.stringify([content.kind, content.subject, content.text, content.html])).digest("hex");
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Records the campaign, or confirms that the stored one has the same content. Throws on a
|
|
20
|
+
* database failure and for content `getCampaignProblems` refuses.
|
|
21
|
+
*/
|
|
22
|
+
export async function registerCampaign(ctx, content) {
|
|
23
|
+
assertCampaign(content);
|
|
24
|
+
const contentHash = getCampaignContentHash(content);
|
|
25
|
+
await ctx.db
|
|
26
|
+
.insert(campaigns)
|
|
27
|
+
.values({ id: content.id, kind: content.kind, subject: content.subject, contentHash, createdAt: ctx.clock.now() })
|
|
28
|
+
.onConflictDoNothing();
|
|
29
|
+
const rows = await ctx.db.select({ contentHash: campaigns.contentHash }).from(campaigns).where(eq(campaigns.id, content.id)).limit(1);
|
|
30
|
+
return rows[0]?.contentHash === contentHash ? ok() : err("mailing.campaign_changed");
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Sends `campaign` to every recipient that has no outcome yet, one at a time. Recipients who
|
|
34
|
+
* unsubscribed are rejected (`mailing.suppressed`) and never retried. Throws on a database failure.
|
|
35
|
+
*/
|
|
36
|
+
export async function sendCampaign(ctx, input, options = {}) {
|
|
37
|
+
const { pauseMs = 0, onDelivery, sleep = wait, ...deliverOptions } = options;
|
|
38
|
+
const { campaign } = input;
|
|
39
|
+
const registered = await registerCampaign(ctx, campaign);
|
|
40
|
+
if (!registered.ok)
|
|
41
|
+
return registered;
|
|
42
|
+
const counts = { sent: 0, done: 0, inFlight: 0, retryLater: 0, rejected: emptyRejections() };
|
|
43
|
+
const seen = new Set();
|
|
44
|
+
const scope = `campaign:${campaign.id}`;
|
|
45
|
+
for await (const address of input.recipients) {
|
|
46
|
+
const recipientKey = getRecipientKey(address);
|
|
47
|
+
if (seen.has(recipientKey))
|
|
48
|
+
continue;
|
|
49
|
+
seen.add(recipientKey);
|
|
50
|
+
const html = campaign.html === null ? {} : { html: campaign.html };
|
|
51
|
+
const outcome = await deliverOnce(ctx, { scope, campaignId: campaign.id, mail: { to: address, subject: campaign.subject, text: campaign.text, kind: campaign.kind, ...html } }, deliverOptions);
|
|
52
|
+
countOutcome(counts, outcome);
|
|
53
|
+
onDelivery?.(outcome);
|
|
54
|
+
const reachedProvider = outcome.status === "sent" || outcome.status === "retry-later" || (outcome.status === "rejected" && PROVIDER_REASONS.has(outcome.reason));
|
|
55
|
+
if (reachedProvider && pauseMs > 0)
|
|
56
|
+
await sleep(pauseMs);
|
|
57
|
+
}
|
|
58
|
+
return ok({ recipients: seen.size, ...counts });
|
|
59
|
+
}
|
|
60
|
+
/** What `sendCampaign` would do, without writing or sending anything. Throws on a database failure. */
|
|
61
|
+
export async function planCampaign(ctx, input) {
|
|
62
|
+
const { campaign } = input;
|
|
63
|
+
assertCampaign(campaign);
|
|
64
|
+
const stored = await ctx.db.select({ contentHash: campaigns.contentHash }).from(campaigns).where(eq(campaigns.id, campaign.id)).limit(1);
|
|
65
|
+
const contentChanged = stored[0] !== undefined && stored[0].contentHash !== getCampaignContentHash(campaign);
|
|
66
|
+
const addresses = new Map();
|
|
67
|
+
for await (const address of input.recipients) {
|
|
68
|
+
const key = getRecipientKey(address);
|
|
69
|
+
if (!addresses.has(key))
|
|
70
|
+
addresses.set(key, address);
|
|
71
|
+
}
|
|
72
|
+
const closed = new Set();
|
|
73
|
+
const keys = [...addresses.keys()];
|
|
74
|
+
for (let start = 0; start < keys.length; start += 1_000) {
|
|
75
|
+
const rows = await ctx.db
|
|
76
|
+
.select({ recipientKey: deliveries.recipientKey })
|
|
77
|
+
.from(deliveries)
|
|
78
|
+
.where(and(eq(deliveries.scope, `campaign:${campaign.id}`), inArray(deliveries.status, ["sent", "rejected"]), inArray(deliveries.recipientKey, keys.slice(start, start + 1_000))));
|
|
79
|
+
rows.forEach((row) => closed.add(row.recipientKey));
|
|
80
|
+
}
|
|
81
|
+
let suppressed = 0;
|
|
82
|
+
for (const [key, address] of addresses) {
|
|
83
|
+
if (!closed.has(key) && (await isSuppressed(ctx, address)))
|
|
84
|
+
suppressed += 1;
|
|
85
|
+
}
|
|
86
|
+
return { recipients: addresses.size, done: closed.size, suppressed, toSend: addresses.size - closed.size - suppressed, contentChanged };
|
|
87
|
+
}
|
|
88
|
+
function assertCampaign(content) {
|
|
89
|
+
const problems = getCampaignProblems(content);
|
|
90
|
+
if (problems.length > 0)
|
|
91
|
+
throw new Error(`@softure-ai/mailing: campaign "${content.id}" cannot be sent: ${problems.join("; ")}`);
|
|
92
|
+
}
|
|
93
|
+
function emptyRejections() {
|
|
94
|
+
return { "mailing.invalid_input": 0, "mailing.rejected": 0, "mailing.unavailable": 0, "mailing.suppressed": 0 };
|
|
95
|
+
}
|
|
96
|
+
function countOutcome(counts, outcome) {
|
|
97
|
+
switch (outcome.status) {
|
|
98
|
+
case "sent":
|
|
99
|
+
counts.sent += 1;
|
|
100
|
+
return;
|
|
101
|
+
case "rejected":
|
|
102
|
+
counts.rejected[outcome.reason] += 1;
|
|
103
|
+
return;
|
|
104
|
+
case "done":
|
|
105
|
+
counts.done += 1;
|
|
106
|
+
return;
|
|
107
|
+
case "in-flight":
|
|
108
|
+
counts.inFlight += 1;
|
|
109
|
+
return;
|
|
110
|
+
case "retry-later":
|
|
111
|
+
counts.retryLater += 1;
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
function wait(ms) {
|
|
116
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
117
|
+
}
|
|
118
|
+
//# sourceMappingURL=campaigns.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"campaigns.js","sourceRoot":"","sources":["../../src/server/campaigns.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { type ModuleContext } from "@softure-ai/core";
|
|
2
|
+
import type { Queryable } from "@softure-ai/db";
|
|
3
|
+
import { type MailingErrorCode, type OutgoingMail } from "../contract.js";
|
|
4
|
+
export type DeliveryContext = ModuleContext<Queryable>;
|
|
5
|
+
/** How long a claim may stay open before another sender may take it over. */
|
|
6
|
+
export declare const DEFAULT_STALE_CLAIM_MS: number;
|
|
7
|
+
/** Claims a delivery gets before `mailing.unavailable` becomes its final outcome. */
|
|
8
|
+
export declare const DEFAULT_MAX_ATTEMPTS = 5;
|
|
9
|
+
export interface Delivery {
|
|
10
|
+
/**
|
|
11
|
+
* What the mail is about, unique per recipient: `campaign:<id>` for campaigns (set by
|
|
12
|
+
* `sendCampaign`), `<module>.<event>:<entity>` for lifecycle mail, e.g.
|
|
13
|
+
* `billing.trial-ending:sub_42`. Lowercase letters, digits and `._:-`, at most 128 characters.
|
|
14
|
+
*/
|
|
15
|
+
readonly scope: string;
|
|
16
|
+
/** The mail. Its `kind` is stored with the row; the idempotency key comes from the ledger. */
|
|
17
|
+
readonly mail: OutgoingMail;
|
|
18
|
+
/** The campaign the delivery belongs to; its scope must then be `campaign:<id>`. */
|
|
19
|
+
readonly campaignId?: string;
|
|
20
|
+
}
|
|
21
|
+
export interface DeliverOptions {
|
|
22
|
+
readonly staleClaimMs?: number;
|
|
23
|
+
readonly maxAttempts?: number;
|
|
24
|
+
}
|
|
25
|
+
export type DeliveryOutcome =
|
|
26
|
+
/** Sent now; `id` is the provider's message id. */
|
|
27
|
+
{
|
|
28
|
+
readonly status: "sent";
|
|
29
|
+
readonly id: string;
|
|
30
|
+
}
|
|
31
|
+
/** Refused now, for good (suppressed, rejected, invalid, or unavailable too many times). */
|
|
32
|
+
| {
|
|
33
|
+
readonly status: "rejected";
|
|
34
|
+
readonly reason: MailingErrorCode;
|
|
35
|
+
}
|
|
36
|
+
/** An earlier run already closed this delivery; nothing was sent. */
|
|
37
|
+
| {
|
|
38
|
+
readonly status: "done";
|
|
39
|
+
readonly outcome: "sent" | "rejected";
|
|
40
|
+
}
|
|
41
|
+
/** Another sender holds a fresh claim; nothing was sent. */
|
|
42
|
+
| {
|
|
43
|
+
readonly status: "in-flight";
|
|
44
|
+
}
|
|
45
|
+
/** The provider was unavailable; the claim is released for a later run. */
|
|
46
|
+
| {
|
|
47
|
+
readonly status: "retry-later";
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Sends `delivery.mail` unless the ledger already has an outcome (or a fresh claim) for its scope
|
|
51
|
+
* and recipient, and records the outcome. Throws on a database failure (after a send, the claim
|
|
52
|
+
* stays open and becomes stale, so a later run re-sends with the same idempotency key), for a
|
|
53
|
+
* malformed scope or kind, and when `sendMail` throws.
|
|
54
|
+
*/
|
|
55
|
+
export declare function deliverOnce(ctx: DeliveryContext, delivery: Delivery, options?: DeliverOptions): Promise<DeliveryOutcome>;
|
|
56
|
+
//# sourceMappingURL=deliveries.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"deliveries.d.ts","sourceRoot":"","sources":["../../src/server/deliveries.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,KAAK,aAAa,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAGhD,OAAO,EAAsB,KAAK,gBAAgB,EAAE,KAAK,YAAY,EAAE,MAAM,gBAAgB,CAAC;AAK9F,MAAM,MAAM,eAAe,GAAG,aAAa,CAAC,SAAS,CAAC,CAAC;AAEvD,6EAA6E;AAC7E,eAAO,MAAM,sBAAsB,QAAc,CAAC;AAClD,qFAAqF;AACrF,eAAO,MAAM,oBAAoB,IAAI,CAAC;AAKtC,MAAM,WAAW,QAAQ;IACvB;;;;OAIG;IACH,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,8FAA8F;IAC9F,QAAQ,CAAC,IAAI,EAAE,YAAY,CAAC;IAC5B,oFAAoF;IACpF,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;CAC9B;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC/B;AAED,MAAM,MAAM,eAAe;AACzB,mDAAmD;AACjD;IAAE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;CAAE;AAClD,4FAA4F;GAC1F;IAAE,QAAQ,CAAC,MAAM,EAAE,UAAU,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,gBAAgB,CAAA;CAAE;AACpE,qEAAqE;GACnE;IAAE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,UAAU,CAAA;CAAE;AACpE,4DAA4D;GAC1D;IAAE,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAA;CAAE;AAClC,2EAA2E;GACzE;IAAE,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAA;CAAE,CAAC;AAEvC;;;;;GAKG;AACH,wBAAsB,WAAW,CAAC,GAAG,EAAE,eAAe,EAAE,QAAQ,EAAE,QAAQ,EAAE,OAAO,GAAE,cAAmB,GAAG,OAAO,CAAC,eAAe,CAAC,CAsBlI"}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// The delivery ledger (mailing.deliveries): a mail goes out at most once per scope and recipient.
|
|
2
|
+
// The sender claims the row in one conditional upsert before it calls the provider, then closes it
|
|
3
|
+
// with one outcome. A claim left behind by a crash is taken over once it is stale, and the mail is
|
|
4
|
+
// sent again with the same idempotency key, so the provider folds it into the first send.
|
|
5
|
+
import {} from "@softure-ai/core";
|
|
6
|
+
import { and, eq, lt, or, sql } from "drizzle-orm";
|
|
7
|
+
import { isMailKind } from "../address.js";
|
|
8
|
+
import { TRANSACTIONAL_KIND } from "../contract.js";
|
|
9
|
+
import { deliveries } from "../schema.js";
|
|
10
|
+
import { sendMail } from "./send-mail.js";
|
|
11
|
+
import { getRecipientKey } from "./unsubscribe-link.js";
|
|
12
|
+
/** How long a claim may stay open before another sender may take it over. */
|
|
13
|
+
export const DEFAULT_STALE_CLAIM_MS = 15 * 60_000;
|
|
14
|
+
/** Claims a delivery gets before `mailing.unavailable` becomes its final outcome. */
|
|
15
|
+
export const DEFAULT_MAX_ATTEMPTS = 5;
|
|
16
|
+
const MAX_SCOPE_LENGTH = 128;
|
|
17
|
+
const SCOPE = /^[a-z0-9][a-z0-9._:-]*$/;
|
|
18
|
+
/**
|
|
19
|
+
* Sends `delivery.mail` unless the ledger already has an outcome (or a fresh claim) for its scope
|
|
20
|
+
* and recipient, and records the outcome. Throws on a database failure (after a send, the claim
|
|
21
|
+
* stays open and becomes stale, so a later run re-sends with the same idempotency key), for a
|
|
22
|
+
* malformed scope or kind, and when `sendMail` throws.
|
|
23
|
+
*/
|
|
24
|
+
export async function deliverOnce(ctx, delivery, options = {}) {
|
|
25
|
+
const { staleClaimMs = DEFAULT_STALE_CLAIM_MS, maxAttempts = DEFAULT_MAX_ATTEMPTS } = options;
|
|
26
|
+
const kind = delivery.mail.kind ?? TRANSACTIONAL_KIND;
|
|
27
|
+
assertDelivery(delivery, kind);
|
|
28
|
+
const recipientKey = getRecipientKey(delivery.mail.to);
|
|
29
|
+
const key = { scope: delivery.scope, recipientKey };
|
|
30
|
+
const attempt = await claimDelivery(ctx, { ...key, kind, campaignId: delivery.campaignId ?? null, staleClaimMs });
|
|
31
|
+
if (attempt === null)
|
|
32
|
+
return readClosedDelivery(ctx, key);
|
|
33
|
+
const result = await sendMail(ctx, delivery.mail, { idempotencyKey: `${delivery.scope}:${recipientKey}` });
|
|
34
|
+
const fence = { ...key, attempt };
|
|
35
|
+
if (result.ok) {
|
|
36
|
+
await closeDelivery(ctx, fence, { status: "sent", providerMessageId: result.value.id, reason: null });
|
|
37
|
+
return { status: "sent", id: result.value.id };
|
|
38
|
+
}
|
|
39
|
+
if (result.error === "mailing.unavailable" && attempt < maxAttempts) {
|
|
40
|
+
await releaseDelivery(ctx, fence);
|
|
41
|
+
return { status: "retry-later" };
|
|
42
|
+
}
|
|
43
|
+
await closeDelivery(ctx, fence, { status: "rejected", providerMessageId: null, reason: result.error });
|
|
44
|
+
return { status: "rejected", reason: result.error };
|
|
45
|
+
}
|
|
46
|
+
function assertDelivery(delivery, kind) {
|
|
47
|
+
if (delivery.scope.length > MAX_SCOPE_LENGTH || !SCOPE.test(delivery.scope)) {
|
|
48
|
+
throw new Error(`@softure-ai/mailing: deliverOnce scope "${delivery.scope}" must be lowercase letters, digits and ._:- (at most 128 characters)`);
|
|
49
|
+
}
|
|
50
|
+
if (!isMailKind(kind)) {
|
|
51
|
+
throw new Error(`@softure-ai/mailing: deliverOnce kind "${kind}" must be kebab-case (at most 64 characters)`);
|
|
52
|
+
}
|
|
53
|
+
if (delivery.campaignId !== undefined && delivery.scope !== `campaign:${delivery.campaignId}`) {
|
|
54
|
+
throw new Error(`@softure-ai/mailing: a delivery of campaign "${delivery.campaignId}" must use the scope "campaign:${delivery.campaignId}"`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Takes the row in one statement: a new row, a `pending` one, or a claim older than
|
|
59
|
+
* `staleClaimMs`. Resolves with the attempt number that fences the outcome, or `null` when the row
|
|
60
|
+
* is closed or claimed by someone else.
|
|
61
|
+
*/
|
|
62
|
+
async function claimDelivery(ctx, claim) {
|
|
63
|
+
const now = ctx.clock.now();
|
|
64
|
+
const staleBefore = new Date(now.getTime() - claim.staleClaimMs);
|
|
65
|
+
const rows = await ctx.db
|
|
66
|
+
.insert(deliveries)
|
|
67
|
+
.values({ scope: claim.scope, recipientKey: claim.recipientKey, kind: claim.kind, campaignId: claim.campaignId, status: "claimed", attempts: 1, claimedAt: now, createdAt: now })
|
|
68
|
+
.onConflictDoUpdate({
|
|
69
|
+
target: [deliveries.scope, deliveries.recipientKey],
|
|
70
|
+
set: { status: "claimed", attempts: sql `${deliveries.attempts} + 1`, claimedAt: now },
|
|
71
|
+
setWhere: or(eq(deliveries.status, "pending"), and(eq(deliveries.status, "claimed"), lt(deliveries.claimedAt, staleBefore))),
|
|
72
|
+
})
|
|
73
|
+
.returning();
|
|
74
|
+
return rows[0]?.attempts ?? null;
|
|
75
|
+
}
|
|
76
|
+
async function readClosedDelivery(ctx, key) {
|
|
77
|
+
const rows = await ctx.db.select({ status: deliveries.status }).from(deliveries).where(matchKey(key)).limit(1);
|
|
78
|
+
const status = rows[0]?.status;
|
|
79
|
+
if (status === "sent" || status === "rejected")
|
|
80
|
+
return { status: "done", outcome: status };
|
|
81
|
+
// A fresh claim; `pending` only between another sender's release and this read.
|
|
82
|
+
return { status: "in-flight" };
|
|
83
|
+
}
|
|
84
|
+
/** Writes the outcome if this sender still holds the claim; a stale holder writes nothing. */
|
|
85
|
+
async function closeDelivery(ctx, fence, outcome) {
|
|
86
|
+
await ctx.db
|
|
87
|
+
.update(deliveries)
|
|
88
|
+
.set({ ...outcome, finishedAt: ctx.clock.now() })
|
|
89
|
+
.where(and(matchKey(fence), eq(deliveries.status, "claimed"), eq(deliveries.attempts, fence.attempt)));
|
|
90
|
+
}
|
|
91
|
+
async function releaseDelivery(ctx, fence) {
|
|
92
|
+
await ctx.db
|
|
93
|
+
.update(deliveries)
|
|
94
|
+
.set({ status: "pending" })
|
|
95
|
+
.where(and(matchKey(fence), eq(deliveries.status, "claimed"), eq(deliveries.attempts, fence.attempt)));
|
|
96
|
+
}
|
|
97
|
+
function matchKey(key) {
|
|
98
|
+
return and(eq(deliveries.scope, key.scope), eq(deliveries.recipientKey, key.recipientKey));
|
|
99
|
+
}
|
|
100
|
+
//# sourceMappingURL=deliveries.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"deliveries.js","sourceRoot":"","sources":["../../src/server/deliveries.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export type DnsCheckStatus = "pass" | "warn" | "fail";
|
|
2
|
+
export type DnsFinding = "found" | "missing" | "multiple" | "permissive" | "revoked" | "monitor-only" | "inherited" | "lookup-failed";
|
|
3
|
+
export interface DnsCheck {
|
|
4
|
+
readonly status: DnsCheckStatus;
|
|
5
|
+
readonly finding: DnsFinding;
|
|
6
|
+
/** The name that answered (or the first one asked, when none did). */
|
|
7
|
+
readonly host: string;
|
|
8
|
+
/** The record that decided the status, when there is one. */
|
|
9
|
+
readonly record: string | null;
|
|
10
|
+
}
|
|
11
|
+
export interface SenderDnsReport {
|
|
12
|
+
readonly domain: string;
|
|
13
|
+
readonly spf: DnsCheck;
|
|
14
|
+
readonly dkim: DnsCheck;
|
|
15
|
+
readonly dmarc: DnsCheck;
|
|
16
|
+
}
|
|
17
|
+
export type ResolveTxt = (hostname: string) => Promise<string[][]>;
|
|
18
|
+
export interface CheckSenderDnsOptions {
|
|
19
|
+
/** DKIM selectors to look up under `_domainkey`. Default `["resend"]`, the Resend selector. */
|
|
20
|
+
readonly dkimSelectors?: readonly string[];
|
|
21
|
+
/**
|
|
22
|
+
* Where SPF is published. Default: the domain itself. Resend checks SPF on `send.<domain>` (its
|
|
23
|
+
* return path); DMARC accepts that in relaxed alignment.
|
|
24
|
+
*/
|
|
25
|
+
readonly spfHosts?: readonly string[];
|
|
26
|
+
readonly resolveTxt?: ResolveTxt;
|
|
27
|
+
}
|
|
28
|
+
export declare const DEFAULT_DKIM_SELECTORS: readonly string[];
|
|
29
|
+
/** Reads the SPF, DKIM and DMARC records of `domain`. Never throws: a failed lookup is a finding. */
|
|
30
|
+
export declare function checkSenderDns(domain: string, options?: CheckSenderDnsOptions): Promise<SenderDnsReport>;
|
|
31
|
+
/** The domain part of a sender (`Plan <hello@mail.example.com>` gives `mail.example.com`). */
|
|
32
|
+
export declare function getSenderDomain(from: string): string;
|
|
33
|
+
//# sourceMappingURL=dns.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dns.d.ts","sourceRoot":"","sources":["../../src/server/dns.ts"],"names":[],"mappings":"AAKA,MAAM,MAAM,cAAc,GAAG,MAAM,GAAG,MAAM,GAAG,MAAM,CAAC;AAEtD,MAAM,MAAM,UAAU,GAClB,OAAO,GACP,SAAS,GACT,UAAU,GACV,YAAY,GACZ,SAAS,GACT,cAAc,GACd,WAAW,GACX,eAAe,CAAC;AAEpB,MAAM,WAAW,QAAQ;IACvB,QAAQ,CAAC,MAAM,EAAE,cAAc,CAAC;IAChC,QAAQ,CAAC,OAAO,EAAE,UAAU,CAAC;IAC7B,sEAAsE;IACtE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,6DAA6D;IAC7D,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;CAChC;AAED,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,GAAG,EAAE,QAAQ,CAAC;IACvB,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC;IACxB,QAAQ,CAAC,KAAK,EAAE,QAAQ,CAAC;CAC1B;AAED,MAAM,MAAM,UAAU,GAAG,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;AAEnE,MAAM,WAAW,qBAAqB;IACpC,+FAA+F;IAC/F,QAAQ,CAAC,aAAa,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC3C;;;OAGG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACtC,QAAQ,CAAC,UAAU,CAAC,EAAE,UAAU,CAAC;CAClC;AAED,eAAO,MAAM,sBAAsB,EAAE,SAAS,MAAM,EAAe,CAAC;AAEpE,qGAAqG;AACrG,wBAAsB,cAAc,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,GAAE,qBAA0B,GAAG,OAAO,CAAC,eAAe,CAAC,CAUlH;AAED,8FAA8F;AAC9F,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAOpD"}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// The sender domain's DNS, as receivers check it: SPF (RFC 7208), DKIM (RFC 6376) and DMARC
|
|
2
|
+
// (RFC 7489). Without all three, list mail lands in spam or is refused outright. The check only
|
|
3
|
+
// reads public TXT records; it cannot see whether the provider signs with the published key.
|
|
4
|
+
import { resolveTxt as resolveTxtRecords } from "node:dns/promises";
|
|
5
|
+
export const DEFAULT_DKIM_SELECTORS = ["resend"];
|
|
6
|
+
/** Reads the SPF, DKIM and DMARC records of `domain`. Never throws: a failed lookup is a finding. */
|
|
7
|
+
export async function checkSenderDns(domain, options = {}) {
|
|
8
|
+
const name = domain.trim().toLowerCase().replace(/\.$/, "");
|
|
9
|
+
const resolveTxt = options.resolveTxt ?? resolveTxtRecords;
|
|
10
|
+
const lookup = (host) => lookupTxt(resolveTxt, host);
|
|
11
|
+
const [spf, dkim, dmarc] = await Promise.all([
|
|
12
|
+
checkSpf(lookup, options.spfHosts ?? [name]),
|
|
13
|
+
checkDkim(lookup, (options.dkimSelectors ?? DEFAULT_DKIM_SELECTORS).map((selector) => `${selector}._domainkey.${name}`)),
|
|
14
|
+
checkDmarc(lookup, name),
|
|
15
|
+
]);
|
|
16
|
+
return { domain: name, spf, dkim, dmarc };
|
|
17
|
+
}
|
|
18
|
+
/** The domain part of a sender (`Plan <hello@mail.example.com>` gives `mail.example.com`). */
|
|
19
|
+
export function getSenderDomain(from) {
|
|
20
|
+
// Index arithmetic, not a regular expression: `from` may come from anywhere, and a pattern over
|
|
21
|
+
// `<...>` backtracks polynomially on input like "<<<<".
|
|
22
|
+
const open = from.lastIndexOf("<");
|
|
23
|
+
const close = from.lastIndexOf(">");
|
|
24
|
+
const address = open !== -1 && close > open ? from.slice(open + 1, close) : from;
|
|
25
|
+
return address.slice(address.lastIndexOf("@") + 1).trim().toLowerCase();
|
|
26
|
+
}
|
|
27
|
+
const NO_RECORD_CODES = new Set(["ENOTFOUND", "ENODATA", "NXDOMAIN"]);
|
|
28
|
+
async function lookupTxt(resolveTxt, host) {
|
|
29
|
+
try {
|
|
30
|
+
// A TXT record may arrive in 255-byte chunks; they form one string.
|
|
31
|
+
const records = (await resolveTxt(host)).map((chunks) => chunks.join(""));
|
|
32
|
+
return records.length === 0 ? { kind: "none" } : { kind: "records", records };
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
const code = error?.code;
|
|
36
|
+
return typeof code === "string" && NO_RECORD_CODES.has(code) ? { kind: "none" } : { kind: "error" };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function startsWithTag(record, tag) {
|
|
40
|
+
return record.trim().toLowerCase().startsWith(tag);
|
|
41
|
+
}
|
|
42
|
+
/** One `v=spf1` record per host; `+all` (or a bare `all`) lets anyone send as the domain. */
|
|
43
|
+
async function checkSpf(lookup, hosts) {
|
|
44
|
+
let lookupFailed = null;
|
|
45
|
+
for (const host of hosts) {
|
|
46
|
+
const answer = await lookup(host);
|
|
47
|
+
if (answer.kind === "error") {
|
|
48
|
+
lookupFailed ??= host;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
const records = answer.kind === "records" ? answer.records.filter((record) => /^v=spf1(\s|$)/i.test(record.trim())) : [];
|
|
52
|
+
if (records.length > 1)
|
|
53
|
+
return { status: "fail", finding: "multiple", host, record: null };
|
|
54
|
+
const record = records[0];
|
|
55
|
+
if (record === undefined)
|
|
56
|
+
continue;
|
|
57
|
+
const permissive = /(^|\s)\+?all(\s|$)/i.test(record);
|
|
58
|
+
return permissive ? { status: "warn", finding: "permissive", host, record } : { status: "pass", finding: "found", host, record };
|
|
59
|
+
}
|
|
60
|
+
return lookupFailed === null ? { status: "fail", finding: "missing", host: hosts[0] ?? "", record: null } : { status: "fail", finding: "lookup-failed", host: lookupFailed, record: null };
|
|
61
|
+
}
|
|
62
|
+
/** A key record (`p=`) under one of the selectors; an empty `p=` means the key was revoked. */
|
|
63
|
+
async function checkDkim(lookup, hosts) {
|
|
64
|
+
let fallback = { status: "fail", finding: "missing", host: hosts[0] ?? "", record: null };
|
|
65
|
+
for (const host of hosts) {
|
|
66
|
+
const answer = await lookup(host);
|
|
67
|
+
if (answer.kind === "error") {
|
|
68
|
+
if (fallback.finding === "missing")
|
|
69
|
+
fallback = { status: "fail", finding: "lookup-failed", host, record: null };
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (answer.kind === "none")
|
|
73
|
+
continue;
|
|
74
|
+
const record = answer.records.find((candidate) => /(^|;)\s*p=/i.test(candidate));
|
|
75
|
+
if (record === undefined)
|
|
76
|
+
continue;
|
|
77
|
+
const key = /(?:^|;)\s*p=([^;]*)/i.exec(record)?.[1]?.trim() ?? "";
|
|
78
|
+
if (key !== "")
|
|
79
|
+
return { status: "pass", finding: "found", host, record };
|
|
80
|
+
fallback = { status: "fail", finding: "revoked", host, record };
|
|
81
|
+
}
|
|
82
|
+
return fallback;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* `_dmarc.<domain>`, or the nearest parent's record (the organisational domain's policy, with
|
|
86
|
+
* `sp=` for subdomains when it is set). `p=none` only reports; it protects nothing.
|
|
87
|
+
*/
|
|
88
|
+
async function checkDmarc(lookup, domain) {
|
|
89
|
+
const labels = domain.split(".");
|
|
90
|
+
const first = `_dmarc.${domain}`;
|
|
91
|
+
for (let index = 0; index <= labels.length - 2; index += 1) {
|
|
92
|
+
const host = `_dmarc.${labels.slice(index).join(".")}`;
|
|
93
|
+
const answer = await lookup(host);
|
|
94
|
+
if (answer.kind === "error")
|
|
95
|
+
return { status: "fail", finding: "lookup-failed", host, record: null };
|
|
96
|
+
if (answer.kind === "none")
|
|
97
|
+
continue;
|
|
98
|
+
const records = answer.records.filter((record) => startsWithTag(record, "v=dmarc1"));
|
|
99
|
+
if (records.length > 1)
|
|
100
|
+
return { status: "fail", finding: "multiple", host, record: null };
|
|
101
|
+
const record = records[0];
|
|
102
|
+
if (record === undefined)
|
|
103
|
+
continue;
|
|
104
|
+
const inherited = host !== first;
|
|
105
|
+
const tag = (name) => new RegExp(`(?:^|;)\\s*${name}=([^;]*)`, "i").exec(record)?.[1]?.trim().toLowerCase();
|
|
106
|
+
const policy = (inherited ? (tag("sp") ?? tag("p")) : tag("p")) ?? "none";
|
|
107
|
+
if (policy === "none")
|
|
108
|
+
return { status: "warn", finding: "monitor-only", host, record };
|
|
109
|
+
return inherited ? { status: "pass", finding: "inherited", host, record } : { status: "pass", finding: "found", host, record };
|
|
110
|
+
}
|
|
111
|
+
return { status: "fail", finding: "missing", host: first, record: null };
|
|
112
|
+
}
|
|
113
|
+
//# sourceMappingURL=dns.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dns.js","sourceRoot":"","sources":["../../src/server/dns.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"health.d.ts","sourceRoot":"","sources":["../../src/server/health.ts"],"names":[],"mappings":"AAEA,OAAO,EAAM,KAAK,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAIxD,eAAO,MAAM,kBAAkB,EAAE,WAOhC,CAAC"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// The module's readiness probe for `GET /api/health` of `@softure-ai/ops`: the suppression list,
|
|
2
|
+
// the campaigns and the delivery ledger exist and answer, i.e. `softure migrate` ran. It reads no rows.
|
|
3
|
+
import { ok } from "@softure-ai/core";
|
|
4
|
+
import { sql } from "drizzle-orm";
|
|
5
|
+
export const checkMailingTables = async (context) => {
|
|
6
|
+
// Core types `db` as unknown; the health route passes the @softure-ai/db handle.
|
|
7
|
+
const db = context.db;
|
|
8
|
+
await db.execute(sql `select 1 from mailing.suppressions limit 0`);
|
|
9
|
+
await db.execute(sql `select 1 from mailing.campaigns limit 0`);
|
|
10
|
+
await db.execute(sql `select 1 from mailing.deliveries limit 0`);
|
|
11
|
+
return ok();
|
|
12
|
+
};
|
|
13
|
+
//# sourceMappingURL=health.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"health.js","sourceRoot":"","sources":["../../src/server/health.ts"],"names":[],"mappings":"AAAA,iGAAiG;AACjG,wGAAwG;AACxG,OAAO,EAAE,EAAE,EAAoB,MAAM,kBAAkB,CAAC;AAExD,OAAO,EAAE,GAAG,EAAE,MAAM,aAAa,CAAC;AAElC,MAAM,CAAC,MAAM,kBAAkB,GAAgB,KAAK,EAAE,OAAO,EAAE,EAAE;IAC/D,iFAAiF;IACjF,MAAM,EAAE,GAAG,OAAO,CAAC,EAAe,CAAC;IACnC,MAAM,EAAE,CAAC,OAAO,CAAC,GAAG,CAAA,4CAA4C,CAAC,CAAC;IAClE,MAAM,EAAE,CAAC,OAAO,CAAC,GAAG,CAAA,yCAAyC,CAAC,CAAC;IAC/D,MAAM,EAAE,CAAC,OAAO,CAAC,GAAG,CAAA,0CAA0C,CAAC,CAAC;IAChE,OAAO,EAAE,EAAE,CAAC;AACd,CAAC,CAAC"}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { getCampaignProblems, parseCampaignFile, parseRecipientList, type CampaignContent, type CampaignFile, type CampaignFileResult } from "./campaign-file.js";
|
|
2
|
+
export { getCampaignContentHash, planCampaign, registerCampaign, sendCampaign, type CampaignErrorCode, type CampaignPlan, type CampaignSummary, type SendCampaignOptions, } from "./campaigns.js";
|
|
3
|
+
export { DEFAULT_MAX_ATTEMPTS, DEFAULT_STALE_CLAIM_MS, deliverOnce, type DeliverOptions, type Delivery, type DeliveryContext, type DeliveryOutcome, } from "./deliveries.js";
|
|
4
|
+
export { checkSenderDns, DEFAULT_DKIM_SELECTORS, getSenderDomain, type CheckSenderDnsOptions, type DnsCheck, type DnsCheckStatus, type DnsFinding, type ResolveTxt, type SenderDnsReport, } from "./dns.js";
|
|
5
|
+
export { checkMailingTables } from "./health.js";
|
|
6
|
+
export { addHtmlFooter, addTextFooter, getListUnsubscribeHeaders, SIGNATURE_SEPARATOR } from "./list-mail.js";
|
|
7
|
+
export { getMailingModule, getMailingOptions, getMailingRoutes, type MailingRoutes } from "./options.js";
|
|
8
|
+
export { sendMail, type MailContext } from "./send-mail.js";
|
|
9
|
+
export { isSuppressed, liftSuppression, suppressRecipient, unsubscribe, type SuppressionContext } from "./suppressions.js";
|
|
10
|
+
export { buildUnsubscribeLinks, getRecipientKey, MIN_UNSUBSCRIBE_SECRET_LENGTH, readUnsubscribeSecrets, readUnsubscribeToken, RECIPIENT_PARAM, signRecipientKey, SIGNATURE_PARAM, UNSUBSCRIBE_PREVIOUS_SECRET_ENV, UNSUBSCRIBE_SECRET_ENV, verifyUnsubscribeToken, type Env, type UnsubscribeLinks, type UnsubscribeSecrets, type UnsubscribeToken, } from "./unsubscribe-link.js";
|
|
11
|
+
export { validateMail, type MailValidation, type ValidMail } from "./validate-mail.js";
|
|
12
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/server/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,mBAAmB,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,KAAK,eAAe,EAAE,KAAK,YAAY,EAAE,KAAK,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AAClK,OAAO,EACL,sBAAsB,EACtB,YAAY,EACZ,gBAAgB,EAChB,YAAY,EACZ,KAAK,iBAAiB,EACtB,KAAK,YAAY,EACjB,KAAK,eAAe,EACpB,KAAK,mBAAmB,GACzB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EACL,oBAAoB,EACpB,sBAAsB,EACtB,WAAW,EACX,KAAK,cAAc,EACnB,KAAK,QAAQ,EACb,KAAK,eAAe,EACpB,KAAK,eAAe,GACrB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EACL,cAAc,EACd,sBAAsB,EACtB,eAAe,EACf,KAAK,qBAAqB,EAC1B,KAAK,QAAQ,EACb,KAAK,cAAc,EACnB,KAAK,UAAU,EACf,KAAK,UAAU,EACf,KAAK,eAAe,GACrB,MAAM,UAAU,CAAC;AAClB,OAAO,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AACjD,OAAO,EAAE,aAAa,EAAE,aAAa,EAAE,yBAAyB,EAAE,mBAAmB,EAAE,MAAM,gBAAgB,CAAC;AAC9G,OAAO,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,KAAK,aAAa,EAAE,MAAM,cAAc,CAAC;AACzG,OAAO,EAAE,QAAQ,EAAE,KAAK,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAC5D,OAAO,EAAE,YAAY,EAAE,eAAe,EAAE,iBAAiB,EAAE,WAAW,EAAE,KAAK,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AAC3H,OAAO,EACL,qBAAqB,EACrB,eAAe,EACf,6BAA6B,EAC7B,sBAAsB,EACtB,oBAAoB,EACpB,eAAe,EACf,gBAAgB,EAChB,eAAe,EACf,+BAA+B,EAC/B,sBAAsB,EACtB,sBAAsB,EACtB,KAAK,GAAG,EACR,KAAK,gBAAgB,EACrB,KAAK,kBAAkB,EACvB,KAAK,gBAAgB,GACtB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAAE,YAAY,EAAE,KAAK,cAAc,EAAE,KAAK,SAAS,EAAE,MAAM,oBAAoB,CAAC"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Server-only logic of @softure-ai/mailing. Functions receive a context and never read request
|
|
2
|
+
// scope; `next/*` imports are not allowed here (ESLint `no-restricted-imports`, NFR-3).
|
|
3
|
+
export { getCampaignProblems, parseCampaignFile, parseRecipientList } from "./campaign-file.js";
|
|
4
|
+
export { getCampaignContentHash, planCampaign, registerCampaign, sendCampaign, } from "./campaigns.js";
|
|
5
|
+
export { DEFAULT_MAX_ATTEMPTS, DEFAULT_STALE_CLAIM_MS, deliverOnce, } from "./deliveries.js";
|
|
6
|
+
export { checkSenderDns, DEFAULT_DKIM_SELECTORS, getSenderDomain, } from "./dns.js";
|
|
7
|
+
export { checkMailingTables } from "./health.js";
|
|
8
|
+
export { addHtmlFooter, addTextFooter, getListUnsubscribeHeaders, SIGNATURE_SEPARATOR } from "./list-mail.js";
|
|
9
|
+
export { getMailingModule, getMailingOptions, getMailingRoutes } from "./options.js";
|
|
10
|
+
export { sendMail } from "./send-mail.js";
|
|
11
|
+
export { isSuppressed, liftSuppression, suppressRecipient, unsubscribe } from "./suppressions.js";
|
|
12
|
+
export { buildUnsubscribeLinks, getRecipientKey, MIN_UNSUBSCRIBE_SECRET_LENGTH, readUnsubscribeSecrets, readUnsubscribeToken, RECIPIENT_PARAM, signRecipientKey, SIGNATURE_PARAM, UNSUBSCRIBE_PREVIOUS_SECRET_ENV, UNSUBSCRIBE_SECRET_ENV, verifyUnsubscribeToken, } from "./unsubscribe-link.js";
|
|
13
|
+
export { validateMail } from "./validate-mail.js";
|
|
14
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/server/index.ts"],"names":[],"mappings":"AAAA,+FAA+F;AAC/F,wFAAwF;AACxF,OAAO,EAAE,mBAAmB,EAAE,iBAAiB,EAAE,kBAAkB,EAAoE,MAAM,oBAAoB,CAAC;AAClK,OAAO,EACL,sBAAsB,EACtB,YAAY,EACZ,gBAAgB,EAChB,YAAY,GAKb,MAAM,gBAAgB,CAAC;AACxB,OAAO,EACL,oBAAoB,EACpB,sBAAsB,EACtB,WAAW,GAKZ,MAAM,iBAAiB,CAAC;AACzB,OAAO,EACL,cAAc,EACd,sBAAsB,EACtB,eAAe,GAOhB,MAAM,UAAU,CAAC;AAClB,OAAO,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AACjD,OAAO,EAAE,aAAa,EAAE,aAAa,EAAE,yBAAyB,EAAE,mBAAmB,EAAE,MAAM,gBAAgB,CAAC;AAC9G,OAAO,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,gBAAgB,EAAsB,MAAM,cAAc,CAAC;AACzG,OAAO,EAAE,QAAQ,EAAoB,MAAM,gBAAgB,CAAC;AAC5D,OAAO,EAAE,YAAY,EAAE,eAAe,EAAE,iBAAiB,EAAE,WAAW,EAA2B,MAAM,mBAAmB,CAAC;AAC3H,OAAO,EACL,qBAAqB,EACrB,eAAe,EACf,6BAA6B,EAC7B,sBAAsB,EACtB,oBAAoB,EACpB,eAAe,EACf,gBAAgB,EAChB,eAAe,EACf,+BAA+B,EAC/B,sBAAsB,EACtB,sBAAsB,GAKvB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAAE,YAAY,EAAuC,MAAM,oBAAoB,CAAC"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { MailingMessages } from "../messages/index.js";
|
|
2
|
+
import type { UnsubscribeLinks } from "./unsubscribe-link.js";
|
|
3
|
+
/** The signature separator (RFC 3676): mail clients fold what follows it. */
|
|
4
|
+
export declare const SIGNATURE_SEPARATOR = "-- ";
|
|
5
|
+
type FooterCopy = MailingMessages["footer"];
|
|
6
|
+
/** `text` with the unsubscribe footer under the signature separator. */
|
|
7
|
+
export declare function addTextFooter(text: string, links: UnsubscribeLinks, copy: FooterCopy): string;
|
|
8
|
+
/**
|
|
9
|
+
* `html` with an unsubscribe paragraph before its last `</body>`, or appended when it has none
|
|
10
|
+
* (a fragment). The copy is escaped; the link is ours and only needs its `&` escaped.
|
|
11
|
+
*/
|
|
12
|
+
export declare function addHtmlFooter(html: string, links: UnsubscribeLinks, copy: FooterCopy): string;
|
|
13
|
+
/**
|
|
14
|
+
* RFC 8058: the mail client shows "Unsubscribe" next to the sender and POSTs to this URL itself,
|
|
15
|
+
* without opening a page or a session. Both headers must be covered by the DKIM signature, which
|
|
16
|
+
* the provider applies to every header it sends.
|
|
17
|
+
*/
|
|
18
|
+
export declare function getListUnsubscribeHeaders(links: UnsubscribeLinks): Record<string, string>;
|
|
19
|
+
export {};
|
|
20
|
+
//# sourceMappingURL=list-mail.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"list-mail.d.ts","sourceRoot":"","sources":["../../src/server/list-mail.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC;AAC5D,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAE9D,6EAA6E;AAC7E,eAAO,MAAM,mBAAmB,QAAQ,CAAC;AAEzC,KAAK,UAAU,GAAG,eAAe,CAAC,QAAQ,CAAC,CAAC;AAE5C,wEAAwE;AACxE,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,gBAAgB,EAAE,IAAI,EAAE,UAAU,GAAG,MAAM,CAE7F;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,gBAAgB,EAAE,IAAI,EAAE,UAAU,GAAG,MAAM,CAK7F;AAED;;;;GAIG;AACH,wBAAgB,yBAAyB,CAAC,KAAK,EAAE,gBAAgB,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAKzF"}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** The signature separator (RFC 3676): mail clients fold what follows it. */
|
|
2
|
+
export const SIGNATURE_SEPARATOR = "-- ";
|
|
3
|
+
/** `text` with the unsubscribe footer under the signature separator. */
|
|
4
|
+
export function addTextFooter(text, links, copy) {
|
|
5
|
+
return [text, "", SIGNATURE_SEPARATOR, copy.text, links.page].join("\n");
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* `html` with an unsubscribe paragraph before its last `</body>`, or appended when it has none
|
|
9
|
+
* (a fragment). The copy is escaped; the link is ours and only needs its `&` escaped.
|
|
10
|
+
*/
|
|
11
|
+
export function addHtmlFooter(html, links, copy) {
|
|
12
|
+
const footer = `<p>${escapeHtml(copy.htmlLead)} <a href="${escapeHtml(links.page)}">${escapeHtml(copy.htmlLink)}</a></p>`;
|
|
13
|
+
// Searched on the original string: lowercasing can change its length (`İ` becomes two characters).
|
|
14
|
+
const bodyEnd = [...html.matchAll(/<\/body>/gi)].at(-1)?.index;
|
|
15
|
+
return bodyEnd === undefined ? `${html}\n${footer}` : `${html.slice(0, bodyEnd)}${footer}\n${html.slice(bodyEnd)}`;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* RFC 8058: the mail client shows "Unsubscribe" next to the sender and POSTs to this URL itself,
|
|
19
|
+
* without opening a page or a session. Both headers must be covered by the DKIM signature, which
|
|
20
|
+
* the provider applies to every header it sends.
|
|
21
|
+
*/
|
|
22
|
+
export function getListUnsubscribeHeaders(links) {
|
|
23
|
+
return {
|
|
24
|
+
"List-Unsubscribe": `<${links.oneClick}>`,
|
|
25
|
+
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function escapeHtml(text) {
|
|
29
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
30
|
+
}
|
|
31
|
+
//# sourceMappingURL=list-mail.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"list-mail.js","sourceRoot":"","sources":["../../src/server/list-mail.ts"],"names":[],"mappings":"AAMA,6EAA6E;AAC7E,MAAM,CAAC,MAAM,mBAAmB,GAAG,KAAK,CAAC;AAIzC,wEAAwE;AACxE,MAAM,UAAU,aAAa,CAAC,IAAY,EAAE,KAAuB,EAAE,IAAgB;IACnF,OAAO,CAAC,IAAI,EAAE,EAAE,EAAE,mBAAmB,EAAE,IAAI,CAAC,IAAI,EAAE,KAAK,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;AAC3E,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,aAAa,CAAC,IAAY,EAAE,KAAuB,EAAE,IAAgB;IACnF,MAAM,MAAM,GAAG,MAAM,UAAU,CAAC,IAAI,CAAC,QAAQ,CAAC,aAAa,UAAU,CAAC,KAAK,CAAC,IAAI,CAAC,KAAK,UAAU,CAAC,IAAI,CAAC,QAAQ,CAAC,UAAU,CAAC;IAC1H,mGAAmG;IACnG,MAAM,OAAO,GAAG,CAAC,GAAG,IAAI,CAAC,QAAQ,CAAC,YAAY,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,KAAK,CAAC;IAC/D,OAAO,OAAO,KAAK,SAAS,CAAC,CAAC,CAAC,GAAG,IAAI,KAAK,MAAM,EAAE,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,OAAO,CAAC,GAAG,MAAM,KAAK,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,EAAE,CAAC;AACrH,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,yBAAyB,CAAC,KAAuB;IAC/D,OAAO;QACL,kBAAkB,EAAE,IAAI,KAAK,CAAC,QAAQ,GAAG;QACzC,uBAAuB,EAAE,4BAA4B;KACtD,CAAC;AACJ,CAAC;AAED,SAAS,UAAU,CAAC,IAAY;IAC9B,OAAO,IAAI,CAAC,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,OAAO,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC;AACzG,CAAC"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type AnySoftureModule, type SoftureConfig } from "@softure-ai/core";
|
|
2
|
+
import type { MailingOptions } from "../options.js";
|
|
3
|
+
/** The paths of the unsubscribe page and the one-click route (overridable in `mailing({ routes })`). */
|
|
4
|
+
export interface MailingRoutes {
|
|
5
|
+
readonly unsubscribe: string;
|
|
6
|
+
readonly oneClick: string;
|
|
7
|
+
}
|
|
8
|
+
/** The enabled module. Throws when the app did not enable it: sending then is a bug. */
|
|
9
|
+
export declare function getMailingModule(config: SoftureConfig): AnySoftureModule;
|
|
10
|
+
/** The module's options. Throws when the app did not enable it. */
|
|
11
|
+
export declare function getMailingOptions(config: SoftureConfig): MailingOptions;
|
|
12
|
+
/** The module's routes, with the app's overrides. Throws when the app did not enable it. */
|
|
13
|
+
export declare function getMailingRoutes(config: SoftureConfig): MailingRoutes;
|
|
14
|
+
//# sourceMappingURL=options.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"options.d.ts","sourceRoot":"","sources":["../../src/server/options.ts"],"names":[],"mappings":"AACA,OAAO,EAAa,KAAK,gBAAgB,EAAE,KAAK,aAAa,EAAE,MAAM,kBAAkB,CAAC;AACxF,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAIpD,wGAAwG;AACxG,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;CAC3B;AAED,wFAAwF;AACxF,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,aAAa,GAAG,gBAAgB,CAMxE;AAED,mEAAmE;AACnE,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,aAAa,GAAG,cAAc,CAGvE;AAED,4FAA4F;AAC5F,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,aAAa,GAAG,aAAa,CAGrE"}
|