@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.
Files changed (91) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +85 -23
  3. package/dist/cli/run.d.ts +1 -1
  4. package/dist/cli/run.d.ts.map +1 -1
  5. package/dist/cli/run.js +56 -22
  6. package/dist/cli/run.js.map +1 -1
  7. package/dist/contract.d.ts +48 -8
  8. package/dist/contract.d.ts.map +1 -1
  9. package/dist/contract.js +2 -0
  10. package/dist/contract.js.map +1 -1
  11. package/dist/index.d.ts +12 -3
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +3 -3
  14. package/dist/index.js.map +1 -1
  15. package/dist/messages/en.d.ts +2 -0
  16. package/dist/messages/en.d.ts.map +1 -1
  17. package/dist/messages/en.js +2 -0
  18. package/dist/messages/en.js.map +1 -1
  19. package/dist/messages/index.d.ts +4 -0
  20. package/dist/messages/index.d.ts.map +1 -1
  21. package/dist/messages/pl.d.ts.map +1 -1
  22. package/dist/messages/pl.js +2 -0
  23. package/dist/messages/pl.js.map +1 -1
  24. package/dist/next/actions.d.ts.map +1 -1
  25. package/dist/next/actions.js +6 -6
  26. package/dist/next/actions.js.map +1 -1
  27. package/dist/next/context.js +2 -2
  28. package/dist/next/context.js.map +1 -1
  29. package/dist/next/pages.d.ts.map +1 -1
  30. package/dist/next/pages.js +8 -6
  31. package/dist/next/pages.js.map +1 -1
  32. package/dist/next/route.d.ts +2 -1
  33. package/dist/next/route.d.ts.map +1 -1
  34. package/dist/next/route.js +6 -4
  35. package/dist/next/route.js.map +1 -1
  36. package/dist/options.d.ts +15 -1
  37. package/dist/options.d.ts.map +1 -1
  38. package/dist/options.js +32 -1
  39. package/dist/options.js.map +1 -1
  40. package/dist/providers/resend.d.ts.map +1 -1
  41. package/dist/providers/resend.js +12 -6
  42. package/dist/providers/resend.js.map +1 -1
  43. package/dist/schema.d.ts +17 -0
  44. package/dist/schema.d.ts.map +1 -1
  45. package/dist/schema.js +2 -0
  46. package/dist/schema.js.map +1 -1
  47. package/dist/server/campaigns.d.ts +30 -4
  48. package/dist/server/campaigns.d.ts.map +1 -1
  49. package/dist/server/campaigns.js +67 -12
  50. package/dist/server/campaigns.js.map +1 -1
  51. package/dist/server/deliveries.d.ts +30 -2
  52. package/dist/server/deliveries.d.ts.map +1 -1
  53. package/dist/server/deliveries.js +58 -23
  54. package/dist/server/deliveries.js.map +1 -1
  55. package/dist/server/index.d.ts +3 -3
  56. package/dist/server/index.d.ts.map +1 -1
  57. package/dist/server/index.js +2 -2
  58. package/dist/server/index.js.map +1 -1
  59. package/dist/server/send-mail.d.ts +3 -1
  60. package/dist/server/send-mail.d.ts.map +1 -1
  61. package/dist/server/send-mail.js +18 -6
  62. package/dist/server/send-mail.js.map +1 -1
  63. package/dist/server/suppressions.d.ts +8 -6
  64. package/dist/server/suppressions.d.ts.map +1 -1
  65. package/dist/server/suppressions.js +23 -7
  66. package/dist/server/suppressions.js.map +1 -1
  67. package/dist/server/unsubscribe-link.d.ts +23 -0
  68. package/dist/server/unsubscribe-link.d.ts.map +1 -1
  69. package/dist/server/unsubscribe-link.js +30 -1
  70. package/dist/server/unsubscribe-link.js.map +1 -1
  71. package/migrations/0003_add_delivery_provider_status.sql +13 -0
  72. package/module.json +1 -1
  73. package/package.json +6 -2
  74. package/src/cli/run.ts +72 -27
  75. package/src/contract.ts +49 -6
  76. package/src/index.ts +8 -3
  77. package/src/messages/en.ts +2 -0
  78. package/src/messages/pl.ts +2 -0
  79. package/src/next/actions.ts +5 -6
  80. package/src/next/context.ts +2 -2
  81. package/src/next/pages.tsx +10 -7
  82. package/src/next/route.ts +6 -4
  83. package/src/options.ts +36 -2
  84. package/src/providers/resend.ts +11 -7
  85. package/src/schema.ts +2 -0
  86. package/src/server/campaigns.ts +95 -17
  87. package/src/server/deliveries.ts +89 -29
  88. package/src/server/index.ts +8 -0
  89. package/src/server/send-mail.ts +30 -6
  90. package/src/server/suppressions.ts +23 -8
  91. 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, Result } from "@softure-ai/core";
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
- export type SendMailResult = Result<SentMail, MailingErrorCode>;
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
- /** What a provider answers. `httpStatus` is logged; the mail never is. */
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: "rejected"; readonly httpStatus?: number }
98
- | { readonly status: "unavailable"; readonly httpStatus?: number };
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.5",
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";
@@ -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: {
@@ -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: {
@@ -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 { RECIPIENT_PARAM, readUnsubscribeToken, SIGNATURE_PARAM } from "../server/unsubscribe-link.js";
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 token = readUnsubscribeToken(formData);
18
+ const link = readUnsubscribeLink(formData, config);
19
19
  let status: UnsubscribeStatus;
20
20
  try {
21
- const result = await unsubscribe(await getMailingContext(config), token, "page");
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" && token !== null) {
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(RECIPIENT_PARAM, token.recipientKey);
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
  }
@@ -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 { getSharedDatabase } from "@softure-ai/db";
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 getSharedDatabase(config.database.url);
13
+ const { db } = await getConfiguredDatabase(config.database);
14
14
  return { db, clock: systemClock, config };
15
15
  }
@@ -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 { RECIPIENT_PARAM, SIGNATURE_PARAM } from "../server/unsubscribe-link.js";
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 recipientKey = readParam(params, RECIPIENT_PARAM) ?? "";
51
- const signature = readParam(params, SIGNATURE_PARAM) ?? "";
52
- if (status === "invalid" || recipientKey === "" || signature === "") {
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
- <input type="hidden" name={RECIPIENT_PARAM} value={recipientKey} />
59
- <input type="hidden" name={SIGNATURE_PARAM} value={signature} />
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 { readUnsubscribeToken } from "../server/unsubscribe-link.js";
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 result = await unsubscribe(await getMailingContext(), token, "one-click");
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>;
@@ -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
- /** Statuses below 500 that mean "try again later", not "this mail is refused". */
25
- const RETRYABLE_CLIENT_STATUSES: ReadonlySet<number> = new Set([408, 429]);
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 isRetryable =
76
- response.status >= 500 ||
77
- RETRYABLE_CLIENT_STATUSES.has(response.status) ||
78
- (response.status === 409 && readString(payload, "name") === CONCURRENT_IDEMPOTENT_REQUESTS);
79
- return { status: isRetryable ? "unavailable" : "rejected", httpStatus: response.status };
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] })],
@@ -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. Throws on a database failure.
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
- /** What `sendCampaign` would do, without writing or sending anything. Throws on a database failure. */
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
- rows.forEach((row) => closed.add(row.recipientKey));
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 (!closed.has(key) && (await isSuppressed(ctx, address))) suppressed += 1;
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, toSend: addresses.size - closed.size - suppressed, contentChanged };
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