@softure-ai/mailing 0.1.6 → 0.1.8

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 (93) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +108 -25
  3. package/dist/cli/run.d.ts +3 -1
  4. package/dist/cli/run.d.ts.map +1 -1
  5. package/dist/cli/run.js +141 -25
  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/pages.d.ts.map +1 -1
  28. package/dist/next/pages.js +8 -6
  29. package/dist/next/pages.js.map +1 -1
  30. package/dist/next/route.d.ts +2 -1
  31. package/dist/next/route.d.ts.map +1 -1
  32. package/dist/next/route.js +6 -4
  33. package/dist/next/route.js.map +1 -1
  34. package/dist/options.d.ts +15 -1
  35. package/dist/options.d.ts.map +1 -1
  36. package/dist/options.js +32 -1
  37. package/dist/options.js.map +1 -1
  38. package/dist/providers/resend.d.ts.map +1 -1
  39. package/dist/providers/resend.js +12 -6
  40. package/dist/providers/resend.js.map +1 -1
  41. package/dist/schema.d.ts +17 -0
  42. package/dist/schema.d.ts.map +1 -1
  43. package/dist/schema.js +2 -0
  44. package/dist/schema.js.map +1 -1
  45. package/dist/server/campaigns.d.ts +30 -4
  46. package/dist/server/campaigns.d.ts.map +1 -1
  47. package/dist/server/campaigns.js +67 -12
  48. package/dist/server/campaigns.js.map +1 -1
  49. package/dist/server/deliveries.d.ts +30 -2
  50. package/dist/server/deliveries.d.ts.map +1 -1
  51. package/dist/server/deliveries.js +58 -23
  52. package/dist/server/deliveries.js.map +1 -1
  53. package/dist/server/dns.d.ts +48 -1
  54. package/dist/server/dns.d.ts.map +1 -1
  55. package/dist/server/dns.js +124 -15
  56. package/dist/server/dns.js.map +1 -1
  57. package/dist/server/index.d.ts +4 -4
  58. package/dist/server/index.d.ts.map +1 -1
  59. package/dist/server/index.js +3 -3
  60. package/dist/server/index.js.map +1 -1
  61. package/dist/server/send-mail.d.ts +3 -1
  62. package/dist/server/send-mail.d.ts.map +1 -1
  63. package/dist/server/send-mail.js +18 -6
  64. package/dist/server/send-mail.js.map +1 -1
  65. package/dist/server/suppressions.d.ts +8 -6
  66. package/dist/server/suppressions.d.ts.map +1 -1
  67. package/dist/server/suppressions.js +23 -7
  68. package/dist/server/suppressions.js.map +1 -1
  69. package/dist/server/unsubscribe-link.d.ts +23 -0
  70. package/dist/server/unsubscribe-link.d.ts.map +1 -1
  71. package/dist/server/unsubscribe-link.js +30 -1
  72. package/dist/server/unsubscribe-link.js.map +1 -1
  73. package/migrations/0003_add_delivery_provider_status.sql +13 -0
  74. package/module.json +1 -1
  75. package/package.json +1 -1
  76. package/src/cli/run.ts +164 -29
  77. package/src/contract.ts +49 -6
  78. package/src/index.ts +8 -3
  79. package/src/messages/en.ts +2 -0
  80. package/src/messages/pl.ts +2 -0
  81. package/src/next/actions.ts +5 -6
  82. package/src/next/pages.tsx +10 -7
  83. package/src/next/route.ts +6 -4
  84. package/src/options.ts +36 -2
  85. package/src/providers/resend.ts +11 -7
  86. package/src/schema.ts +2 -0
  87. package/src/server/campaigns.ts +95 -17
  88. package/src/server/deliveries.ts +89 -29
  89. package/src/server/dns.ts +174 -16
  90. package/src/server/index.ts +17 -0
  91. package/src/server/send-mail.ts +30 -6
  92. package/src/server/suppressions.ts +23 -8
  93. package/src/server/unsubscribe-link.ts +41 -1
@@ -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,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
 
@@ -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
- /** How long a claim may stay open before another sender may take it over. */
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 { staleClaimMs = DEFAULT_STALE_CLAIM_MS, maxAttempts = DEFAULT_MAX_ATTEMPTS } = options;
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, staleClaimMs });
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
- await closeDelivery(ctx, fence, { status: "rejected", providerMessageId: null, reason: result.error });
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`. Resolves with the attempt number that fences the outcome, or `null` when the row
104
- * is closed or claimed by someone else.
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"), lt(deliveries.claimedAt, staleBefore))),
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 status = rows[0]?.status;
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
- async function releaseDelivery(ctx: DeliveryContext, fence: Fence): Promise<void> {
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