@softure-ai/mailing 0.1.5 → 0.1.7
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 +28 -0
- package/README.md +85 -23
- package/dist/cli/run.d.ts +1 -1
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +56 -22
- package/dist/cli/run.js.map +1 -1
- package/dist/contract.d.ts +48 -8
- package/dist/contract.d.ts.map +1 -1
- package/dist/contract.js +2 -0
- package/dist/contract.js.map +1 -1
- package/dist/index.d.ts +12 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/messages/en.d.ts +2 -0
- package/dist/messages/en.d.ts.map +1 -1
- package/dist/messages/en.js +2 -0
- package/dist/messages/en.js.map +1 -1
- package/dist/messages/index.d.ts +4 -0
- package/dist/messages/index.d.ts.map +1 -1
- package/dist/messages/pl.d.ts.map +1 -1
- package/dist/messages/pl.js +2 -0
- package/dist/messages/pl.js.map +1 -1
- package/dist/next/actions.d.ts.map +1 -1
- package/dist/next/actions.js +6 -6
- package/dist/next/actions.js.map +1 -1
- package/dist/next/context.js +2 -2
- package/dist/next/context.js.map +1 -1
- package/dist/next/pages.d.ts.map +1 -1
- package/dist/next/pages.js +8 -6
- package/dist/next/pages.js.map +1 -1
- package/dist/next/route.d.ts +2 -1
- package/dist/next/route.d.ts.map +1 -1
- package/dist/next/route.js +6 -4
- package/dist/next/route.js.map +1 -1
- package/dist/options.d.ts +15 -1
- package/dist/options.d.ts.map +1 -1
- package/dist/options.js +32 -1
- package/dist/options.js.map +1 -1
- package/dist/providers/resend.d.ts.map +1 -1
- package/dist/providers/resend.js +12 -6
- package/dist/providers/resend.js.map +1 -1
- package/dist/schema.d.ts +17 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +2 -0
- package/dist/schema.js.map +1 -1
- package/dist/server/campaigns.d.ts +30 -4
- package/dist/server/campaigns.d.ts.map +1 -1
- package/dist/server/campaigns.js +67 -12
- package/dist/server/campaigns.js.map +1 -1
- package/dist/server/deliveries.d.ts +30 -2
- package/dist/server/deliveries.d.ts.map +1 -1
- package/dist/server/deliveries.js +58 -23
- package/dist/server/deliveries.js.map +1 -1
- package/dist/server/index.d.ts +3 -3
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +2 -2
- package/dist/server/index.js.map +1 -1
- package/dist/server/send-mail.d.ts +3 -1
- package/dist/server/send-mail.d.ts.map +1 -1
- package/dist/server/send-mail.js +18 -6
- package/dist/server/send-mail.js.map +1 -1
- package/dist/server/suppressions.d.ts +8 -6
- package/dist/server/suppressions.d.ts.map +1 -1
- package/dist/server/suppressions.js +23 -7
- package/dist/server/suppressions.js.map +1 -1
- package/dist/server/unsubscribe-link.d.ts +23 -0
- package/dist/server/unsubscribe-link.d.ts.map +1 -1
- package/dist/server/unsubscribe-link.js +30 -1
- package/dist/server/unsubscribe-link.js.map +1 -1
- package/migrations/0003_add_delivery_provider_status.sql +13 -0
- package/module.json +1 -1
- package/package.json +6 -2
- package/src/cli/run.ts +72 -27
- package/src/contract.ts +49 -6
- package/src/index.ts +8 -3
- package/src/messages/en.ts +2 -0
- package/src/messages/pl.ts +2 -0
- package/src/next/actions.ts +5 -6
- package/src/next/context.ts +2 -2
- package/src/next/pages.tsx +10 -7
- package/src/next/route.ts +6 -4
- package/src/options.ts +36 -2
- package/src/providers/resend.ts +11 -7
- package/src/schema.ts +2 -0
- package/src/server/campaigns.ts +95 -17
- package/src/server/deliveries.ts +89 -29
- package/src/server/index.ts +8 -0
- package/src/server/send-mail.ts +30 -6
- package/src/server/suppressions.ts +23 -8
- package/src/server/unsubscribe-link.ts +41 -1
package/src/contract.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Result types, error codes and the provider contract of the mailing module. No user-facing copy
|
|
2
2
|
// here: the UI translates codes through `messages` (docs/02-module-standard.md §6).
|
|
3
|
-
import type { ModuleContext,
|
|
3
|
+
import type { Err, ModuleContext, Ok } from "@softure-ai/core";
|
|
4
4
|
import type { Queryable } from "@softure-ai/db";
|
|
5
5
|
|
|
6
6
|
export type MailingErrorCode =
|
|
@@ -11,7 +11,17 @@ export type MailingErrorCode =
|
|
|
11
11
|
/** The provider could not be reached or did not answer in time. Retry later, with the same idempotency key. */
|
|
12
12
|
| "mailing.unavailable"
|
|
13
13
|
/** A list mail to a recipient who unsubscribed. Nothing was sent; do not retry. */
|
|
14
|
-
| "mailing.suppressed"
|
|
14
|
+
| "mailing.suppressed"
|
|
15
|
+
/**
|
|
16
|
+
* The provider refuses the sender, not the mail: a wrong or revoked API key, a suspended or restricted account
|
|
17
|
+
* (HTTP 401/403). Every other mail would fail the same way: stop sending, fix the account, then retry.
|
|
18
|
+
*/
|
|
19
|
+
| "mailing.provider_refused"
|
|
20
|
+
/** The account's sending quota is spent (HTTP 429 that is not a rate limit). Stop sending; retry when it renews. */
|
|
21
|
+
| "mailing.quota_exceeded";
|
|
22
|
+
|
|
23
|
+
/** The codes that are about the sending account, not the mail: a run of sends stops at the first one. */
|
|
24
|
+
export const HALTING_ERROR_CODES: ReadonlySet<MailingErrorCode> = new Set(["mailing.provider_refused", "mailing.quota_exceeded"]);
|
|
15
25
|
|
|
16
26
|
/** Why an unsubscribe link was refused: missing, malformed, or not signed by a current secret. */
|
|
17
27
|
export type UnsubscribeErrorCode = "mailing.invalid_link";
|
|
@@ -36,6 +46,32 @@ export interface UnsubscribeEvent {
|
|
|
36
46
|
*/
|
|
37
47
|
export type OnUnsubscribedHook = (event: UnsubscribeEvent, ctx: ModuleContext<Queryable>) => Promise<void>;
|
|
38
48
|
|
|
49
|
+
/** What `filterCampaignRecipient` receives for each recipient of a campaign. */
|
|
50
|
+
export interface CampaignRecipient {
|
|
51
|
+
readonly address: string;
|
|
52
|
+
/** The recipient key of the address (`getRecipientKey`). */
|
|
53
|
+
readonly recipientKey: string;
|
|
54
|
+
readonly campaign: { readonly id: string; readonly kind: string };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Decides whether a campaign goes to a recipient, e.g. by the consent scope the app stored for the address. A
|
|
59
|
+
* recipient it refuses is skipped before the ledger is touched, so a later re-run sends to them once they qualify.
|
|
60
|
+
* A throw stops the campaign, like a database failure. Set in `mailing({ filterCampaignRecipient })`.
|
|
61
|
+
*/
|
|
62
|
+
export type CampaignRecipientFilter = (recipient: CampaignRecipient, ctx: ModuleContext<Queryable>) => Promise<boolean>;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Unsubscribe links an app sent before it adopted the module, in its own scheme. `params` are the query names of
|
|
66
|
+
* such a link; `verify` receives their values (all present once, each at most 512 characters) and returns the
|
|
67
|
+
* recipient's address when the link is genuine, else `null`. It must check the link's signature itself: whatever
|
|
68
|
+
* address it returns is unsubscribed. A throw reads as a failure (the person may try again), not as a bad link.
|
|
69
|
+
*/
|
|
70
|
+
export interface LegacyUnsubscribe {
|
|
71
|
+
readonly params: readonly string[];
|
|
72
|
+
readonly verify: (values: Readonly<Record<string, string>>, ctx: ModuleContext<Queryable>) => Promise<string | null>;
|
|
73
|
+
}
|
|
74
|
+
|
|
39
75
|
/** One mail to one recipient. A mail to a list is a loop over recipients, never a list in `to`. */
|
|
40
76
|
export interface OutgoingMail {
|
|
41
77
|
/** Exactly one address: `ada@example.com`. */
|
|
@@ -77,7 +113,10 @@ export interface SentMail {
|
|
|
77
113
|
readonly provider: string;
|
|
78
114
|
}
|
|
79
115
|
|
|
80
|
-
|
|
116
|
+
/** A failed send: the code, and the provider's HTTP status when it answered with one. */
|
|
117
|
+
export type SendMailFailure = Err<MailingErrorCode> & { readonly httpStatus?: number };
|
|
118
|
+
|
|
119
|
+
export type SendMailResult = Ok<SentMail> | SendMailFailure;
|
|
81
120
|
|
|
82
121
|
/** What a provider receives: a validated mail with the sender and reply-to from the configuration. */
|
|
83
122
|
export interface ProviderMessage {
|
|
@@ -91,11 +130,15 @@ export interface ProviderMessage {
|
|
|
91
130
|
readonly idempotencyKey: string | null;
|
|
92
131
|
}
|
|
93
132
|
|
|
94
|
-
/**
|
|
133
|
+
/**
|
|
134
|
+
* What a provider answers. `httpStatus` is logged and returned with the failure; the mail never is. `refused` is the
|
|
135
|
+
* provider refusing the sender (key, account), `quota_exceeded` a spent sending quota; `rejected` is about this mail.
|
|
136
|
+
*/
|
|
95
137
|
export type ProviderOutcome =
|
|
96
138
|
| { readonly status: "sent"; readonly id: string }
|
|
97
|
-
| { readonly status:
|
|
98
|
-
|
|
139
|
+
| { readonly status: ProviderFailureStatus; readonly httpStatus?: number };
|
|
140
|
+
|
|
141
|
+
export type ProviderFailureStatus = "rejected" | "unavailable" | "refused" | "quota_exceeded";
|
|
99
142
|
|
|
100
143
|
/**
|
|
101
144
|
* A mail service adapter. `send` should resolve with an outcome for every failure it knows; a
|
package/src/index.ts
CHANGED
|
@@ -17,7 +17,7 @@ export const MODULE_ID = "mailing";
|
|
|
17
17
|
export const mailing = defineModule({
|
|
18
18
|
manifest: {
|
|
19
19
|
id: MODULE_ID,
|
|
20
|
-
version: "0.1.
|
|
20
|
+
version: "0.1.7",
|
|
21
21
|
dependsOn: {},
|
|
22
22
|
dbSchema: "mailing",
|
|
23
23
|
tables: ["suppressions", "campaigns", "deliveries"],
|
|
@@ -64,14 +64,19 @@ export {
|
|
|
64
64
|
MAX_SUBJECT_LENGTH,
|
|
65
65
|
RESERVED_HEADERS,
|
|
66
66
|
} from "./address.js";
|
|
67
|
-
export { TRANSACTIONAL_KIND } from "./contract.js";
|
|
67
|
+
export { HALTING_ERROR_CODES, TRANSACTIONAL_KIND } from "./contract.js";
|
|
68
68
|
export type {
|
|
69
|
+
CampaignRecipient,
|
|
70
|
+
CampaignRecipientFilter,
|
|
71
|
+
LegacyUnsubscribe,
|
|
69
72
|
MailingErrorCode,
|
|
70
73
|
MailProvider,
|
|
71
74
|
OnUnsubscribedHook,
|
|
72
75
|
OutgoingMail,
|
|
76
|
+
ProviderFailureStatus,
|
|
73
77
|
ProviderMessage,
|
|
74
78
|
ProviderOutcome,
|
|
79
|
+
SendMailFailure,
|
|
75
80
|
SendMailOptions,
|
|
76
81
|
SendMailResult,
|
|
77
82
|
SentMail,
|
|
@@ -80,6 +85,6 @@ export type {
|
|
|
80
85
|
UnsubscribeEvent,
|
|
81
86
|
} from "./contract.js";
|
|
82
87
|
export { getMailingErrorMessage, mailingMessages, type MailingMessages } from "./messages/index.js";
|
|
83
|
-
export { DEFAULT_TIMEOUT_MS, type MailingOptions, type MailingOptionsInput } from "./options.js";
|
|
88
|
+
export { DEFAULT_STALE_CLAIM_MS, DEFAULT_TIMEOUT_MS, DEFAULT_UNCERTAIN_CLAIM_MS, type MailingOptions, type MailingOptionsInput } from "./options.js";
|
|
84
89
|
export { resend, RESEND_API_KEY_ENV, RESEND_ENDPOINT, type ResendOptions } from "./providers/resend.js";
|
|
85
90
|
export { campaigns, deliveries, mailingSchema, suppressions, type DeliveryStatus } from "./schema.js";
|
package/src/messages/en.ts
CHANGED
|
@@ -5,6 +5,8 @@ export const en = {
|
|
|
5
5
|
rejected: "The mail service refused this message.",
|
|
6
6
|
unavailable: "The mail service is not answering. Try again in a moment.",
|
|
7
7
|
suppressed: "This address unsubscribed from these emails, so nothing was sent.",
|
|
8
|
+
provider_refused: "The mail service refused our account, so nothing was sent. We need to fix it on our side.",
|
|
9
|
+
quota_exceeded: "We reached our sending limit for now. Try again later.",
|
|
8
10
|
},
|
|
9
11
|
},
|
|
10
12
|
footer: {
|
package/src/messages/pl.ts
CHANGED
|
@@ -8,6 +8,8 @@ export const pl: typeof en = {
|
|
|
8
8
|
rejected: "Usługa pocztowa odrzuciła tę wiadomość.",
|
|
9
9
|
unavailable: "Usługa pocztowa nie odpowiada. Spróbuj ponownie za chwilę.",
|
|
10
10
|
suppressed: "Ten adres wypisał się z takich wiadomości, więc nic nie wysłano.",
|
|
11
|
+
provider_refused: "Usługa pocztowa odrzuciła nasze konto, więc nic nie wysłano. Musimy to naprawić po naszej stronie.",
|
|
12
|
+
quota_exceeded: "Wyczerpaliśmy na razie limit wysyłki. Spróbuj ponownie później.",
|
|
11
13
|
},
|
|
12
14
|
},
|
|
13
15
|
footer: {
|
package/src/next/actions.ts
CHANGED
|
@@ -9,16 +9,16 @@ import { getSoftureConfig } from "@softure-ai/core/next";
|
|
|
9
9
|
import { redirect } from "next/navigation";
|
|
10
10
|
import { getMailingRoutes } from "../server/options.js";
|
|
11
11
|
import { unsubscribe } from "../server/suppressions.js";
|
|
12
|
-
import {
|
|
12
|
+
import { getUnsubscribeLinkParams, readUnsubscribeLink } from "../server/unsubscribe-link.js";
|
|
13
13
|
import { getMailingContext } from "./context.js";
|
|
14
14
|
import { UNSUBSCRIBE_STATUS_PARAM, type UnsubscribeStatus } from "./params.js";
|
|
15
15
|
|
|
16
16
|
export async function unsubscribeAction(formData: FormData): Promise<void> {
|
|
17
17
|
const config = getSoftureConfig();
|
|
18
|
-
const
|
|
18
|
+
const link = readUnsubscribeLink(formData, config);
|
|
19
19
|
let status: UnsubscribeStatus;
|
|
20
20
|
try {
|
|
21
|
-
const result = await unsubscribe(await getMailingContext(config),
|
|
21
|
+
const result = await unsubscribe(await getMailingContext(config), link, "page");
|
|
22
22
|
status = result.ok ? "done" : "invalid";
|
|
23
23
|
} catch (error) {
|
|
24
24
|
// The label names the kind of failure, never its text: that can carry the link's values.
|
|
@@ -26,10 +26,9 @@ export async function unsubscribeAction(formData: FormData): Promise<void> {
|
|
|
26
26
|
status = "failed";
|
|
27
27
|
}
|
|
28
28
|
const query = new URLSearchParams({ [UNSUBSCRIBE_STATUS_PARAM]: status });
|
|
29
|
-
if (status === "failed" &&
|
|
29
|
+
if (status === "failed" && link !== null) {
|
|
30
30
|
// The form comes back with the same link, so the person can try again.
|
|
31
|
-
query.set(
|
|
32
|
-
query.set(SIGNATURE_PARAM, token.signature);
|
|
31
|
+
for (const [name, value] of Object.entries(getUnsubscribeLinkParams(link))) query.set(name, value);
|
|
33
32
|
}
|
|
34
33
|
redirect(`${getMailingRoutes(config).unsubscribe}?${query.toString()}`);
|
|
35
34
|
}
|
package/src/next/context.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// handle and the wall clock.
|
|
3
3
|
import { systemClock, type SoftureConfig } from "@softure-ai/core";
|
|
4
4
|
import { getSoftureConfig } from "@softure-ai/core/next";
|
|
5
|
-
import {
|
|
5
|
+
import { getConfiguredDatabase } from "@softure-ai/db";
|
|
6
6
|
import type { SuppressionContext } from "../server/suppressions.js";
|
|
7
7
|
|
|
8
8
|
export async function getMailingContext(config: SoftureConfig = getSoftureConfig()): Promise<SuppressionContext> {
|
|
@@ -10,6 +10,6 @@ export async function getMailingContext(config: SoftureConfig = getSoftureConfig
|
|
|
10
10
|
// Unreachable for a validated config: the module has a database schema.
|
|
11
11
|
throw new Error("@softure-ai/mailing: softure.config.ts has no database; mailing needs one");
|
|
12
12
|
}
|
|
13
|
-
const { db } = await
|
|
13
|
+
const { db } = await getConfiguredDatabase(config.database);
|
|
14
14
|
return { db, clock: systemClock, config };
|
|
15
15
|
}
|
package/src/next/pages.tsx
CHANGED
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
// `export { UnsubscribePage as default } from "@softure-ai/mailing/next"`.
|
|
3
3
|
// A server component without client JavaScript: the button is a plain form posting a server
|
|
4
4
|
// action, so it works in any browser a mail client opens. Opening the page changes nothing (mail
|
|
5
|
-
// scanners open links too) and does not check the signature: the submit does.
|
|
5
|
+
// scanners open links too) and does not check the signature: the submit does. It takes the
|
|
6
|
+
// module's signed links and, with `mailing({ legacyUnsubscribe })`, the app's older ones: mount it
|
|
7
|
+
// at the old links' path too.
|
|
6
8
|
import { getSoftureConfig } from "@softure-ai/core/next";
|
|
7
9
|
import { Button, Card, FormError } from "@softure-ai/ui";
|
|
8
10
|
import type { ReactNode } from "react";
|
|
9
|
-
import {
|
|
11
|
+
import { getUnsubscribeLinkParams, readUnsubscribeLink } from "../server/unsubscribe-link.js";
|
|
10
12
|
import { unsubscribeAction } from "./actions.js";
|
|
11
13
|
import { getMailingMessages } from "./messages.js";
|
|
12
14
|
import { UNSUBSCRIBE_STATUS_PARAM } from "./params.js";
|
|
@@ -47,16 +49,17 @@ export async function UnsubscribePage({ searchParams }: UnsubscribePageProps) {
|
|
|
47
49
|
const status = readParam(params, UNSUBSCRIBE_STATUS_PARAM);
|
|
48
50
|
if (status === "done") return <UnsubscribeLayout title={messages.doneTitle} lead={messages.doneBody} />;
|
|
49
51
|
|
|
50
|
-
const
|
|
51
|
-
const
|
|
52
|
-
if (status === "invalid" ||
|
|
52
|
+
const link = readUnsubscribeLink({ get: (name) => readParam(params, name) ?? null }, config);
|
|
53
|
+
const linkParams = link === null ? {} : getUnsubscribeLinkParams(link);
|
|
54
|
+
if (status === "invalid" || link === null || Object.values(linkParams).some((value) => value === "")) {
|
|
53
55
|
return <UnsubscribeLayout title={messages.invalidTitle} lead={messages.invalidBody} />;
|
|
54
56
|
}
|
|
55
57
|
return (
|
|
56
58
|
<UnsubscribeLayout title={messages.title} lead={messages.lead}>
|
|
57
59
|
<form action={unsubscribeAction} className={FORM_CLASS}>
|
|
58
|
-
|
|
59
|
-
|
|
60
|
+
{Object.entries(linkParams).map(([name, value]) => (
|
|
61
|
+
<input key={name} type="hidden" name={name} value={value} />
|
|
62
|
+
))}
|
|
60
63
|
<FormError message={status === "failed" ? messages.failed : undefined} />
|
|
61
64
|
<div>
|
|
62
65
|
<Button type="submit" variant="primary">
|
package/src/next/route.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { errorLogLabel } from "@softure-ai/core";
|
|
|
12
12
|
import { getSoftureConfig } from "@softure-ai/core/next";
|
|
13
13
|
import { getMailingRoutes } from "../server/options.js";
|
|
14
14
|
import { unsubscribe } from "../server/suppressions.js";
|
|
15
|
-
import {
|
|
15
|
+
import { readUnsubscribeLink } from "../server/unsubscribe-link.js";
|
|
16
16
|
import { getMailingContext } from "./context.js";
|
|
17
17
|
|
|
18
18
|
const NO_STORE = { "cache-control": "no-store" };
|
|
@@ -21,12 +21,14 @@ const NO_STORE = { "cache-control": "no-store" };
|
|
|
21
21
|
* Records the opt-out of the link in the URL. The body (`List-Unsubscribe=One-Click`) is not read:
|
|
22
22
|
* anyone can send it, the signature is the proof. 200 once recorded (also again), 400 for a link
|
|
23
23
|
* that does not verify, 500 when the database fails: the client may retry, and 200 would claim an
|
|
24
|
-
* opt-out that did not happen.
|
|
24
|
+
* opt-out that did not happen. A legacy link (`mailing({ legacyUnsubscribe })`) is handled the same
|
|
25
|
+
* way; mount this route at the old link's path too.
|
|
25
26
|
*/
|
|
26
27
|
export async function postUnsubscribeRoute(request: Request): Promise<Response> {
|
|
27
|
-
const token = readUnsubscribeToken(new URL(request.url).searchParams);
|
|
28
28
|
try {
|
|
29
|
-
const
|
|
29
|
+
const ctx = await getMailingContext();
|
|
30
|
+
const link = readUnsubscribeLink(new URL(request.url).searchParams, ctx.config);
|
|
31
|
+
const result = await unsubscribe(ctx, link, "one-click");
|
|
30
32
|
return new Response(null, { status: result.ok ? 200 : 400, headers: NO_STORE });
|
|
31
33
|
} catch (error) {
|
|
32
34
|
console.error(`@softure-ai/mailing: one-click unsubscribe failed: ${errorLogLabel(error)}`);
|
package/src/options.ts
CHANGED
|
@@ -1,9 +1,32 @@
|
|
|
1
1
|
// The options an app passes to `mailing({ ... })` in softure.config.ts, parsed at startup.
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { isMailbox, isSingleAddress } from "./address.js";
|
|
4
|
-
import type { MailProvider, OnUnsubscribedHook } from "./contract.js";
|
|
4
|
+
import type { CampaignRecipientFilter, LegacyUnsubscribe, MailProvider, OnUnsubscribedHook } from "./contract.js";
|
|
5
5
|
|
|
6
6
|
export const DEFAULT_TIMEOUT_MS = 10_000;
|
|
7
|
+
/** How long a delivery claim may stay open before another sender may take it over (and send again, same key). */
|
|
8
|
+
export const DEFAULT_STALE_CLAIM_MS = 15 * 60_000;
|
|
9
|
+
/**
|
|
10
|
+
* How old a claim may get before it is "uncertain" and never taken over by itself: its send may have gone out, and
|
|
11
|
+
* the provider may have forgotten the idempotency key (Resend keeps one for 24 hours), so a retake could mail twice.
|
|
12
|
+
*/
|
|
13
|
+
export const DEFAULT_UNCERTAIN_CLAIM_MS = 23 * 60 * 60_000;
|
|
14
|
+
|
|
15
|
+
/** Query names the signed link and the page use; a legacy link cannot claim them. */
|
|
16
|
+
const RESERVED_LINK_PARAMS: ReadonlySet<string> = new Set(["r", "status"]);
|
|
17
|
+
const LINK_PARAM = /^[A-Za-z][A-Za-z0-9_-]{0,31}$/;
|
|
18
|
+
|
|
19
|
+
const legacyUnsubscribeSchema = z.strictObject({
|
|
20
|
+
params: z
|
|
21
|
+
.array(z.string().regex(LINK_PARAM, "must be a query name: a letter, then letters, digits, _ or -, at most 32 characters"))
|
|
22
|
+
.min(1)
|
|
23
|
+
.max(8)
|
|
24
|
+
.refine((params) => new Set(params).size === params.length, "must not repeat a name")
|
|
25
|
+
.refine((params) => params.every((param) => !RESERVED_LINK_PARAMS.has(param)), "must not use r or status: the signed link and the page own them"),
|
|
26
|
+
verify: z.custom<LegacyUnsubscribe["verify"]>((value) => typeof value === "function", "must be a function"),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const HOUR_MS = 60 * 60_000;
|
|
7
30
|
|
|
8
31
|
function isMailProvider(value: unknown): value is MailProvider {
|
|
9
32
|
if (typeof value !== "object" || value === null) return false;
|
|
@@ -28,7 +51,18 @@ export const mailingOptionsSchema = z.strictObject({
|
|
|
28
51
|
* `@softure-ai/waitlist/server`, which records the withdrawal in privacy's consent ledger.
|
|
29
52
|
*/
|
|
30
53
|
onUnsubscribed: z.custom<OnUnsubscribedHook>((value) => typeof value === "function", "must be a function").optional(),
|
|
31
|
-
|
|
54
|
+
/** Verifies unsubscribe links the app sent before it adopted the module (see `LegacyUnsubscribe`). */
|
|
55
|
+
legacyUnsubscribe: legacyUnsubscribeSchema.optional(),
|
|
56
|
+
/** Decides per recipient whether a campaign goes to them, e.g. by consent scope (see `CampaignRecipientFilter`). */
|
|
57
|
+
filterCampaignRecipient: z.custom<CampaignRecipientFilter>((value) => typeof value === "function", "must be a function").optional(),
|
|
58
|
+
/** How long a delivery claim may stay open before another sender takes it over. 1 minute to 23 hours. */
|
|
59
|
+
staleClaimMs: z.number().int().min(60_000).max(23 * HOUR_MS).default(DEFAULT_STALE_CLAIM_MS),
|
|
60
|
+
/**
|
|
61
|
+
* How old a claim may get before it is never taken over by itself (`uncertain`); keep it under the provider's
|
|
62
|
+
* idempotency window. More than `staleClaimMs`, at most 30 days.
|
|
63
|
+
*/
|
|
64
|
+
uncertainClaimMs: z.number().int().min(60_000).max(30 * 24 * HOUR_MS).default(DEFAULT_UNCERTAIN_CLAIM_MS),
|
|
65
|
+
}).refine((options) => options.uncertainClaimMs > options.staleClaimMs, { message: "must be more than staleClaimMs", path: ["uncertainClaimMs"] });
|
|
32
66
|
|
|
33
67
|
export type MailingOptionsInput = z.input<typeof mailingOptionsSchema>;
|
|
34
68
|
export type MailingOptions = z.output<typeof mailingOptionsSchema>;
|
package/src/providers/resend.ts
CHANGED
|
@@ -21,8 +21,11 @@ export interface ResendOptions {
|
|
|
21
21
|
/** Resend's name for an idempotency key that another request is still using: retry later. */
|
|
22
22
|
const CONCURRENT_IDEMPOTENT_REQUESTS = "concurrent_idempotent_requests";
|
|
23
23
|
|
|
24
|
-
/**
|
|
25
|
-
const
|
|
24
|
+
/** Resend's name for a 429 that is a per-second rate limit; any other 429 is a spent daily or monthly quota. */
|
|
25
|
+
const RATE_LIMIT_EXCEEDED = "rate_limit_exceeded";
|
|
26
|
+
|
|
27
|
+
/** Statuses that refuse the sender (missing, invalid or restricted key, account not allowed to send). */
|
|
28
|
+
const REFUSED_STATUSES: ReadonlySet<number> = new Set([401, 403]);
|
|
26
29
|
|
|
27
30
|
/** Sends through Resend's HTTP API. */
|
|
28
31
|
export function resend(options: ResendOptions = {}): MailProvider {
|
|
@@ -72,11 +75,12 @@ async function readOutcome(response: Response): Promise<ProviderOutcome> {
|
|
|
72
75
|
return id === null ? { status: "unavailable", httpStatus: response.status } : { status: "sent", id };
|
|
73
76
|
}
|
|
74
77
|
// Only the error's `name` (an enum) is read: its `message` can echo the address back.
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
78
|
+
const name = readString(payload, "name");
|
|
79
|
+
const httpStatus = response.status;
|
|
80
|
+
if (REFUSED_STATUSES.has(httpStatus)) return { status: "refused", httpStatus };
|
|
81
|
+
if (httpStatus === 429) return { status: name === RATE_LIMIT_EXCEEDED ? "unavailable" : "quota_exceeded", httpStatus };
|
|
82
|
+
const isRetryable = httpStatus >= 500 || httpStatus === 408 || (httpStatus === 409 && name === CONCURRENT_IDEMPOTENT_REQUESTS);
|
|
83
|
+
return { status: isRetryable ? "unavailable" : "rejected", httpStatus };
|
|
80
84
|
}
|
|
81
85
|
|
|
82
86
|
function readString(payload: unknown, key: string): string | null {
|
package/src/schema.ts
CHANGED
|
@@ -31,6 +31,8 @@ export const deliveries = mailingSchema.table(
|
|
|
31
31
|
finishedAt: timestamp("finished_at", { withTimezone: true }),
|
|
32
32
|
providerMessageId: text("provider_message_id"),
|
|
33
33
|
reason: text("reason"),
|
|
34
|
+
/** The provider's HTTP status of the last answer that failed or was released; null when there was none. */
|
|
35
|
+
providerStatus: integer("provider_status"),
|
|
34
36
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
|
|
35
37
|
},
|
|
36
38
|
(table) => [primaryKey({ columns: [table.scope, table.recipientKey] })],
|
package/src/server/campaigns.ts
CHANGED
|
@@ -1,19 +1,27 @@
|
|
|
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
|
-
// the same mail and nothing twice; other content under that id is refused.
|
|
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), leaving the rest for the next run.
|
|
4
5
|
import { createHash } from "node:crypto";
|
|
5
6
|
import { err, ok, type Result } from "@softure-ai/core";
|
|
6
7
|
import { and, eq, inArray } from "drizzle-orm";
|
|
7
|
-
import type { MailingErrorCode } from "../contract.js";
|
|
8
|
+
import type { CampaignRecipient, MailingErrorCode } from "../contract.js";
|
|
8
9
|
import { campaigns, deliveries } from "../schema.js";
|
|
9
10
|
import { getCampaignProblems, type CampaignContent } from "./campaign-file.js";
|
|
10
|
-
import { deliverOnce, type DeliverOptions, type DeliveryContext, type DeliveryOutcome } from "./deliveries.js";
|
|
11
|
+
import { deliverOnce, type DeliverOptions, type DeliveryContext, type DeliveryOutcome, type HaltingErrorCode } from "./deliveries.js";
|
|
12
|
+
import { getMailingOptions } from "./options.js";
|
|
11
13
|
import { isSuppressed } from "./suppressions.js";
|
|
12
14
|
import { getRecipientKey } from "./unsubscribe-link.js";
|
|
13
15
|
|
|
14
16
|
/** Rejections the provider answered (the others never left the process). */
|
|
15
17
|
const PROVIDER_REASONS: ReadonlySet<MailingErrorCode> = new Set(["mailing.rejected", "mailing.unavailable"]);
|
|
16
18
|
|
|
19
|
+
/** Why a run stopped before the end of the list. */
|
|
20
|
+
export interface CampaignHalt {
|
|
21
|
+
readonly reason: HaltingErrorCode;
|
|
22
|
+
readonly httpStatus?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
17
25
|
/** The campaign id is taken by other content. Give the new content a new id. */
|
|
18
26
|
export type CampaignErrorCode = "mailing.campaign_changed";
|
|
19
27
|
|
|
@@ -29,6 +37,15 @@ export interface CampaignSummary {
|
|
|
29
37
|
readonly inFlight: number;
|
|
30
38
|
/** The provider was unavailable; the next run tries them again. */
|
|
31
39
|
readonly retryLater: number;
|
|
40
|
+
/** Skipped by `filterCampaignRecipient`; nothing stored, so a later run sends to them once they qualify. */
|
|
41
|
+
readonly filtered: number;
|
|
42
|
+
/** Claimed more than `uncertainClaimMs` ago and never closed: their mail may have gone out. */
|
|
43
|
+
readonly uncertain: number;
|
|
44
|
+
/**
|
|
45
|
+
* Set when the run stopped at a failure about the sending account. The recipient it happened to and every one
|
|
46
|
+
* after it were left as they were; run again once the account can send.
|
|
47
|
+
*/
|
|
48
|
+
readonly halted: CampaignHalt | null;
|
|
32
49
|
}
|
|
33
50
|
|
|
34
51
|
export interface SendCampaignOptions extends DeliverOptions {
|
|
@@ -61,7 +78,9 @@ export async function registerCampaign(ctx: DeliveryContext, content: CampaignCo
|
|
|
61
78
|
|
|
62
79
|
/**
|
|
63
80
|
* Sends `campaign` to every recipient that has no outcome yet, one at a time. Recipients who
|
|
64
|
-
* unsubscribed are rejected (`mailing.suppressed`) and never retried
|
|
81
|
+
* unsubscribed are rejected (`mailing.suppressed`) and never retried; recipients the module's
|
|
82
|
+
* `filterCampaignRecipient` refuses are skipped. Stops at the first `halted` delivery. Throws on a
|
|
83
|
+
* database failure and when the filter throws.
|
|
65
84
|
*/
|
|
66
85
|
export async function sendCampaign(
|
|
67
86
|
ctx: DeliveryContext,
|
|
@@ -73,13 +92,18 @@ export async function sendCampaign(
|
|
|
73
92
|
const registered = await registerCampaign(ctx, campaign);
|
|
74
93
|
if (!registered.ok) return registered;
|
|
75
94
|
|
|
76
|
-
const counts = { sent: 0, done: 0, inFlight: 0, retryLater: 0, rejected: emptyRejections() };
|
|
95
|
+
const counts: Counts = { sent: 0, done: 0, inFlight: 0, retryLater: 0, filtered: 0, uncertain: 0, rejected: emptyRejections() };
|
|
77
96
|
const seen = new Set<string>();
|
|
78
97
|
const scope = `campaign:${campaign.id}`;
|
|
98
|
+
const isWanted = createRecipientFilter(ctx, campaign);
|
|
79
99
|
for await (const address of input.recipients) {
|
|
80
100
|
const recipientKey = getRecipientKey(address);
|
|
81
101
|
if (seen.has(recipientKey)) continue;
|
|
82
102
|
seen.add(recipientKey);
|
|
103
|
+
if (!(await isWanted(address, recipientKey))) {
|
|
104
|
+
counts.filtered += 1;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
83
107
|
|
|
84
108
|
const html = campaign.html === null ? {} : { html: campaign.html };
|
|
85
109
|
const outcome = await deliverOnce(
|
|
@@ -89,10 +113,26 @@ export async function sendCampaign(
|
|
|
89
113
|
);
|
|
90
114
|
countOutcome(counts, outcome);
|
|
91
115
|
onDelivery?.(outcome);
|
|
116
|
+
if (outcome.status === "halted") {
|
|
117
|
+
// The rest of the list is not even counted as seen: the summary covers what this run reached.
|
|
118
|
+
const httpStatus = outcome.httpStatus === undefined ? {} : { httpStatus: outcome.httpStatus };
|
|
119
|
+
return ok({ recipients: seen.size, ...counts, halted: { reason: outcome.reason, ...httpStatus } });
|
|
120
|
+
}
|
|
92
121
|
const reachedProvider = outcome.status === "sent" || outcome.status === "retry-later" || (outcome.status === "rejected" && PROVIDER_REASONS.has(outcome.reason));
|
|
93
122
|
if (reachedProvider && pauseMs > 0) await sleep(pauseMs);
|
|
94
123
|
}
|
|
95
|
-
return ok({ recipients: seen.size, ...counts });
|
|
124
|
+
return ok({ recipients: seen.size, ...counts, halted: null });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** `filterCampaignRecipient` bound to the campaign, or "everyone" when the app set none. */
|
|
128
|
+
function createRecipientFilter(ctx: DeliveryContext, campaign: CampaignContent): (address: string, recipientKey: string) => Promise<boolean> {
|
|
129
|
+
const filter = getMailingOptions(ctx.config).filterCampaignRecipient;
|
|
130
|
+
if (filter === undefined) return () => Promise.resolve(true);
|
|
131
|
+
const target = { id: campaign.id, kind: campaign.kind };
|
|
132
|
+
return (address, recipientKey) => {
|
|
133
|
+
const recipient: CampaignRecipient = { address, recipientKey, campaign: target };
|
|
134
|
+
return filter(recipient, ctx);
|
|
135
|
+
};
|
|
96
136
|
}
|
|
97
137
|
|
|
98
138
|
export interface CampaignPlan {
|
|
@@ -101,16 +141,27 @@ export interface CampaignPlan {
|
|
|
101
141
|
readonly done: number;
|
|
102
142
|
/** Not closed yet, but unsubscribed: they will be rejected without a send. */
|
|
103
143
|
readonly suppressed: number;
|
|
144
|
+
/** Not closed yet, but `filterCampaignRecipient` refuses them: they will be skipped. */
|
|
145
|
+
readonly filtered: number;
|
|
146
|
+
/**
|
|
147
|
+
* Claimed more than `uncertainClaimMs` ago and never closed: skipped unless the run retakes uncertain claims
|
|
148
|
+
* (then they are sent again and counted in `toSend` too).
|
|
149
|
+
*/
|
|
150
|
+
readonly uncertain: number;
|
|
104
151
|
/** Would be sent now. */
|
|
105
152
|
readonly toSend: number;
|
|
106
153
|
/** The campaign id is stored with other content: a real run would be refused. */
|
|
107
154
|
readonly contentChanged: boolean;
|
|
108
155
|
}
|
|
109
156
|
|
|
110
|
-
/**
|
|
157
|
+
/**
|
|
158
|
+
* What `sendCampaign` would do, without writing or sending anything (the filter runs, and must not write). Throws
|
|
159
|
+
* on a database failure. `toSend` counts uncertain recipients only with `retakeUncertain`.
|
|
160
|
+
*/
|
|
111
161
|
export async function planCampaign(
|
|
112
162
|
ctx: DeliveryContext,
|
|
113
163
|
input: { readonly campaign: CampaignContent; readonly recipients: Iterable<string> | AsyncIterable<string> },
|
|
164
|
+
options: Pick<DeliverOptions, "uncertainClaimMs" | "retakeUncertain"> = {},
|
|
114
165
|
): Promise<CampaignPlan> {
|
|
115
166
|
const { campaign } = input;
|
|
116
167
|
assertCampaign(campaign);
|
|
@@ -122,20 +173,35 @@ export async function planCampaign(
|
|
|
122
173
|
const key = getRecipientKey(address);
|
|
123
174
|
if (!addresses.has(key)) addresses.set(key, address);
|
|
124
175
|
}
|
|
176
|
+
const uncertainBefore = ctx.clock.now().getTime() - (options.uncertainClaimMs ?? getMailingOptions(ctx.config).uncertainClaimMs);
|
|
125
177
|
const closed = new Set<string>();
|
|
178
|
+
const uncertainKeys = new Set<string>();
|
|
126
179
|
const keys = [...addresses.keys()];
|
|
127
180
|
for (let start = 0; start < keys.length; start += 1_000) {
|
|
128
181
|
const rows = await ctx.db
|
|
129
|
-
.select({ recipientKey: deliveries.recipientKey })
|
|
182
|
+
.select({ recipientKey: deliveries.recipientKey, status: deliveries.status, claimedAt: deliveries.claimedAt })
|
|
130
183
|
.from(deliveries)
|
|
131
|
-
.where(and(eq(deliveries.scope, `campaign:${campaign.id}`), inArray(deliveries.status, ["sent", "rejected"]), inArray(deliveries.recipientKey, keys.slice(start, start + 1_000))));
|
|
132
|
-
|
|
184
|
+
.where(and(eq(deliveries.scope, `campaign:${campaign.id}`), inArray(deliveries.status, ["sent", "rejected", "claimed"]), inArray(deliveries.recipientKey, keys.slice(start, start + 1_000))));
|
|
185
|
+
for (const row of rows) {
|
|
186
|
+
if (row.status !== "claimed") closed.add(row.recipientKey);
|
|
187
|
+
else if (row.claimedAt.getTime() < uncertainBefore) uncertainKeys.add(row.recipientKey);
|
|
188
|
+
}
|
|
133
189
|
}
|
|
190
|
+
const isWanted = createRecipientFilter(ctx, campaign);
|
|
134
191
|
let suppressed = 0;
|
|
192
|
+
let filtered = 0;
|
|
193
|
+
let toSend = 0;
|
|
194
|
+
let uncertain = 0;
|
|
135
195
|
for (const [key, address] of addresses) {
|
|
136
|
-
if (
|
|
196
|
+
if (closed.has(key)) continue;
|
|
197
|
+
if (!(await isWanted(address, key))) filtered += 1;
|
|
198
|
+
else if (await isSuppressed(ctx, address)) suppressed += 1;
|
|
199
|
+
else if (uncertainKeys.has(key)) {
|
|
200
|
+
uncertain += 1;
|
|
201
|
+
if (options.retakeUncertain === true) toSend += 1;
|
|
202
|
+
} else toSend += 1;
|
|
137
203
|
}
|
|
138
|
-
return { recipients: addresses.size, done: closed.size, suppressed,
|
|
204
|
+
return { recipients: addresses.size, done: closed.size, suppressed, filtered, uncertain, toSend, contentChanged };
|
|
139
205
|
}
|
|
140
206
|
|
|
141
207
|
function assertCampaign(content: CampaignContent): void {
|
|
@@ -144,13 +210,20 @@ function assertCampaign(content: CampaignContent): void {
|
|
|
144
210
|
}
|
|
145
211
|
|
|
146
212
|
function emptyRejections(): Record<MailingErrorCode, number> {
|
|
147
|
-
return { "mailing.invalid_input": 0, "mailing.rejected": 0, "mailing.unavailable": 0, "mailing.suppressed": 0 };
|
|
213
|
+
return { "mailing.invalid_input": 0, "mailing.rejected": 0, "mailing.unavailable": 0, "mailing.suppressed": 0, "mailing.provider_refused": 0, "mailing.quota_exceeded": 0 };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
interface Counts {
|
|
217
|
+
sent: number;
|
|
218
|
+
done: number;
|
|
219
|
+
inFlight: number;
|
|
220
|
+
retryLater: number;
|
|
221
|
+
filtered: number;
|
|
222
|
+
uncertain: number;
|
|
223
|
+
rejected: Record<MailingErrorCode, number>;
|
|
148
224
|
}
|
|
149
225
|
|
|
150
|
-
function countOutcome(
|
|
151
|
-
counts: { sent: number; done: number; inFlight: number; retryLater: number; rejected: Record<MailingErrorCode, number> },
|
|
152
|
-
outcome: DeliveryOutcome,
|
|
153
|
-
): void {
|
|
226
|
+
function countOutcome(counts: Counts, outcome: DeliveryOutcome): void {
|
|
154
227
|
switch (outcome.status) {
|
|
155
228
|
case "sent":
|
|
156
229
|
counts.sent += 1;
|
|
@@ -165,8 +238,13 @@ function countOutcome(
|
|
|
165
238
|
counts.inFlight += 1;
|
|
166
239
|
return;
|
|
167
240
|
case "retry-later":
|
|
241
|
+
case "halted":
|
|
242
|
+
// A halted recipient is left for the next run, like one the provider could not take now.
|
|
168
243
|
counts.retryLater += 1;
|
|
169
244
|
return;
|
|
245
|
+
case "uncertain":
|
|
246
|
+
counts.uncertain += 1;
|
|
247
|
+
return;
|
|
170
248
|
}
|
|
171
249
|
}
|
|
172
250
|
|