@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/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,
|
package/src/server/send-mail.ts
CHANGED
|
@@ -5,7 +5,17 @@
|
|
|
5
5
|
// link in a footer and the RFC 8058 headers, and is refused for a recipient who unsubscribed.
|
|
6
6
|
import { err, ok, type SoftureConfig } from "@softure-ai/core";
|
|
7
7
|
import type { Queryable } from "@softure-ai/db";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
TRANSACTIONAL_KIND,
|
|
10
|
+
type MailingErrorCode,
|
|
11
|
+
type MailProvider,
|
|
12
|
+
type OutgoingMail,
|
|
13
|
+
type ProviderFailureStatus,
|
|
14
|
+
type ProviderMessage,
|
|
15
|
+
type ProviderOutcome,
|
|
16
|
+
type SendMailOptions,
|
|
17
|
+
type SendMailResult,
|
|
18
|
+
} from "../contract.js";
|
|
9
19
|
import type { MailingMessages } from "../messages/index.js";
|
|
10
20
|
import { addHtmlFooter, addTextFooter, getListUnsubscribeHeaders } from "./list-mail.js";
|
|
11
21
|
import { getMailingModule, getMailingOptions } from "./options.js";
|
|
@@ -27,7 +37,9 @@ type MailContent = Omit<ProviderMessage, "from" | "replyTo" | "idempotencyKey">;
|
|
|
27
37
|
/**
|
|
28
38
|
* Sends `mail` and resolves with the provider's message id or a `mailing.*` code:
|
|
29
39
|
* `invalid_input` (fix the input), `rejected` (do not retry), `unavailable` (retry later with the
|
|
30
|
-
* same `idempotencyKey`), `suppressed` (a list mail to someone who unsubscribed; do not retry)
|
|
40
|
+
* same `idempotencyKey`), `suppressed` (a list mail to someone who unsubscribed; do not retry),
|
|
41
|
+
* `provider_refused` / `quota_exceeded` (the account cannot send: stop, fix or wait, then retry). A
|
|
42
|
+
* failure the provider answered with an HTTP status carries it as `httpStatus`.
|
|
31
43
|
* Throws only when the module is not enabled, or for list mail without `context.db`.
|
|
32
44
|
*/
|
|
33
45
|
export async function sendMail(context: MailContext, mail: OutgoingMail, options: SendMailOptions = {}): Promise<SendMailResult> {
|
|
@@ -54,11 +66,18 @@ export async function sendMail(context: MailContext, mail: OutgoingMail, options
|
|
|
54
66
|
if (outcome.status === "sent") {
|
|
55
67
|
return ok({ id: outcome.id, provider: provider.name });
|
|
56
68
|
}
|
|
57
|
-
const code
|
|
69
|
+
const code = FAILURE_CODES[outcome.status];
|
|
58
70
|
logFailure(provider, code, { status: outcome.httpStatus });
|
|
59
|
-
return err(code);
|
|
71
|
+
return outcome.httpStatus === undefined ? err(code) : { ...err(code), httpStatus: outcome.httpStatus };
|
|
60
72
|
}
|
|
61
73
|
|
|
74
|
+
const FAILURE_CODES: Readonly<Record<ProviderFailureStatus, MailingErrorCode>> = {
|
|
75
|
+
rejected: "mailing.rejected",
|
|
76
|
+
unavailable: "mailing.unavailable",
|
|
77
|
+
refused: "mailing.provider_refused",
|
|
78
|
+
quota_exceeded: "mailing.quota_exceeded",
|
|
79
|
+
};
|
|
80
|
+
|
|
62
81
|
type ListMailResult =
|
|
63
82
|
| { readonly ok: true; readonly value: MailContent }
|
|
64
83
|
| { readonly ok: false; readonly error: "mailing.suppressed" | "mailing.unavailable"; readonly cause: string };
|
|
@@ -130,12 +149,17 @@ function readOutcome(outcome: unknown): ProviderOutcome {
|
|
|
130
149
|
if (status === "sent") {
|
|
131
150
|
return typeof id === "string" && id !== "" ? { status, id } : { status: "unavailable" };
|
|
132
151
|
}
|
|
133
|
-
if (status === "
|
|
134
|
-
|
|
152
|
+
if (typeof status === "string" && Object.hasOwn(FAILURE_CODES, status)) {
|
|
153
|
+
const failure = status as ProviderFailureStatus;
|
|
154
|
+
return isHttpStatus(httpStatus) ? { status: failure, httpStatus } : { status: failure };
|
|
135
155
|
}
|
|
136
156
|
return { status: "unavailable" };
|
|
137
157
|
}
|
|
138
158
|
|
|
159
|
+
function isHttpStatus(value: unknown): value is number {
|
|
160
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 100 && value <= 599;
|
|
161
|
+
}
|
|
162
|
+
|
|
139
163
|
/**
|
|
140
164
|
* The module's only log line. Deliberately takes nothing but the provider, the code, a status,
|
|
141
165
|
* field names and a fixed cause: anything added "for diagnosis" is where an address leaks first.
|
|
@@ -9,7 +9,7 @@ import { and, eq, inArray } from "drizzle-orm";
|
|
|
9
9
|
import type { SuppressionSource, UnsubscribeErrorCode } from "../contract.js";
|
|
10
10
|
import { suppressions } from "../schema.js";
|
|
11
11
|
import { getMailingOptions } from "./options.js";
|
|
12
|
-
import { getRecipientKey, readUnsubscribeSecrets, verifyUnsubscribeToken, type Env, type UnsubscribeToken } from "./unsubscribe-link.js";
|
|
12
|
+
import { getRecipientKey, readUnsubscribeSecrets, verifyUnsubscribeToken, type Env, type UnsubscribeLink, type UnsubscribeToken } from "./unsubscribe-link.js";
|
|
13
13
|
|
|
14
14
|
export type SuppressionContext = ModuleContext<Queryable>;
|
|
15
15
|
|
|
@@ -54,20 +54,22 @@ export async function liftSuppression(ctx: Pick<SuppressionContext, "db">, addre
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
/**
|
|
57
|
-
* Unsubscribes the recipient of a
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
57
|
+
* Unsubscribes the recipient of a link and runs `onUnsubscribed` in the same transaction, also when
|
|
58
|
+
* the recipient had already unsubscribed (a retried one-click heals a missed hook). A signed link
|
|
59
|
+
* (or a bare token) is checked against the secrets before the database is touched; a legacy link
|
|
60
|
+
* goes to the app's `legacyUnsubscribe.verify`, which names the address. A link that does not
|
|
61
|
+
* verify is `mailing.invalid_link`. Throws on a database failure, or when `verify` or the hook
|
|
62
|
+
* throws (nothing is stored).
|
|
61
63
|
*/
|
|
62
64
|
export async function unsubscribe(
|
|
63
65
|
ctx: SuppressionContext,
|
|
64
|
-
|
|
66
|
+
link: UnsubscribeLink | UnsubscribeToken | null,
|
|
65
67
|
source: Exclude<SuppressionSource, "operator">,
|
|
66
68
|
env: Env = process.env,
|
|
67
69
|
): Promise<Result<undefined, UnsubscribeErrorCode>> {
|
|
68
|
-
|
|
70
|
+
const recipientKey = await verifyLink(ctx, link, env);
|
|
71
|
+
if (recipientKey === null) return err("mailing.invalid_link");
|
|
69
72
|
const { onUnsubscribed } = getMailingOptions(ctx.config);
|
|
70
|
-
const { recipientKey } = token;
|
|
71
73
|
await ctx.db.transaction(async (tx) => {
|
|
72
74
|
const txCtx: SuppressionContext = { ...ctx, db: tx };
|
|
73
75
|
await recordSuppression(txCtx, recipientKey, source);
|
|
@@ -75,3 +77,16 @@ export async function unsubscribe(
|
|
|
75
77
|
});
|
|
76
78
|
return ok();
|
|
77
79
|
}
|
|
80
|
+
|
|
81
|
+
/** The recipient key a link proves, or `null` when it proves none. */
|
|
82
|
+
async function verifyLink(ctx: SuppressionContext, link: UnsubscribeLink | UnsubscribeToken | null, env: Env): Promise<string | null> {
|
|
83
|
+
if (link === null) return null;
|
|
84
|
+
if (!("scheme" in link) || link.scheme === "signed") {
|
|
85
|
+
const token = "scheme" in link ? link.token : link;
|
|
86
|
+
return verifyUnsubscribeToken(token, readUnsubscribeSecrets(env)) ? token.recipientKey : null;
|
|
87
|
+
}
|
|
88
|
+
const legacy = getMailingOptions(ctx.config).legacyUnsubscribe;
|
|
89
|
+
if (legacy === undefined) return null;
|
|
90
|
+
const address: unknown = await legacy.verify(link.values, ctx);
|
|
91
|
+
return typeof address === "string" && address.trim() !== "" ? getRecipientKey(address) : null;
|
|
92
|
+
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// so it never reaches a log, an error or a result.
|
|
6
6
|
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
|
7
7
|
import type { SoftureConfig } from "@softure-ai/core";
|
|
8
|
-
import { getMailingRoutes } from "./options.js";
|
|
8
|
+
import { getMailingOptions, getMailingRoutes } from "./options.js";
|
|
9
9
|
|
|
10
10
|
/** Signs every new link and verifies. */
|
|
11
11
|
export const UNSUBSCRIBE_SECRET_ENV = "MAILING_UNSUBSCRIBE_SECRET";
|
|
@@ -101,6 +101,46 @@ export function buildUnsubscribeLinks(config: SoftureConfig, address: string, se
|
|
|
101
101
|
};
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
/** A legacy link's value longer than this makes the link invalid before the app's `verify` sees it. */
|
|
105
|
+
export const MAX_LEGACY_VALUE_LENGTH = 512;
|
|
106
|
+
|
|
107
|
+
/** The link a person followed: the module's signed one, or one the app sent before it adopted the module. */
|
|
108
|
+
export type UnsubscribeLink =
|
|
109
|
+
| { readonly scheme: "signed"; readonly token: UnsubscribeToken }
|
|
110
|
+
| { readonly scheme: "legacy"; readonly values: Readonly<Record<string, string>> };
|
|
111
|
+
|
|
112
|
+
/** Reads one query or form value; `URLSearchParams` and `FormData` both fit. */
|
|
113
|
+
export interface LinkParams {
|
|
114
|
+
get(name: string): unknown;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The link in a query or a form. A link with the recipient parameter (`r`) is a signed link, whatever else it
|
|
119
|
+
* carries, so a crafted URL cannot route a signed link to the app's legacy check. Without it, and with
|
|
120
|
+
* `mailing({ legacyUnsubscribe })`, a link that has every legacy parameter (non-empty, at most 512 characters) is
|
|
121
|
+
* a legacy link. Anything else is `null`. Checks no signature: `unsubscribe` does.
|
|
122
|
+
*/
|
|
123
|
+
export function readUnsubscribeLink(params: LinkParams, config: SoftureConfig): UnsubscribeLink | null {
|
|
124
|
+
if (params.get(RECIPIENT_PARAM) !== null && params.get(RECIPIENT_PARAM) !== undefined) {
|
|
125
|
+
const token = readUnsubscribeToken(params);
|
|
126
|
+
return token === null ? null : { scheme: "signed", token };
|
|
127
|
+
}
|
|
128
|
+
const legacy = getMailingOptions(config).legacyUnsubscribe;
|
|
129
|
+
if (legacy === undefined) return null;
|
|
130
|
+
const values: Record<string, string> = {};
|
|
131
|
+
for (const name of legacy.params) {
|
|
132
|
+
const value = params.get(name);
|
|
133
|
+
if (typeof value !== "string" || value === "" || value.length > MAX_LEGACY_VALUE_LENGTH) return null;
|
|
134
|
+
values[name] = value;
|
|
135
|
+
}
|
|
136
|
+
return { scheme: "legacy", values };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The query (or form) parameters that carry `link`, to send it back to the page. */
|
|
140
|
+
export function getUnsubscribeLinkParams(link: UnsubscribeLink): Record<string, string> {
|
|
141
|
+
return link.scheme === "signed" ? { [RECIPIENT_PARAM]: link.token.recipientKey, [SIGNATURE_PARAM]: link.token.signature } : { ...link.values };
|
|
142
|
+
}
|
|
143
|
+
|
|
104
144
|
/** The token in a link's query (or a form), when both parameters are present once. */
|
|
105
145
|
export function readUnsubscribeToken(params: { get(name: string): unknown }): UnsubscribeToken | null {
|
|
106
146
|
const recipientKey = params.get(RECIPIENT_PARAM);
|