@softure-ai/mailing 0.1.11 → 0.1.13

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 (92) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +182 -9
  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 +179 -25
  6. package/dist/cli/run.js.map +1 -1
  7. package/dist/contract.d.ts +5 -3
  8. package/dist/contract.d.ts.map +1 -1
  9. package/dist/index.d.ts +3 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +1 -1
  12. package/dist/next/index.d.ts +3 -1
  13. package/dist/next/index.d.ts.map +1 -1
  14. package/dist/next/index.js +3 -1
  15. package/dist/next/index.js.map +1 -1
  16. package/dist/next/pages.d.ts +25 -0
  17. package/dist/next/pages.d.ts.map +1 -1
  18. package/dist/next/pages.js +33 -10
  19. package/dist/next/pages.js.map +1 -1
  20. package/dist/next/preview-mail.d.ts +5 -0
  21. package/dist/next/preview-mail.d.ts.map +1 -0
  22. package/dist/next/preview-mail.js +8 -0
  23. package/dist/next/preview-mail.js.map +1 -0
  24. package/dist/next/run-deliveries.d.ts +4 -0
  25. package/dist/next/run-deliveries.d.ts.map +1 -0
  26. package/dist/next/run-deliveries.js +7 -0
  27. package/dist/next/run-deliveries.js.map +1 -0
  28. package/dist/options.d.ts +3 -1
  29. package/dist/options.d.ts.map +1 -1
  30. package/dist/options.js +16 -1
  31. package/dist/options.js.map +1 -1
  32. package/dist/server/campaign-file.d.ts +7 -2
  33. package/dist/server/campaign-file.d.ts.map +1 -1
  34. package/dist/server/campaign-file.js +35 -15
  35. package/dist/server/campaign-file.js.map +1 -1
  36. package/dist/server/campaigns.d.ts +13 -8
  37. package/dist/server/campaigns.d.ts.map +1 -1
  38. package/dist/server/campaigns.js +15 -14
  39. package/dist/server/campaigns.js.map +1 -1
  40. package/dist/server/deliveries.d.ts +32 -0
  41. package/dist/server/deliveries.d.ts.map +1 -1
  42. package/dist/server/deliveries.js +36 -0
  43. package/dist/server/deliveries.js.map +1 -1
  44. package/dist/server/dns.d.ts +15 -1
  45. package/dist/server/dns.d.ts.map +1 -1
  46. package/dist/server/dns.js +45 -4
  47. package/dist/server/dns.js.map +1 -1
  48. package/dist/server/index.d.ts +7 -5
  49. package/dist/server/index.d.ts.map +1 -1
  50. package/dist/server/index.js +6 -4
  51. package/dist/server/index.js.map +1 -1
  52. package/dist/server/operator-output.d.ts +5 -0
  53. package/dist/server/operator-output.d.ts.map +1 -0
  54. package/dist/server/operator-output.js +19 -0
  55. package/dist/server/operator-output.js.map +1 -0
  56. package/dist/server/options.d.ts +2 -0
  57. package/dist/server/options.d.ts.map +1 -1
  58. package/dist/server/options.js +5 -0
  59. package/dist/server/options.js.map +1 -1
  60. package/dist/server/run-deliveries.d.ts +62 -0
  61. package/dist/server/run-deliveries.d.ts.map +1 -0
  62. package/dist/server/run-deliveries.js +133 -0
  63. package/dist/server/run-deliveries.js.map +1 -0
  64. package/dist/server/send-mail.d.ts +21 -2
  65. package/dist/server/send-mail.d.ts.map +1 -1
  66. package/dist/server/send-mail.js +50 -30
  67. package/dist/server/send-mail.js.map +1 -1
  68. package/dist/server/suppressions.d.ts +5 -0
  69. package/dist/server/suppressions.d.ts.map +1 -1
  70. package/dist/server/suppressions.js +27 -1
  71. package/dist/server/suppressions.js.map +1 -1
  72. package/migrations/0005_add_sql_surface.sql +44 -0
  73. package/module.json +1 -1
  74. package/package.json +1 -1
  75. package/src/cli/run.ts +184 -27
  76. package/src/contract.ts +9 -3
  77. package/src/index.ts +1 -1
  78. package/src/next/index.ts +3 -1
  79. package/src/next/pages.tsx +69 -14
  80. package/src/next/preview-mail.ts +9 -0
  81. package/src/next/run-deliveries.ts +10 -0
  82. package/src/options.ts +18 -1
  83. package/src/server/campaign-file.ts +36 -2
  84. package/src/server/campaigns.ts +25 -21
  85. package/src/server/deliveries.ts +53 -0
  86. package/src/server/dns.ts +60 -4
  87. package/src/server/index.ts +9 -3
  88. package/src/server/operator-output.ts +20 -0
  89. package/src/server/options.ts +6 -0
  90. package/src/server/run-deliveries.ts +193 -0
  91. package/src/server/send-mail.ts +63 -37
  92. package/src/server/suppressions.ts +28 -1
