@softure-ai/mailing 0.1.6 → 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 +19 -0
- package/README.md +83 -18
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +46 -15
- 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/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 +1 -1
- package/src/cli/run.ts +61 -18
- 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/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/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
|
|
package/src/server/deliveries.ts
CHANGED
|
@@ -1,20 +1,24 @@
|
|
|
1
1
|
// The delivery ledger (mailing.deliveries): a mail goes out at most once per scope and recipient.
|
|
2
2
|
// The sender claims the row in one conditional upsert before it calls the provider, then closes it
|
|
3
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.
|
|
4
|
+
// sent again with the same idempotency key, so the provider folds it into the first send. A claim
|
|
5
|
+
// older than the provider keeps that key is "uncertain": it is taken over only when the operator
|
|
6
|
+
// says so. A failure about the sending account (refused key, spent quota) closes nothing: the
|
|
7
|
+
// claim goes back, attempt included, so fixing the account and re-running loses no recipient.
|
|
5
8
|
import { type ModuleContext } from "@softure-ai/core";
|
|
6
9
|
import type { Queryable } from "@softure-ai/db";
|
|
7
|
-
import { and, eq, lt, or, sql } from "drizzle-orm";
|
|
10
|
+
import { and, eq, gte, lt, or, sql, type SQL } from "drizzle-orm";
|
|
8
11
|
import { isMailKind } from "../address.js";
|
|
9
|
-
import { TRANSACTIONAL_KIND, type MailingErrorCode, type OutgoingMail } from "../contract.js";
|
|
12
|
+
import { HALTING_ERROR_CODES, TRANSACTIONAL_KIND, type MailingErrorCode, type OutgoingMail } from "../contract.js";
|
|
13
|
+
import { DEFAULT_STALE_CLAIM_MS, DEFAULT_UNCERTAIN_CLAIM_MS } from "../options.js";
|
|
10
14
|
import { deliveries } from "../schema.js";
|
|
15
|
+
import { getMailingOptions } from "./options.js";
|
|
11
16
|
import { sendMail } from "./send-mail.js";
|
|
12
17
|
import { getRecipientKey } from "./unsubscribe-link.js";
|
|
13
18
|
|
|
14
19
|
export type DeliveryContext = ModuleContext<Queryable>;
|
|
15
20
|
|
|
16
|
-
|
|
17
|
-
export const DEFAULT_STALE_CLAIM_MS = 15 * 60_000;
|
|
21
|
+
export { DEFAULT_STALE_CLAIM_MS, DEFAULT_UNCERTAIN_CLAIM_MS };
|
|
18
22
|
/** Claims a delivery gets before `mailing.unavailable` becomes its final outcome. */
|
|
19
23
|
export const DEFAULT_MAX_ATTEMPTS = 5;
|
|
20
24
|
|
|
@@ -35,21 +39,42 @@ export interface Delivery {
|
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
export interface DeliverOptions {
|
|
42
|
+
/** Default: the module's `staleClaimMs` (15 minutes). */
|
|
38
43
|
readonly staleClaimMs?: number;
|
|
44
|
+
/** Default: the module's `uncertainClaimMs` (23 hours). */
|
|
45
|
+
readonly uncertainClaimMs?: number;
|
|
46
|
+
/**
|
|
47
|
+
* Takes over uncertain claims too (older than `uncertainClaimMs`), accepting that their mail may arrive twice.
|
|
48
|
+
* An operator's decision, e.g. `softure-mail campaign --resend-uncertain`. Default false.
|
|
49
|
+
*/
|
|
50
|
+
readonly retakeUncertain?: boolean;
|
|
39
51
|
readonly maxAttempts?: number;
|
|
40
52
|
}
|
|
41
53
|
|
|
54
|
+
/** The codes that stop a run: the sending account, not the mail, is the problem. */
|
|
55
|
+
export type HaltingErrorCode = "mailing.provider_refused" | "mailing.quota_exceeded";
|
|
56
|
+
|
|
42
57
|
export type DeliveryOutcome =
|
|
43
58
|
/** Sent now; `id` is the provider's message id. */
|
|
44
59
|
| { readonly status: "sent"; readonly id: string }
|
|
45
60
|
/** Refused now, for good (suppressed, rejected, invalid, or unavailable too many times). */
|
|
46
|
-
| { readonly status: "rejected"; readonly reason: MailingErrorCode }
|
|
61
|
+
| { readonly status: "rejected"; readonly reason: MailingErrorCode; readonly httpStatus?: number }
|
|
47
62
|
/** An earlier run already closed this delivery; nothing was sent. */
|
|
48
63
|
| { readonly status: "done"; readonly outcome: "sent" | "rejected" }
|
|
49
64
|
/** Another sender holds a fresh claim; nothing was sent. */
|
|
50
65
|
| { readonly status: "in-flight" }
|
|
66
|
+
/**
|
|
67
|
+
* A claim older than `uncertainClaimMs` is open: its mail may have gone out. Nothing was sent; only
|
|
68
|
+
* `retakeUncertain` sends it again.
|
|
69
|
+
*/
|
|
70
|
+
| { readonly status: "uncertain" }
|
|
51
71
|
/** The provider was unavailable; the claim is released for a later run. */
|
|
52
|
-
| { readonly status: "retry-later" }
|
|
72
|
+
| { readonly status: "retry-later"; readonly httpStatus?: number }
|
|
73
|
+
/**
|
|
74
|
+
* The provider refused the account or its quota is spent: the claim is released, its attempt given back. Stop
|
|
75
|
+
* sending (every other mail fails the same way) and run again once the account can send.
|
|
76
|
+
*/
|
|
77
|
+
| { readonly status: "halted"; readonly reason: HaltingErrorCode; readonly httpStatus?: number };
|
|
53
78
|
|
|
54
79
|
/**
|
|
55
80
|
* Sends `delivery.mail` unless the ledger already has an outcome (or a fresh claim) for its scope
|
|
@@ -58,27 +83,55 @@ export type DeliveryOutcome =
|
|
|
58
83
|
* malformed scope or kind, and when `sendMail` throws.
|
|
59
84
|
*/
|
|
60
85
|
export async function deliverOnce(ctx: DeliveryContext, delivery: Delivery, options: DeliverOptions = {}): Promise<DeliveryOutcome> {
|
|
61
|
-
const
|
|
86
|
+
const windows = resolveClaimWindows(ctx, options);
|
|
87
|
+
const { maxAttempts = DEFAULT_MAX_ATTEMPTS } = options;
|
|
62
88
|
const kind = delivery.mail.kind ?? TRANSACTIONAL_KIND;
|
|
63
89
|
assertDelivery(delivery, kind);
|
|
64
90
|
|
|
65
91
|
const recipientKey = getRecipientKey(delivery.mail.to);
|
|
66
92
|
const key = { scope: delivery.scope, recipientKey };
|
|
67
|
-
const attempt = await claimDelivery(ctx, { ...key, kind, campaignId: delivery.campaignId ?? null,
|
|
68
|
-
if (attempt === null) return readClosedDelivery(ctx, key);
|
|
93
|
+
const attempt = await claimDelivery(ctx, { ...key, kind, campaignId: delivery.campaignId ?? null, ...windows });
|
|
94
|
+
if (attempt === null) return readClosedDelivery(ctx, key, windows.uncertainClaimMs);
|
|
69
95
|
|
|
70
96
|
const result = await sendMail(ctx, delivery.mail, { idempotencyKey: `${delivery.scope}:${recipientKey}` });
|
|
71
97
|
const fence = { ...key, attempt };
|
|
72
98
|
if (result.ok) {
|
|
73
|
-
await closeDelivery(ctx, fence, { status: "sent", providerMessageId: result.value.id, reason: null });
|
|
99
|
+
await closeDelivery(ctx, fence, { status: "sent", providerMessageId: result.value.id, reason: null, providerStatus: null });
|
|
74
100
|
return { status: "sent", id: result.value.id };
|
|
75
101
|
}
|
|
102
|
+
const providerStatus = result.httpStatus ?? null;
|
|
103
|
+
const httpStatus = result.httpStatus === undefined ? {} : { httpStatus: result.httpStatus };
|
|
104
|
+
if (isHaltingCode(result.error)) {
|
|
105
|
+
await releaseDelivery(ctx, fence, { providerStatus, giveAttemptBack: true });
|
|
106
|
+
return { status: "halted", reason: result.error, ...httpStatus };
|
|
107
|
+
}
|
|
76
108
|
if (result.error === "mailing.unavailable" && attempt < maxAttempts) {
|
|
77
|
-
await releaseDelivery(ctx, fence);
|
|
78
|
-
return { status: "retry-later" };
|
|
109
|
+
await releaseDelivery(ctx, fence, { providerStatus, giveAttemptBack: false });
|
|
110
|
+
return { status: "retry-later", ...httpStatus };
|
|
111
|
+
}
|
|
112
|
+
await closeDelivery(ctx, fence, { status: "rejected", providerMessageId: null, reason: result.error, providerStatus });
|
|
113
|
+
return { status: "rejected", reason: result.error, ...httpStatus };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function isHaltingCode(code: MailingErrorCode): code is HaltingErrorCode {
|
|
117
|
+
return HALTING_ERROR_CODES.has(code);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
interface ClaimWindows {
|
|
121
|
+
readonly staleClaimMs: number;
|
|
122
|
+
readonly uncertainClaimMs: number;
|
|
123
|
+
readonly retakeUncertain: boolean;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The claim windows: the call's options, else the module's, else the defaults. */
|
|
127
|
+
function resolveClaimWindows(ctx: DeliveryContext, options: DeliverOptions): ClaimWindows {
|
|
128
|
+
const configured = getMailingOptions(ctx.config);
|
|
129
|
+
const staleClaimMs = options.staleClaimMs ?? configured.staleClaimMs;
|
|
130
|
+
const uncertainClaimMs = options.uncertainClaimMs ?? configured.uncertainClaimMs;
|
|
131
|
+
if (uncertainClaimMs <= staleClaimMs) {
|
|
132
|
+
throw new Error(`@softure-ai/mailing: uncertainClaimMs (${String(uncertainClaimMs)}) must be more than staleClaimMs (${String(staleClaimMs)})`);
|
|
79
133
|
}
|
|
80
|
-
|
|
81
|
-
return { status: "rejected", reason: result.error };
|
|
134
|
+
return { staleClaimMs, uncertainClaimMs, retakeUncertain: options.retakeUncertain ?? false };
|
|
82
135
|
}
|
|
83
136
|
|
|
84
137
|
function assertDelivery(delivery: Delivery, kind: string): void {
|
|
@@ -100,31 +153,34 @@ interface DeliveryKey {
|
|
|
100
153
|
|
|
101
154
|
/**
|
|
102
155
|
* Takes the row in one statement: a new row, a `pending` one, or a claim older than
|
|
103
|
-
* `staleClaimMs
|
|
104
|
-
* is closed
|
|
156
|
+
* `staleClaimMs` (and, unless `retakeUncertain`, not older than `uncertainClaimMs`). Resolves with
|
|
157
|
+
* the attempt number that fences the outcome, or `null` when the row is closed, claimed by someone
|
|
158
|
+
* else, or uncertain.
|
|
105
159
|
*/
|
|
106
|
-
async function claimDelivery(
|
|
107
|
-
ctx: DeliveryContext,
|
|
108
|
-
claim: DeliveryKey & { readonly kind: string; readonly campaignId: string | null; readonly staleClaimMs: number },
|
|
109
|
-
): Promise<number | null> {
|
|
160
|
+
async function claimDelivery(ctx: DeliveryContext, claim: DeliveryKey & ClaimWindows & { readonly kind: string; readonly campaignId: string | null }): Promise<number | null> {
|
|
110
161
|
const now = ctx.clock.now();
|
|
111
162
|
const staleBefore = new Date(now.getTime() - claim.staleClaimMs);
|
|
163
|
+
const uncertainBefore = new Date(now.getTime() - claim.uncertainClaimMs);
|
|
164
|
+
const isStale: SQL | undefined = claim.retakeUncertain
|
|
165
|
+
? lt(deliveries.claimedAt, staleBefore)
|
|
166
|
+
: and(lt(deliveries.claimedAt, staleBefore), gte(deliveries.claimedAt, uncertainBefore));
|
|
112
167
|
const rows = await ctx.db
|
|
113
168
|
.insert(deliveries)
|
|
114
169
|
.values({ scope: claim.scope, recipientKey: claim.recipientKey, kind: claim.kind, campaignId: claim.campaignId, status: "claimed", attempts: 1, claimedAt: now, createdAt: now })
|
|
115
170
|
.onConflictDoUpdate({
|
|
116
171
|
target: [deliveries.scope, deliveries.recipientKey],
|
|
117
172
|
set: { status: "claimed", attempts: sql`${deliveries.attempts} + 1`, claimedAt: now },
|
|
118
|
-
setWhere: or(eq(deliveries.status, "pending"), and(eq(deliveries.status, "claimed"),
|
|
173
|
+
setWhere: or(eq(deliveries.status, "pending"), and(eq(deliveries.status, "claimed"), isStale)),
|
|
119
174
|
})
|
|
120
175
|
.returning();
|
|
121
176
|
return rows[0]?.attempts ?? null;
|
|
122
177
|
}
|
|
123
178
|
|
|
124
|
-
async function readClosedDelivery(ctx: DeliveryContext, key: DeliveryKey): Promise<DeliveryOutcome> {
|
|
125
|
-
const rows = await ctx.db.select({ status: deliveries.status }).from(deliveries).where(matchKey(key)).limit(1);
|
|
126
|
-
const
|
|
127
|
-
if (status === "sent" || status === "rejected") return { status: "done", outcome: status };
|
|
179
|
+
async function readClosedDelivery(ctx: DeliveryContext, key: DeliveryKey, uncertainClaimMs: number): Promise<DeliveryOutcome> {
|
|
180
|
+
const rows = await ctx.db.select({ status: deliveries.status, claimedAt: deliveries.claimedAt }).from(deliveries).where(matchKey(key)).limit(1);
|
|
181
|
+
const row = rows[0];
|
|
182
|
+
if (row?.status === "sent" || row?.status === "rejected") return { status: "done", outcome: row.status };
|
|
183
|
+
if (row?.status === "claimed" && row.claimedAt.getTime() < ctx.clock.now().getTime() - uncertainClaimMs) return { status: "uncertain" };
|
|
128
184
|
// A fresh claim; `pending` only between another sender's release and this read.
|
|
129
185
|
return { status: "in-flight" };
|
|
130
186
|
}
|
|
@@ -135,7 +191,7 @@ type Fence = DeliveryKey & { readonly attempt: number };
|
|
|
135
191
|
async function closeDelivery(
|
|
136
192
|
ctx: DeliveryContext,
|
|
137
193
|
fence: Fence,
|
|
138
|
-
outcome: { readonly status: "sent" | "rejected"; readonly providerMessageId: string | null; readonly reason: string | null },
|
|
194
|
+
outcome: { readonly status: "sent" | "rejected"; readonly providerMessageId: string | null; readonly reason: string | null; readonly providerStatus: number | null },
|
|
139
195
|
): Promise<void> {
|
|
140
196
|
await ctx.db
|
|
141
197
|
.update(deliveries)
|
|
@@ -143,10 +199,14 @@ async function closeDelivery(
|
|
|
143
199
|
.where(and(matchKey(fence), eq(deliveries.status, "claimed"), eq(deliveries.attempts, fence.attempt)));
|
|
144
200
|
}
|
|
145
201
|
|
|
146
|
-
|
|
202
|
+
/**
|
|
203
|
+
* Puts the claim back for a later run. A halt gives its attempt back: the account's failure says
|
|
204
|
+
* nothing about this mail, so it must not bring the delivery closer to `maxAttempts`.
|
|
205
|
+
*/
|
|
206
|
+
async function releaseDelivery(ctx: DeliveryContext, fence: Fence, release: { readonly providerStatus: number | null; readonly giveAttemptBack: boolean }): Promise<void> {
|
|
147
207
|
await ctx.db
|
|
148
208
|
.update(deliveries)
|
|
149
|
-
.set({ status: "pending" })
|
|
209
|
+
.set({ status: "pending", providerStatus: release.providerStatus, ...(release.giveAttemptBack ? { attempts: fence.attempt - 1 } : {}) })
|
|
150
210
|
.where(and(matchKey(fence), eq(deliveries.status, "claimed"), eq(deliveries.attempts, fence.attempt)));
|
|
151
211
|
}
|
|
152
212
|
|
package/src/server/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ export {
|
|
|
7
7
|
registerCampaign,
|
|
8
8
|
sendCampaign,
|
|
9
9
|
type CampaignErrorCode,
|
|
10
|
+
type CampaignHalt,
|
|
10
11
|
type CampaignPlan,
|
|
11
12
|
type CampaignSummary,
|
|
12
13
|
type SendCampaignOptions,
|
|
@@ -14,11 +15,13 @@ export {
|
|
|
14
15
|
export {
|
|
15
16
|
DEFAULT_MAX_ATTEMPTS,
|
|
16
17
|
DEFAULT_STALE_CLAIM_MS,
|
|
18
|
+
DEFAULT_UNCERTAIN_CLAIM_MS,
|
|
17
19
|
deliverOnce,
|
|
18
20
|
type DeliverOptions,
|
|
19
21
|
type Delivery,
|
|
20
22
|
type DeliveryContext,
|
|
21
23
|
type DeliveryOutcome,
|
|
24
|
+
type HaltingErrorCode,
|
|
22
25
|
} from "./deliveries.js";
|
|
23
26
|
export {
|
|
24
27
|
checkSenderDns,
|
|
@@ -39,6 +42,9 @@ export { isSuppressed, liftSuppression, suppressRecipient, unsubscribe, type Sup
|
|
|
39
42
|
export {
|
|
40
43
|
buildUnsubscribeLinks,
|
|
41
44
|
getRecipientKey,
|
|
45
|
+
getUnsubscribeLinkParams,
|
|
46
|
+
MAX_LEGACY_VALUE_LENGTH,
|
|
47
|
+
readUnsubscribeLink,
|
|
42
48
|
MIN_UNSUBSCRIBE_SECRET_LENGTH,
|
|
43
49
|
readUnsubscribeSecrets,
|
|
44
50
|
readUnsubscribeToken,
|
|
@@ -49,6 +55,8 @@ export {
|
|
|
49
55
|
UNSUBSCRIBE_SECRET_ENV,
|
|
50
56
|
verifyUnsubscribeToken,
|
|
51
57
|
type Env,
|
|
58
|
+
type LinkParams,
|
|
59
|
+
type UnsubscribeLink,
|
|
52
60
|
type UnsubscribeLinks,
|
|
53
61
|
type UnsubscribeSecrets,
|
|
54
62
|
type UnsubscribeToken,
|