@@ -0,0 +1,193 @@
1
+ // A run of lifecycle mail: one delivery per recipient through the ledger (`deliverOnce`), e.g. every subscription
2
+ // whose trial ends this week. The app lists the recipients and builds each mail; the run keeps the ledger's
3
+ // at-most-once rule, stops at the first failure about the sending account (refused key, spent quota), at the first
4
+ // mail the provider could not take now (unless told otherwise), and after `limit` mails reached the provider, and
5
+ // counts what it did. A dry run reads the same state and writes nothing.
6
+ import { TRANSACTIONAL_KIND, type MailingErrorCode } from "../contract.js";
7
+ import { deliverOnce, forecastDelivery, getRunHalt, type DeliverOptions, type Delivery, type DeliveryContext, type DeliveryForecast, type DeliveryOutcome, type RunHalt } from "./deliveries.js";
8
+ import { getRecipientKey, MIN_UNSUBSCRIBE_SECRET_LENGTH, readUnsubscribeSecrets, UNSUBSCRIBE_SECRET_ENV } from "./unsubscribe-link.js";
9
+
10
+ /** Rejections the provider answered (the others never left the process). */
11
+ const PROVIDER_REASONS: ReadonlySet<MailingErrorCode> = new Set(["mailing.rejected", "mailing.unavailable"]);
12
+
13
+ export interface DeliveryRunInput<Recipient> {
14
+ readonly recipients: Iterable<Recipient> | AsyncIterable<Recipient>;
15
+ /** The delivery for one recipient (its scope and mail), or `null` to skip them. A throw stops the run. */
16
+ readonly build: (recipient: Recipient) => Delivery | null | Promise<Delivery | null>;
17
+ }
18
+
19
+ export interface RunDeliveriesOptions extends DeliverOptions {
20
+ /** Pause after every mail that reached the provider, for its rate limit. Default 0. */
21
+ readonly pauseMs?: number;
22
+ /**
23
+ * Most mails this run hands to the provider (sent, rejected by it, or not taken now). Once reached, the run reads
24
+ * the rest only to count `remaining`. A whole number of at least 1. Default: no limit.
25
+ */
26
+ readonly limit?: number;
27
+ /** Reads the ledger and the suppression list and sends and writes nothing; `sent` then counts what would go out. */
28
+ readonly dryRun?: boolean;
29
+ /** Called after every delivery of a real run, e.g. for progress output. Never receives the address. */
30
+ readonly onDelivery?: (outcome: DeliveryOutcome) => void;
31
+ readonly sleep?: (ms: number) => Promise<void>;
32
+ /**
33
+ * End the run at the first `retry-later` (the provider is down, timed out or rate limited), reported in `halted` as
34
+ * `mailing.unavailable`. Going on would spend an attempt of every following recipient on the same answer. Default
35
+ * `true`.
36
+ */
37
+ readonly stopOnRetryLater?: boolean;
38
+ }
39
+
40
+ export interface DeliveryRunSummary {
41
+ readonly dryRun: boolean;
42
+ /** Recipients read (past the limit too, but not past a halt). */
43
+ readonly recipients: number;
44
+ /** `build` returned `null`; nothing stored. */
45
+ readonly skipped: number;
46
+ /** Sent in this run; in a dry run, the mails that would reach the provider. */
47
+ readonly sent: number;
48
+ /** Rejected in this run, by reason; suppressed list mail is under `mailing.suppressed`. */
49
+ readonly rejected: Readonly<Record<MailingErrorCode, number>>;
50
+ /** Closed by an earlier run (or, in a dry run, by a delivery earlier in this one); nothing sent. */
51
+ readonly done: number;
52
+ /** Claimed by another run that is still sending. */
53
+ readonly inFlight: number;
54
+ /** The provider was unavailable, or the run halted on it; the next run tries again. */
55
+ readonly retryLater: number;
56
+ /** Claimed more than `uncertainClaimMs` ago and never closed: their mail may have gone out. */
57
+ readonly uncertain: number;
58
+ /**
59
+ * Set when the run stopped early: at a failure about the sending account (run again once the account can send) or,
60
+ * with `stopOnRetryLater`, at the first mail the provider could not take now (`mailing.unavailable`; run again later).
61
+ */
62
+ readonly halted: RunHalt | null;
63
+ /** Deliveries past the `limit` cut that the next run would send; 0 without a cut, `null` after a halt. */
64
+ readonly remaining: number | null;
65
+ }
66
+
67
+ type Counts = { -readonly [Key in Exclude<keyof DeliveryRunSummary, "dryRun" | "halted" | "remaining" | "rejected">]: number } & { rejected: Record<MailingErrorCode, number> };
68
+
69
+ /**
70
+ * Builds and delivers one mail per recipient, in order, each at most once per scope and recipient. Stops at the first
71
+ * `halted` delivery and, unless `stopOnRetryLater` is `false`, at the first `retry-later`. Throws on a
72
+ * database failure, when `build` throws, like `deliverOnce` for a malformed delivery, at the first list mail when
73
+ * `MAILING_UNSUBSCRIBE_SECRET` is not set (each send would fail and use up an attempt), and (a `RangeError`) for a
74
+ * `limit` that is not a whole number of at least 1.
75
+ */
76
+ export async function runDeliveries<Recipient>(ctx: DeliveryContext, input: DeliveryRunInput<Recipient>, options: RunDeliveriesOptions = {}): Promise<DeliveryRunSummary> {
77
+ const { pauseMs = 0, limit, dryRun = false, onDelivery, sleep = wait, stopOnRetryLater = true, ...deliverOptions } = options;
78
+ if (limit !== undefined && !(Number.isInteger(limit) && limit >= 1)) {
79
+ throw new RangeError(`@softure-ai/mailing: runDeliveries limit must be a whole number of at least 1, got ${String(limit)}`);
80
+ }
81
+ const counts: Counts = { recipients: 0, skipped: 0, sent: 0, done: 0, inFlight: 0, retryLater: 0, uncertain: 0, rejected: emptyRejections() };
82
+ // Deliveries this run already sent or would send, so a dry run (and the count past the limit) sees its own effect.
83
+ const reached = new Set<string>();
84
+ let handedToProvider = 0;
85
+ let remaining = 0;
86
+ for await (const recipient of input.recipients) {
87
+ counts.recipients += 1;
88
+ const delivery = await input.build(recipient);
89
+ if (delivery === null) {
90
+ counts.skipped += 1;
91
+ continue;
92
+ }
93
+ assertListMailCanBeSigned(delivery);
94
+ const isPastLimit = limit !== undefined && handedToProvider >= limit;
95
+ if (dryRun || isPastLimit) {
96
+ const forecast = await forecastOnce(ctx, delivery, { options: deliverOptions, reached });
97
+ if (isPastLimit) {
98
+ if (forecast.status === "send") remaining += 1;
99
+ continue;
100
+ }
101
+ countForecast(counts, forecast);
102
+ if (forecast.status === "send") handedToProvider += 1;
103
+ continue;
104
+ }
105
+
106
+ const outcome = await deliverOnce(ctx, delivery, deliverOptions);
107
+ countOutcome(counts, outcome);
108
+ onDelivery?.(outcome);
109
+ const halt = getRunHalt(outcome, stopOnRetryLater);
110
+ if (halt !== null) return { dryRun, ...counts, halted: halt, remaining: null };
111
+ const reachedProvider = outcome.status === "sent" || outcome.status === "retry-later" || (outcome.status === "rejected" && PROVIDER_REASONS.has(outcome.reason));
112
+ if (outcome.status === "sent") reached.add(getDeliveryKey(delivery));
113
+ if (reachedProvider) handedToProvider += 1;
114
+ if (reachedProvider && pauseMs > 0) await sleep(pauseMs);
115
+ }
116
+ return { dryRun, ...counts, halted: null, remaining };
117
+ }
118
+
119
+ function getDeliveryKey(delivery: Delivery): string {
120
+ return `${delivery.scope}\n${getRecipientKey(delivery.mail.to)}`;
121
+ }
122
+
123
+ /** List mail without the signing secret is a configuration bug: stop before any claim is taken. */
124
+ function assertListMailCanBeSigned(delivery: Delivery): void {
125
+ if ((delivery.mail.kind ?? TRANSACTIONAL_KIND) === TRANSACTIONAL_KIND || readUnsubscribeSecrets().current !== null) return;
126
+ throw new Error(`@softure-ai/mailing: runDeliveries sends list mail, which needs ${UNSUBSCRIBE_SECRET_ENV} (at least ${String(MIN_UNSUBSCRIBE_SECRET_LENGTH)} characters)`);
127
+ }
128
+
129
+ /** The forecast, with a delivery this run already sent (or would send) counted as done. */
130
+ async function forecastOnce(
131
+ ctx: DeliveryContext,
132
+ delivery: Delivery,
133
+ { options, reached }: { readonly options: DeliverOptions; readonly reached: Set<string> },
134
+ ): Promise<DeliveryForecast> {
135
+ const key = getDeliveryKey(delivery);
136
+ const forecast = await forecastDelivery(ctx, delivery, options);
137
+ if (forecast.status !== "send") return forecast;
138
+ if (reached.has(key)) return { status: "done" };
139
+ reached.add(key);
140
+ return forecast;
141
+ }
142
+
143
+ function countForecast(counts: Counts, forecast: DeliveryForecast): void {
144
+ switch (forecast.status) {
145
+ case "send":
146
+ counts.sent += 1;
147
+ return;
148
+ case "rejected":
149
+ counts.rejected[forecast.reason] += 1;
150
+ return;
151
+ case "done":
152
+ counts.done += 1;
153
+ return;
154
+ case "in-flight":
155
+ counts.inFlight += 1;
156
+ return;
157
+ case "uncertain":
158
+ counts.uncertain += 1;
159
+ return;
160
+ }
161
+ }
162
+
163
+ function countOutcome(counts: Counts, outcome: DeliveryOutcome): void {
164
+ switch (outcome.status) {
165
+ case "sent":
166
+ counts.sent += 1;
167
+ return;
168
+ case "rejected":
169
+ counts.rejected[outcome.reason] += 1;
170
+ return;
171
+ case "done":
172
+ counts.done += 1;
173
+ return;
174
+ case "in-flight":
175
+ counts.inFlight += 1;
176
+ return;
177
+ case "retry-later":
178
+ case "halted":
179
+ counts.retryLater += 1;
180
+ return;
181
+ case "uncertain":
182
+ counts.uncertain += 1;
183
+ return;
184
+ }
185
+ }
186
+
187
+ function emptyRejections(): Record<MailingErrorCode, number> {
188
+ return { "mailing.invalid_input": 0, "mailing.rejected": 0, "mailing.unavailable": 0, "mailing.suppressed": 0, "mailing.provider_refused": 0, "mailing.quota_exceeded": 0 };
189
+ }
190
+
191
+ function wait(ms: number): Promise<void> {
192
+ return new Promise((resolve) => setTimeout(resolve, ms));
193
+ }
@@ -3,7 +3,7 @@
3
3
  // or the result, and `headers` cannot replace the envelope or the sender (a mail's own reply-to is
4
4
  // the `replyTo` field, checked like `to`). List mail (any kind but `transactional`) also gets a signed unsubscribe
5
5
  // link in a footer and the RFC 8058 headers, and is refused for a recipient who unsubscribed.
6
- import { err, ok, type SoftureConfig } from "@softure-ai/core";
6
+ import { err, ok, type Err, type Ok, type SoftureConfig } from "@softure-ai/core";
7
7
  import type { Queryable } from "@softure-ai/db";
8
8
  import {
9
9
  TRANSACTIONAL_KIND,
@@ -20,7 +20,7 @@ import type { MailingMessages } from "../messages/index.js";
20
20
  import { addHtmlFooter, addTextFooter, getListUnsubscribeHeaders } from "./list-mail.js";
21
21
  import { getMailingModule, getMailingOptions } from "./options.js";
22
22
  import { isSuppressed } from "./suppressions.js";
23
- import { buildUnsubscribeLinks, MIN_UNSUBSCRIBE_SECRET_LENGTH, readUnsubscribeSecrets, UNSUBSCRIBE_SECRET_ENV } from "./unsubscribe-link.js";
23
+ import { buildUnsubscribeLinks, MIN_UNSUBSCRIBE_SECRET_LENGTH, readUnsubscribeSecrets, UNSUBSCRIBE_SECRET_ENV, type Env } from "./unsubscribe-link.js";
24
24
  import { validateMail, type ValidMail } from "./validate-mail.js";
25
25
 
26
26
  /**
@@ -32,8 +32,6 @@ export interface MailContext {
32
32
  readonly db?: Queryable;
33
33
  }
34
34
 
35
- type MailContent = Omit<ProviderMessage, "from" | "replyTo" | "idempotencyKey">;
36
-
37
35
  /**
38
36
  * Sends `mail` and resolves with the provider's message id or a `mailing.*` code:
39
37
  * `invalid_input` (fix the input), `rejected` (do not retry), `unavailable` (retry later with the
@@ -43,26 +41,24 @@ type MailContent = Omit<ProviderMessage, "from" | "replyTo" | "idempotencyKey">;
43
41
  * Throws only when the module is not enabled, or for list mail without `context.db`.
44
42
  */
45
43
  export async function sendMail(context: MailContext, mail: OutgoingMail, options: SendMailOptions = {}): Promise<SendMailResult> {
46
- const { from, replyTo, provider, timeoutMs } = getMailingOptions(context.config);
44
+ const { provider, timeoutMs } = getMailingOptions(context.config);
47
45
 
48
46
  const validation = validateMail(mail, options);
49
47
  if (!validation.ok) {
50
48
  logFailure(provider, "mailing.invalid_input", { fields: validation.fields });
51
49
  return err("mailing.invalid_input");
52
50
  }
53
-
54
- const { idempotencyKey, kind, replyTo: mailReplyTo, ...content } = validation.value;
55
- let message: MailContent = content;
56
- if (kind !== TRANSACTIONAL_KIND) {
57
- const listMail = await prepareListMail(context, validation.value);
58
- if (!listMail.ok) {
59
- logFailure(provider, listMail.error, { cause: listMail.cause });
60
- return err(listMail.error);
51
+ const valid = validation.value;
52
+ const secret = readUnsubscribeSecrets().current;
53
+ if (valid.kind !== TRANSACTIONAL_KIND) {
54
+ const refusal = await checkListMail(context, valid, secret);
55
+ if (refusal !== null) {
56
+ logFailure(provider, refusal.error, { cause: refusal.cause });
57
+ return err(refusal.error);
61
58
  }
62
- message = listMail.value;
63
59
  }
64
60
 
65
- const outcome = await callProvider(provider, { ...message, from, replyTo: mailReplyTo ?? replyTo ?? null, idempotencyKey }, timeoutMs);
61
+ const outcome = await callProvider(provider, composeMessage(context.config, valid, secret), timeoutMs);
66
62
  if (outcome.status === "sent") {
67
63
  return ok({ id: outcome.id, provider: provider.name });
68
64
  }
@@ -71,6 +67,33 @@ export async function sendMail(context: MailContext, mail: OutgoingMail, options
71
67
  return outcome.httpStatus === undefined ? err(code) : { ...err(code), httpStatus: outcome.httpStatus };
72
68
  }
73
69
 
70
+ /** Why `previewMail` cannot render a mail: invalid input (with the fields), or list mail without the secret. */
71
+ export type MailPreviewFailure =
72
+ | (Err<"mailing.invalid_input"> & { readonly fields: readonly string[] })
73
+ | (Err<"mailing.unavailable"> & { readonly cause: "no_unsubscribe_secret" });
74
+
75
+ export type MailPreviewResult = Ok<ProviderMessage> | MailPreviewFailure;
76
+
77
+ export interface PreviewMailOptions extends SendMailOptions {
78
+ /** Where the unsubscribe secret is read. Default: `process.env`. */
79
+ readonly env?: Env;
80
+ }
81
+
82
+ /**
83
+ * Exactly what `sendMail` would hand the provider for `mail`: the sender, the reply-to, and for list mail the
84
+ * footer with a link signed for `mail.to` and the RFC 8058 headers. Sends nothing, logs nothing and needs no
85
+ * database: it does not check the suppression list, so it renders a mail `sendMail` would refuse as suppressed.
86
+ * The links in it are credentials for `mail.to`. Throws only when the module is not enabled.
87
+ */
88
+ export function previewMail(config: SoftureConfig, mail: OutgoingMail, options: PreviewMailOptions = {}): MailPreviewResult {
89
+ const { env, ...sendOptions } = options;
90
+ const validation = validateMail(mail, sendOptions);
91
+ if (!validation.ok) return { ...err("mailing.invalid_input"), fields: validation.fields };
92
+ const secret = readUnsubscribeSecrets(env).current;
93
+ if (validation.value.kind !== TRANSACTIONAL_KIND && secret === null) return { ...err("mailing.unavailable"), cause: "no_unsubscribe_secret" };
94
+ return ok(composeMessage(config, validation.value, secret));
95
+ }
96
+
74
97
  const FAILURE_CODES: Readonly<Record<ProviderFailureStatus, MailingErrorCode>> = {
75
98
  rejected: "mailing.rejected",
76
99
  unavailable: "mailing.unavailable",
@@ -78,44 +101,47 @@ const FAILURE_CODES: Readonly<Record<ProviderFailureStatus, MailingErrorCode>> =
78
101
  quota_exceeded: "mailing.quota_exceeded",
79
102
  };
80
103
 
81
- type ListMailResult =
82
- | { readonly ok: true; readonly value: MailContent }
83
- | { readonly ok: false; readonly error: "mailing.suppressed" | "mailing.unavailable"; readonly cause: string };
104
+ type ListMailRefusal = { readonly error: "mailing.suppressed" | "mailing.unavailable"; readonly cause: string };
84
105
 
85
106
  /**
86
- * A list mail with its footer and headers, or why it must not go out. Fails closed: without the
87
- * secret, or when the suppression list cannot be read, nothing is sent.
107
+ * Why a list mail must not go out, or `null` when it may. Fails closed: without the secret, or when the suppression
108
+ * list cannot be read, nothing is sent.
88
109
  */
89
- async function prepareListMail(context: MailContext, mail: ValidMail): Promise<ListMailResult> {
110
+ async function checkListMail(context: MailContext, mail: ValidMail, secret: string | null): Promise<ListMailRefusal | null> {
90
111
  if (context.db === undefined) {
91
112
  throw new Error("@softure-ai/mailing: list mail needs the database handle; call sendMail({ config, db }, ...)");
92
113
  }
93
- const secret = readUnsubscribeSecrets().current;
94
114
  if (secret === null) {
95
115
  console.error(`mailing: list mail needs ${UNSUBSCRIBE_SECRET_ENV} (at least ${String(MIN_UNSUBSCRIBE_SECRET_LENGTH)} characters) to sign unsubscribe links`);
96
- return { ok: false, error: "mailing.unavailable", cause: "no_unsubscribe_secret" };
116
+ return { error: "mailing.unavailable", cause: "no_unsubscribe_secret" };
97
117
  }
98
- let suppressed: boolean;
99
118
  try {
100
- suppressed = await isSuppressed({ db: context.db }, mail.to);
119
+ return (await isSuppressed({ db: context.db }, mail.to)) ? { error: "mailing.suppressed", cause: "unsubscribed" } : null;
101
120
  } catch {
102
121
  // The error text can carry the query parameters, the recipient key among them.
103
- return { ok: false, error: "mailing.unavailable", cause: "suppressions_unreadable" };
122
+ return { error: "mailing.unavailable", cause: "suppressions_unreadable" };
104
123
  }
105
- if (suppressed) return { ok: false, error: "mailing.suppressed", cause: "unsubscribed" };
124
+ }
106
125
 
107
- const links = buildUnsubscribeLinks(context.config, mail.to, secret);
126
+ /**
127
+ * The message the provider receives: the configured sender, the mail's or the configured reply-to, and for list
128
+ * mail the footer and headers signed with `secret` (which the caller checked is set).
129
+ */
130
+ function composeMessage(config: SoftureConfig, mail: ValidMail, secret: string | null): ProviderMessage {
131
+ const { from, replyTo } = getMailingOptions(config);
132
+ const { idempotencyKey, kind, replyTo: mailReplyTo, ...content } = mail;
133
+ const envelope = { from, replyTo: mailReplyTo ?? replyTo ?? null, idempotencyKey };
134
+ if (kind === TRANSACTIONAL_KIND || secret === null) return { ...content, ...envelope };
135
+ const links = buildUnsubscribeLinks(config, mail.to, secret);
108
136
  // The module factory merged the dictionaries; their shape is the module's own.
109
- const copy = (getMailingModule(context.config).messages[context.config.locale] as MailingMessages).footer;
137
+ const copy = (getMailingModule(config).messages[config.locale] as MailingMessages).footer;
110
138
  return {
111
- ok: true,
112
- value: {
113
- to: mail.to,
114
- subject: mail.subject,
115
- text: addTextFooter(mail.text, links, copy),
116
- html: mail.html === null ? null : addHtmlFooter(mail.html, links, copy),
117
- headers: { ...mail.headers, ...getListUnsubscribeHeaders(links) },
118
- },
139
+ ...envelope,
140
+ to: mail.to,
141
+ subject: mail.subject,
142
+ text: addTextFooter(mail.text, links, copy),
143
+ html: mail.html === null ? null : addHtmlFooter(mail.html, links, copy),
144
+ headers: { ...mail.headers, ...getListUnsubscribeHeaders(links) },
119
145
  };
120
146
  }
121
147
 
@@ -23,6 +23,33 @@ export async function isSuppressed(ctx: Pick<SuppressionContext, "db">, address:
23
23
  return rows.length > 0;
24
24
  }
25
25
 
26
+ /** How many recipient keys one query asks about, so a long list never builds an oversized statement. */
27
+ const SUPPRESSION_LOOKUP_BATCH = 500;
28
+
29
+ /**
30
+ * Which of `addresses` unsubscribed, each returned as it was given; e.g. a report that splits a list into
31
+ * active and suppressed recipients. Throws on a database failure: callers decide how to fail.
32
+ */
33
+ export async function findSuppressedAddresses(ctx: Pick<SuppressionContext, "db">, addresses: readonly string[]): Promise<Set<string>> {
34
+ const byKey = new Map<string, string[]>();
35
+ for (const address of addresses) {
36
+ const key = getRecipientKey(address);
37
+ byKey.set(key, [...(byKey.get(key) ?? []), address]);
38
+ }
39
+ const keys = [...byKey.keys()];
40
+ const suppressed = new Set<string>();
41
+ for (let start = 0; start < keys.length; start += SUPPRESSION_LOOKUP_BATCH) {
42
+ const rows = await ctx.db
43
+ .select({ recipientKey: suppressions.recipientKey })
44
+ .from(suppressions)
45
+ .where(inArray(suppressions.recipientKey, keys.slice(start, start + SUPPRESSION_LOOKUP_BATCH)));
46
+ for (const row of rows) {
47
+ for (const address of byKey.get(row.recipientKey) ?? []) suppressed.add(address);
48
+ }
49
+ }
50
+ return suppressed;
51
+ }
52
+
26
53
  /** Records an opt-out by recipient key. Throws on a database failure. */
27
54
  async function recordSuppression(ctx: SuppressionContext, recipientKey: string, source: SuppressionSource): Promise<void> {
28
55
  await ctx.db.insert(suppressions).values({ recipientKey, source, createdAt: ctx.clock.now() }).onConflictDoNothing();
@@ -93,7 +120,7 @@ async function verifyLink(ctx: SuppressionContext, link: UnsubscribeLink | Unsub
93
120
  }
94
121
  const legacy = getMailingOptions(ctx.config).legacyUnsubscribe;
95
122
  if (legacy === undefined) return null;
96
- const address: unknown = await legacy.verify(link.values, ctx);
123
+ const address: unknown = await legacy.verify(link.values, ctx, env);
97
124
  if (typeof address !== "string" || address.trim() === "") return null;
98
125
  return { recipientKey: getRecipientKey(address), link: { scheme: "legacy", values: link.values } };
99
126
  }