@softure-ai/mailing 0.1.8 → 0.1.10

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 (80) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +110 -16
  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 +202 -30
  6. package/dist/cli/run.js.map +1 -1
  7. package/dist/contract.d.ts +41 -6
  8. package/dist/contract.d.ts.map +1 -1
  9. package/dist/index.d.ts +14 -2
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +1 -1
  12. package/dist/index.js.map +1 -1
  13. package/dist/next/import-deliveries.d.ts +4 -0
  14. package/dist/next/import-deliveries.d.ts.map +1 -0
  15. package/dist/next/import-deliveries.js +9 -0
  16. package/dist/next/import-deliveries.js.map +1 -0
  17. package/dist/next/index.d.ts +1 -0
  18. package/dist/next/index.d.ts.map +1 -1
  19. package/dist/next/index.js +1 -0
  20. package/dist/next/index.js.map +1 -1
  21. package/dist/next/route.d.ts +3 -2
  22. package/dist/next/route.d.ts.map +1 -1
  23. package/dist/next/route.js +5 -4
  24. package/dist/next/route.js.map +1 -1
  25. package/dist/options.d.ts +19 -2
  26. package/dist/options.d.ts.map +1 -1
  27. package/dist/options.js +30 -6
  28. package/dist/options.js.map +1 -1
  29. package/dist/schema.d.ts +17 -0
  30. package/dist/schema.d.ts.map +1 -1
  31. package/dist/schema.js +2 -0
  32. package/dist/schema.js.map +1 -1
  33. package/dist/server/campaigns.d.ts +5 -0
  34. package/dist/server/campaigns.d.ts.map +1 -1
  35. package/dist/server/campaigns.js +10 -0
  36. package/dist/server/campaigns.js.map +1 -1
  37. package/dist/server/deliveries.d.ts +9 -5
  38. package/dist/server/deliveries.d.ts.map +1 -1
  39. package/dist/server/deliveries.js +9 -7
  40. package/dist/server/deliveries.js.map +1 -1
  41. package/dist/server/import-deliveries.d.ts +68 -0
  42. package/dist/server/import-deliveries.d.ts.map +1 -0
  43. package/dist/server/import-deliveries.js +105 -0
  44. package/dist/server/import-deliveries.js.map +1 -0
  45. package/dist/server/index.d.ts +3 -2
  46. package/dist/server/index.d.ts.map +1 -1
  47. package/dist/server/index.js +3 -2
  48. package/dist/server/index.js.map +1 -1
  49. package/dist/server/send-mail.js +6 -6
  50. package/dist/server/send-mail.js.map +1 -1
  51. package/dist/server/suppressions.d.ts.map +1 -1
  52. package/dist/server/suppressions.js +9 -6
  53. package/dist/server/suppressions.js.map +1 -1
  54. package/dist/server/unsubscribe-link.d.ts +3 -2
  55. package/dist/server/unsubscribe-link.d.ts.map +1 -1
  56. package/dist/server/unsubscribe-link.js +12 -3
  57. package/dist/server/unsubscribe-link.js.map +1 -1
  58. package/dist/server/validate-mail.d.ts +3 -1
  59. package/dist/server/validate-mail.d.ts.map +1 -1
  60. package/dist/server/validate-mail.js +4 -3
  61. package/dist/server/validate-mail.js.map +1 -1
  62. package/migrations/0004_allow_imported_deliveries.sql +20 -0
  63. package/module.json +1 -1
  64. package/package.json +1 -1
  65. package/src/cli/run.ts +208 -28
  66. package/src/contract.ts +36 -6
  67. package/src/index.ts +4 -1
  68. package/src/next/import-deliveries.ts +9 -0
  69. package/src/next/index.ts +1 -0
  70. package/src/next/route.ts +5 -4
  71. package/src/options.ts +36 -7
  72. package/src/schema.ts +2 -0
  73. package/src/server/campaigns.ts +13 -0
  74. package/src/server/deliveries.ts +15 -8
  75. package/src/server/import-deliveries.ts +149 -0
  76. package/src/server/index.ts +12 -0
  77. package/src/server/send-mail.ts +6 -6
  78. package/src/server/suppressions.ts +15 -8
  79. package/src/server/unsubscribe-link.ts +10 -3
  80. package/src/server/validate-mail.ts +6 -3
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.8",
20
+ version: "0.1.10",
21
21
  dependsOn: {},
22
22
  dbSchema: "mailing",
23
23
  tables: ["suppressions", "campaigns", "deliveries"],
@@ -68,7 +68,9 @@ export { HALTING_ERROR_CODES, TRANSACTIONAL_KIND } from "./contract.js";
68
68
  export type {
69
69
  CampaignRecipient,
70
70
  CampaignRecipientFilter,
71
+ CampaignRecipientSource,
71
72
  LegacyUnsubscribe,
73
+ LegacyUnsubscribeParams,
72
74
  MailingErrorCode,
73
75
  MailProvider,
74
76
  OnUnsubscribedHook,
@@ -83,6 +85,7 @@ export type {
83
85
  SuppressionSource,
84
86
  UnsubscribeErrorCode,
85
87
  UnsubscribeEvent,
88
+ VerifiedUnsubscribeLink,
86
89
  } from "./contract.js";
87
90
  export { getMailingErrorMessage, mailingMessages, type MailingMessages } from "./messages/index.js";
88
91
  export { DEFAULT_STALE_CLAIM_MS, DEFAULT_TIMEOUT_MS, DEFAULT_UNCERTAIN_CLAIM_MS, type MailingOptions, type MailingOptionsInput } from "./options.js";
@@ -0,0 +1,9 @@
1
+ // `importDeliveries` on the app's registered configuration and the shared database, e.g. for a one-off route or
2
+ // script that seeds the ledger from the app's own history.
3
+ import { importDeliveries as importDeliveriesWithContext, type ImportedDelivery, type ImportResult } from "../server/import-deliveries.js";
4
+ import { getMailingContext } from "./context.js";
5
+
6
+ /** Seeds the delivery ledger; see `importDeliveries` in `@softure-ai/mailing/server`. */
7
+ export async function importDeliveries(rows: readonly ImportedDelivery[]): Promise<ImportResult> {
8
+ return importDeliveriesWithContext(await getMailingContext(), rows);
9
+ }
package/src/next/index.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  // (docs/02-module-standard.md §8).
4
4
  export { unsubscribeAction } from "./actions.js";
5
5
  export { deliverOnce } from "./deliver-once.js";
6
+ export { importDeliveries } from "./import-deliveries.js";
6
7
  export { getMailingMessages } from "./messages.js";
7
8
  export { UnsubscribePage, type UnsubscribePageProps } from "./pages.js";
8
9
  export { UNSUBSCRIBE_STATUS_PARAM, type UnsubscribeStatus } from "./params.js";
package/src/next/route.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  // the only write is an idempotent insert.
11
11
  import { errorLogLabel } from "@softure-ai/core";
12
12
  import { getSoftureConfig } from "@softure-ai/core/next";
13
- import { getMailingRoutes } from "../server/options.js";
13
+ import { getMailingOptions, getMailingRoutes } from "../server/options.js";
14
14
  import { unsubscribe } from "../server/suppressions.js";
15
15
  import { readUnsubscribeLink } from "../server/unsubscribe-link.js";
16
16
  import { getMailingContext } from "./context.js";
@@ -20,8 +20,9 @@ const NO_STORE = { "cache-control": "no-store" };
20
20
  /**
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
- * 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. A legacy link (`mailing({ legacyUnsubscribe })`) is handled the same
23
+ * that does not verify (200 with `mailing({ oneClickInvalidLinkStatus: 200 })`, so the answer never
24
+ * tells whether a token is live), 500 when the database fails: the client may retry, and 200 would
25
+ * claim an opt-out that did not happen. A legacy link (`mailing({ legacyUnsubscribe })`) is handled the same
25
26
  * way; mount this route at the old link's path too.
26
27
  */
27
28
  export async function postUnsubscribeRoute(request: Request): Promise<Response> {
@@ -29,7 +30,7 @@ export async function postUnsubscribeRoute(request: Request): Promise<Response>
29
30
  const ctx = await getMailingContext();
30
31
  const link = readUnsubscribeLink(new URL(request.url).searchParams, ctx.config);
31
32
  const result = await unsubscribe(ctx, link, "one-click");
32
- return new Response(null, { status: result.ok ? 200 : 400, headers: NO_STORE });
33
+ return new Response(null, { status: result.ok ? 200 : getMailingOptions(ctx.config).oneClickInvalidLinkStatus, headers: NO_STORE });
33
34
  } catch (error) {
34
35
  console.error(`@softure-ai/mailing: one-click unsubscribe failed: ${errorLogLabel(error)}`);
35
36
  return new Response(null, { status: 500, headers: NO_STORE });
package/src/options.ts CHANGED
@@ -1,7 +1,7 @@
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 { CampaignRecipientFilter, LegacyUnsubscribe, MailProvider, OnUnsubscribedHook } from "./contract.js";
4
+ import type { CampaignRecipientFilter, CampaignRecipientSource, LegacyUnsubscribe, MailProvider, OnUnsubscribedHook } from "./contract.js";
5
5
 
6
6
  export const DEFAULT_TIMEOUT_MS = 10_000;
7
7
  /** How long a delivery claim may stay open before another sender may take it over (and send again, same key). */
@@ -11,18 +11,32 @@ export const DEFAULT_STALE_CLAIM_MS = 15 * 60_000;
11
11
  * the provider may have forgotten the idempotency key (Resend keeps one for 24 hours), so a retake could mail twice.
12
12
  */
13
13
  export const DEFAULT_UNCERTAIN_CLAIM_MS = 23 * 60 * 60_000;
14
+ /**
15
+ * Claims a delivery gets before `mailing.unavailable` becomes its final outcome. A short outage across a few runs is
16
+ * ridden out, while a provider that keeps failing on one mail does not keep it open forever.
17
+ */
18
+ export const DEFAULT_MAX_ATTEMPTS = 5;
14
19
 
15
20
  /** Query names the signed link and the page use; a legacy link cannot claim them. */
16
21
  const RESERVED_LINK_PARAMS: ReadonlySet<string> = new Set(["r", "status"]);
17
22
  const LINK_PARAM = /^[A-Za-z][A-Za-z0-9_-]{0,31}$/;
18
23
 
24
+ const MAX_LINK_PARAMS = 8;
25
+ const linkParamNames = z.array(z.string().regex(LINK_PARAM, "must be a query name: a letter, then letters, digits, _ or -, at most 32 characters"));
26
+
27
+ /** Both forms of `params`, normalised to required and optional names, with the limits on all names together. */
28
+ const legacyParamsSchema = z
29
+ .union([linkParamNames.min(1), z.strictObject({ required: linkParamNames.min(1), optional: linkParamNames.default([]) })])
30
+ .transform((params) => (Array.isArray(params) ? { required: params, optional: [] } : params))
31
+ .refine(({ required, optional }) => required.length + optional.length <= MAX_LINK_PARAMS, `must name at most ${String(MAX_LINK_PARAMS)} parameters`)
32
+ .refine(({ required, optional }) => new Set([...required, ...optional]).size === required.length + optional.length, "must not repeat a name")
33
+ .refine(
34
+ ({ required, optional }) => [...required, ...optional].every((param) => !RESERVED_LINK_PARAMS.has(param)),
35
+ "must not use r or status: the signed link and the page own them",
36
+ );
37
+
19
38
  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"),
39
+ params: legacyParamsSchema,
26
40
  verify: z.custom<LegacyUnsubscribe["verify"]>((value) => typeof value === "function", "must be a function"),
27
41
  });
28
42
 
@@ -53,8 +67,23 @@ export const mailingOptionsSchema = z.strictObject({
53
67
  onUnsubscribed: z.custom<OnUnsubscribedHook>((value) => typeof value === "function", "must be a function").optional(),
54
68
  /** Verifies unsubscribe links the app sent before it adopted the module (see `LegacyUnsubscribe`). */
55
69
  legacyUnsubscribe: legacyUnsubscribeSchema.optional(),
70
+ /**
71
+ * What the one-click route answers for a link that does not verify: 400 (the default), or 200 so the answer never
72
+ * tells whether a token is live. A failure still answers 500, so the mail client retries; the page is unaffected.
73
+ */
74
+ oneClickInvalidLinkStatus: z.union([z.literal(200), z.literal(400)]).default(400),
56
75
  /** Decides per recipient whether a campaign goes to them, e.g. by consent scope (see `CampaignRecipientFilter`). */
57
76
  filterCampaignRecipient: z.custom<CampaignRecipientFilter>((value) => typeof value === "function", "must be a function").optional(),
77
+ /**
78
+ * Lists a campaign's recipients from the app's data; `softure-mail campaign` uses it when no `--recipients` file
79
+ * is given (see `CampaignRecipientSource`).
80
+ */
81
+ listCampaignRecipients: z.custom<CampaignRecipientSource>((value) => typeof value === "function", "must be a function").optional(),
82
+ /**
83
+ * Claims a delivery gets before `mailing.unavailable` closes it as rejected, 1 to 100; `null` never closes it, for
84
+ * apps that retry on their own schedule. Default 5.
85
+ */
86
+ maxAttempts: z.number().int().min(1).max(100).nullable().default(DEFAULT_MAX_ATTEMPTS),
58
87
  /** How long a delivery claim may stay open before another sender takes it over. 1 minute to 23 hours. */
59
88
  staleClaimMs: z.number().int().min(60_000).max(23 * HOUR_MS).default(DEFAULT_STALE_CLAIM_MS),
60
89
  /**
package/src/schema.ts CHANGED
@@ -33,6 +33,8 @@ export const deliveries = mailingSchema.table(
33
33
  reason: text("reason"),
34
34
  /** The provider's HTTP status of the last answer that failed or was released; null when there was none. */
35
35
  providerStatus: integer("provider_status"),
36
+ /** When `importDeliveries` wrote the row; null for a delivery the module made. */
37
+ importedAt: timestamp("imported_at", { withTimezone: true }),
36
38
  createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
37
39
  },
38
40
  (table) => [primaryKey({ columns: [table.scope, table.recipientKey] })],
@@ -135,6 +135,19 @@ function createRecipientFilter(ctx: DeliveryContext, campaign: CampaignContent):
135
135
  };
136
136
  }
137
137
 
138
+ /**
139
+ * The campaign's recipients from the module's `listCampaignRecipients`, or `null` when the app set none. Throws
140
+ * what the app's function throws.
141
+ */
142
+ export async function listConfiguredCampaignRecipients(
143
+ ctx: DeliveryContext,
144
+ campaign: Pick<CampaignContent, "id" | "kind">,
145
+ ): Promise<Iterable<string> | AsyncIterable<string> | null> {
146
+ const source = getMailingOptions(ctx.config).listCampaignRecipients;
147
+ if (source === undefined) return null;
148
+ return source({ id: campaign.id, kind: campaign.kind }, ctx);
149
+ }
150
+
138
151
  export interface CampaignPlan {
139
152
  readonly recipients: number;
140
153
  /** Already closed by an earlier run. */
@@ -10,7 +10,7 @@ import type { Queryable } from "@softure-ai/db";
10
10
  import { and, eq, gte, lt, or, sql, type SQL } from "drizzle-orm";
11
11
  import { isMailKind } from "../address.js";
12
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";
13
+ import { DEFAULT_MAX_ATTEMPTS, DEFAULT_STALE_CLAIM_MS, DEFAULT_UNCERTAIN_CLAIM_MS } from "../options.js";
14
14
  import { deliveries } from "../schema.js";
15
15
  import { getMailingOptions } from "./options.js";
16
16
  import { sendMail } from "./send-mail.js";
@@ -18,9 +18,7 @@ import { getRecipientKey } from "./unsubscribe-link.js";
18
18
 
19
19
  export type DeliveryContext = ModuleContext<Queryable>;
20
20
 
21
- export { DEFAULT_STALE_CLAIM_MS, DEFAULT_UNCERTAIN_CLAIM_MS };
22
- /** Claims a delivery gets before `mailing.unavailable` becomes its final outcome. */
23
- export const DEFAULT_MAX_ATTEMPTS = 5;
21
+ export { DEFAULT_MAX_ATTEMPTS, DEFAULT_STALE_CLAIM_MS, DEFAULT_UNCERTAIN_CLAIM_MS };
24
22
 
25
23
  const MAX_SCOPE_LENGTH = 128;
26
24
  const SCOPE = /^[a-z0-9][a-z0-9._:-]*$/;
@@ -48,7 +46,11 @@ export interface DeliverOptions {
48
46
  * An operator's decision, e.g. `softure-mail campaign --resend-uncertain`. Default false.
49
47
  */
50
48
  readonly retakeUncertain?: boolean;
51
- readonly maxAttempts?: number;
49
+ /**
50
+ * Claims before `mailing.unavailable` closes the delivery as rejected; `null` never closes it. Default: the module's
51
+ * `maxAttempts` (5).
52
+ */
53
+ readonly maxAttempts?: number | null;
52
54
  }
53
55
 
54
56
  /** The codes that stop a run: the sending account, not the mail, is the problem. */
@@ -84,7 +86,7 @@ export type DeliveryOutcome =
84
86
  */
85
87
  export async function deliverOnce(ctx: DeliveryContext, delivery: Delivery, options: DeliverOptions = {}): Promise<DeliveryOutcome> {
86
88
  const windows = resolveClaimWindows(ctx, options);
87
- const { maxAttempts = DEFAULT_MAX_ATTEMPTS } = options;
89
+ const maxAttempts = options.maxAttempts === undefined ? getMailingOptions(ctx.config).maxAttempts : options.maxAttempts;
88
90
  const kind = delivery.mail.kind ?? TRANSACTIONAL_KIND;
89
91
  assertDelivery(delivery, kind);
90
92
 
@@ -105,7 +107,7 @@ export async function deliverOnce(ctx: DeliveryContext, delivery: Delivery, opti
105
107
  await releaseDelivery(ctx, fence, { providerStatus, giveAttemptBack: true });
106
108
  return { status: "halted", reason: result.error, ...httpStatus };
107
109
  }
108
- if (result.error === "mailing.unavailable" && attempt < maxAttempts) {
110
+ if (result.error === "mailing.unavailable" && (maxAttempts === null || attempt < maxAttempts)) {
109
111
  await releaseDelivery(ctx, fence, { providerStatus, giveAttemptBack: false });
110
112
  return { status: "retry-later", ...httpStatus };
111
113
  }
@@ -134,8 +136,13 @@ function resolveClaimWindows(ctx: DeliveryContext, options: DeliverOptions): Cla
134
136
  return { staleClaimMs, uncertainClaimMs, retakeUncertain: options.retakeUncertain ?? false };
135
137
  }
136
138
 
139
+ /** Lowercase letters, digits and `._:-`, at most 128 characters. */
140
+ export function isDeliveryScope(scope: string): boolean {
141
+ return scope.length <= MAX_SCOPE_LENGTH && SCOPE.test(scope);
142
+ }
143
+
137
144
  function assertDelivery(delivery: Delivery, kind: string): void {
138
- if (delivery.scope.length > MAX_SCOPE_LENGTH || !SCOPE.test(delivery.scope)) {
145
+ if (!isDeliveryScope(delivery.scope)) {
139
146
  throw new Error(`@softure-ai/mailing: deliverOnce scope "${delivery.scope}" must be lowercase letters, digits and ._:- (at most 128 characters)`);
140
147
  }
141
148
  if (!isMailKind(kind)) {
@@ -0,0 +1,149 @@
1
+ // Seeds the delivery ledger with deliveries an app made before it adopted the module, so `deliverOnce` and
2
+ // campaigns skip them. Every row is checked before anything is written; a row whose scope and recipient are
3
+ // already in the ledger is left as it is (the ledger wins, and a live claim keeps its fence), so a re-run imports
4
+ // nothing twice.
5
+ import { isMailKind, isSingleAddress } from "../address.js";
6
+ import { TRANSACTIONAL_KIND, type MailingErrorCode } from "../contract.js";
7
+ import { deliveries } from "../schema.js";
8
+ import { isDeliveryScope, type DeliveryContext } from "./deliveries.js";
9
+ import { getRecipientKey } from "./unsubscribe-link.js";
10
+
11
+ /** The reasons a rejected row may carry: the codes `deliverOnce` closes a row with. */
12
+ export type ImportedRejectionReason = Extract<MailingErrorCode, "mailing.invalid_input" | "mailing.rejected" | "mailing.unavailable" | "mailing.suppressed">;
13
+
14
+ const REJECTION_REASONS: ReadonlySet<string> = new Set<ImportedRejectionReason>(["mailing.invalid_input", "mailing.rejected", "mailing.unavailable", "mailing.suppressed"]);
15
+
16
+ /** Rows written per statement. */
17
+ const BATCH_SIZE = 500;
18
+
19
+ export interface ImportedDelivery {
20
+ /** The scope `deliverOnce` will use for this mail: `campaign:<id>` for a campaign, else e.g. `billing.trial-ending:sub_42`. */
21
+ readonly scope: string;
22
+ /** The recipient's address; only its key is stored (any case or spacing gives the same key). */
23
+ readonly address: string;
24
+ readonly status: "sent" | "rejected";
25
+ /** When the delivery was closed; a `Date` or an ISO 8601 string, not in the future. */
26
+ readonly finishedAt: Date | string;
27
+ /** The provider's message id, when the app kept it. Only on `sent`. */
28
+ readonly providerMessageId?: string;
29
+ /** Why a rejected delivery is final. Only on `rejected`; default `mailing.rejected`. */
30
+ readonly reason?: ImportedRejectionReason;
31
+ /** The mail kind. Default `transactional`. */
32
+ readonly kind?: string;
33
+ }
34
+
35
+ export interface ImportProblem {
36
+ /** The row's index in the input (0-based). */
37
+ readonly index: number;
38
+ /** What is wrong, naming the field; never the address. */
39
+ readonly problem: string;
40
+ }
41
+
42
+ export interface ImportSummary {
43
+ /** Rows given. */
44
+ readonly rows: number;
45
+ /** Rows written now. */
46
+ readonly imported: number;
47
+ /** Rows whose scope and recipient the ledger already had (from an earlier import or a send); left as they were. */
48
+ readonly alreadyPresent: number;
49
+ /** Rows with the scope and recipient of an earlier row in the input; the first one counts. */
50
+ readonly duplicates: number;
51
+ }
52
+
53
+ /** The import's answer: a summary, or every problem (and nothing written). */
54
+ export type ImportResult = { readonly ok: true; readonly value: ImportSummary } | { readonly ok: false; readonly problems: readonly ImportProblem[] };
55
+
56
+ /** The checked rows, ready for the ledger, or every problem. */
57
+ export type ImportCheck =
58
+ | { readonly ok: true; readonly value: { readonly ledgerRows: readonly LedgerRow[]; readonly duplicates: number } }
59
+ | { readonly ok: false; readonly problems: readonly ImportProblem[] };
60
+
61
+ type LedgerRow = typeof deliveries.$inferInsert;
62
+
63
+ /**
64
+ * Writes `rows` into `mailing.deliveries` as closed deliveries. Returns the problems, and writes nothing, when any
65
+ * row is invalid. Throws on a database failure; rows written before it stay, and a re-run skips them.
66
+ */
67
+ export async function importDeliveries(ctx: DeliveryContext, rows: readonly ImportedDelivery[]): Promise<ImportResult> {
68
+ const checked = checkImportedDeliveries(ctx, rows);
69
+ if (!checked.ok) return checked;
70
+ const { ledgerRows, duplicates } = checked.value;
71
+
72
+ let imported = 0;
73
+ for (let start = 0; start < ledgerRows.length; start += BATCH_SIZE) {
74
+ const written = await ctx.db
75
+ .insert(deliveries)
76
+ .values(ledgerRows.slice(start, start + BATCH_SIZE))
77
+ .onConflictDoNothing({ target: [deliveries.scope, deliveries.recipientKey] })
78
+ .returning();
79
+ imported += written.length;
80
+ }
81
+ return { ok: true, value: { rows: rows.length, imported, alreadyPresent: ledgerRows.length - imported, duplicates } };
82
+ }
83
+
84
+ /**
85
+ * Checks every row and builds the ledger rows, without touching the database (e.g. for a dry run). The problems
86
+ * cover every invalid row, not only the first.
87
+ */
88
+ export function checkImportedDeliveries(
89
+ ctx: Pick<DeliveryContext, "clock">,
90
+ rows: readonly ImportedDelivery[],
91
+ ): ImportCheck {
92
+ const now = ctx.clock.now();
93
+ const problems: ImportProblem[] = [];
94
+ const ledgerRows: LedgerRow[] = [];
95
+ const seen = new Set<string>();
96
+ let duplicates = 0;
97
+ rows.forEach((row, index) => {
98
+ const rowProblems = findRowProblems(row, now);
99
+ if (rowProblems.length > 0) {
100
+ problems.push(...rowProblems.map((problem) => ({ index, problem })));
101
+ return;
102
+ }
103
+ const recipientKey = getRecipientKey(row.address);
104
+ const key = `${row.scope}\n${recipientKey}`;
105
+ if (seen.has(key)) {
106
+ duplicates += 1;
107
+ return;
108
+ }
109
+ seen.add(key);
110
+ const finishedAt = new Date(row.finishedAt);
111
+ ledgerRows.push({
112
+ scope: row.scope,
113
+ recipientKey,
114
+ kind: row.kind ?? TRANSACTIONAL_KIND,
115
+ // Campaign rows are matched by scope; a campaign id would need the campaign's content, which history lacks.
116
+ campaignId: null,
117
+ status: row.status,
118
+ attempts: 1,
119
+ claimedAt: finishedAt,
120
+ finishedAt,
121
+ providerMessageId: row.status === "sent" ? (row.providerMessageId ?? null) : null,
122
+ reason: row.status === "rejected" ? (row.reason ?? "mailing.rejected") : null,
123
+ providerStatus: null,
124
+ createdAt: now,
125
+ importedAt: now,
126
+ });
127
+ });
128
+ return problems.length > 0 ? { ok: false, problems } : { ok: true, value: { ledgerRows, duplicates } };
129
+ }
130
+
131
+ function findRowProblems(row: ImportedDelivery, now: Date): string[] {
132
+ const problems: string[] = [];
133
+ if (!isDeliveryScope(row.scope)) problems.push("scope must be lowercase letters, digits and ._:- (at most 128 characters)");
134
+ if (!isSingleAddress(row.address.trim())) problems.push("address must be one email address");
135
+ if (row.status !== "sent" && row.status !== "rejected") problems.push('status must be "sent" or "rejected"');
136
+ const finishedAt = new Date(row.finishedAt);
137
+ if (Number.isNaN(finishedAt.getTime())) problems.push("finishedAt must be a date");
138
+ else if (finishedAt.getTime() > now.getTime()) problems.push("finishedAt must not be in the future");
139
+ if (row.providerMessageId !== undefined) {
140
+ if (row.status !== "sent") problems.push("providerMessageId belongs only to a sent row");
141
+ else if (row.providerMessageId.trim() === "" || row.providerMessageId.length > 256) problems.push("providerMessageId must be 1 to 256 characters");
142
+ }
143
+ if (row.reason !== undefined) {
144
+ if (row.status !== "rejected") problems.push("reason belongs only to a rejected row");
145
+ else if (!REJECTION_REASONS.has(row.reason)) problems.push(`reason must be one of ${[...REJECTION_REASONS].join(", ")}`);
146
+ }
147
+ if (row.kind !== undefined && !isMailKind(row.kind)) problems.push("kind must be kebab-case (at most 64 characters)");
148
+ return problems;
149
+ }
@@ -3,6 +3,7 @@
3
3
  export { getCampaignProblems, parseCampaignFile, parseRecipientList, type CampaignContent, type CampaignFile, type CampaignFileResult } from "./campaign-file.js";
4
4
  export {
5
5
  getCampaignContentHash,
6
+ listConfiguredCampaignRecipients,
6
7
  planCampaign,
7
8
  registerCampaign,
8
9
  sendCampaign,
@@ -17,6 +18,7 @@ export {
17
18
  DEFAULT_STALE_CLAIM_MS,
18
19
  DEFAULT_UNCERTAIN_CLAIM_MS,
19
20
  deliverOnce,
21
+ isDeliveryScope,
20
22
  type DeliverOptions,
21
23
  type Delivery,
22
24
  type DeliveryContext,
@@ -44,6 +46,16 @@ export {
44
46
  type SenderDnsReport,
45
47
  } from "./dns.js";
46
48
  export { checkMailingTables } from "./health.js";
49
+ export {
50
+ checkImportedDeliveries,
51
+ importDeliveries,
52
+ type ImportCheck,
53
+ type ImportedDelivery,
54
+ type ImportedRejectionReason,
55
+ type ImportProblem,
56
+ type ImportResult,
57
+ type ImportSummary,
58
+ } from "./import-deliveries.js";
47
59
  export { addHtmlFooter, addTextFooter, getListUnsubscribeHeaders, SIGNATURE_SEPARATOR } from "./list-mail.js";
48
60
  export { getMailingModule, getMailingOptions, getMailingRoutes, type MailingRoutes } from "./options.js";
49
61
  export { sendMail, type MailContext } from "./send-mail.js";
@@ -1,7 +1,7 @@
1
- // Sends one mail through the configured provider. Ported from FIRE_TRACKER `src/lib/mail.ts` with
2
- // its three rules: it never throws for a failed send (every failure is a result), nothing of the
3
- // mail (address, subject, body, key) reaches a log or the result, and `headers` cannot replace the
4
- // envelope or the sender. List mail (any kind but `transactional`) also gets a signed unsubscribe
1
+ // Sends one mail through the configured provider, with three rules: it never throws for a failed
2
+ // send (every failure is a result), nothing of the mail (address, subject, body, key) reaches a log
3
+ // or the result, and `headers` cannot replace the envelope or the sender (a mail's own reply-to is
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
6
  import { err, ok, type SoftureConfig } from "@softure-ai/core";
7
7
  import type { Queryable } from "@softure-ai/db";
@@ -51,7 +51,7 @@ export async function sendMail(context: MailContext, mail: OutgoingMail, options
51
51
  return err("mailing.invalid_input");
52
52
  }
53
53
 
54
- const { idempotencyKey, kind, ...content } = validation.value;
54
+ const { idempotencyKey, kind, replyTo: mailReplyTo, ...content } = validation.value;
55
55
  let message: MailContent = content;
56
56
  if (kind !== TRANSACTIONAL_KIND) {
57
57
  const listMail = await prepareListMail(context, validation.value);
@@ -62,7 +62,7 @@ export async function sendMail(context: MailContext, mail: OutgoingMail, options
62
62
  message = listMail.value;
63
63
  }
64
64
 
65
- const outcome = await callProvider(provider, { ...message, from, replyTo: replyTo ?? null, idempotencyKey }, timeoutMs);
65
+ const outcome = await callProvider(provider, { ...message, from, replyTo: mailReplyTo ?? replyTo ?? null, idempotencyKey }, timeoutMs);
66
66
  if (outcome.status === "sent") {
67
67
  return ok({ id: outcome.id, provider: provider.name });
68
68
  }
@@ -6,7 +6,7 @@
6
6
  import { err, ok, type ModuleContext, type Result } from "@softure-ai/core";
7
7
  import type { Queryable } from "@softure-ai/db";
8
8
  import { and, eq, inArray } from "drizzle-orm";
9
- import type { SuppressionSource, UnsubscribeErrorCode } from "../contract.js";
9
+ import type { SuppressionSource, UnsubscribeErrorCode, VerifiedUnsubscribeLink } from "../contract.js";
10
10
  import { suppressions } from "../schema.js";
11
11
  import { getMailingOptions } from "./options.js";
12
12
  import { getRecipientKey, readUnsubscribeSecrets, verifyUnsubscribeToken, type Env, type UnsubscribeLink, type UnsubscribeToken } from "./unsubscribe-link.js";
@@ -67,26 +67,33 @@ export async function unsubscribe(
67
67
  source: Exclude<SuppressionSource, "operator">,
68
68
  env: Env = process.env,
69
69
  ): Promise<Result<undefined, UnsubscribeErrorCode>> {
70
- const recipientKey = await verifyLink(ctx, link, env);
71
- if (recipientKey === null) return err("mailing.invalid_link");
70
+ const verified = await verifyLink(ctx, link, env);
71
+ if (verified === null) return err("mailing.invalid_link");
72
+ const { recipientKey } = verified;
72
73
  const { onUnsubscribed } = getMailingOptions(ctx.config);
73
74
  await ctx.db.transaction(async (tx) => {
74
75
  const txCtx: SuppressionContext = { ...ctx, db: tx };
75
76
  await recordSuppression(txCtx, recipientKey, source);
76
- await onUnsubscribed?.({ recipientKey, source }, txCtx);
77
+ await onUnsubscribed?.({ recipientKey, source, link: verified.link }, txCtx);
77
78
  });
78
79
  return ok();
79
80
  }
80
81
 
81
- /** The recipient key a link proves, or `null` when it proves none. */
82
- async function verifyLink(ctx: SuppressionContext, link: UnsubscribeLink | UnsubscribeToken | null, env: Env): Promise<string | null> {
82
+ interface VerifiedLink {
83
+ readonly recipientKey: string;
84
+ readonly link: VerifiedUnsubscribeLink;
85
+ }
86
+
87
+ /** The recipient key a link proves and the link it came through, or `null` when it proves none. */
88
+ async function verifyLink(ctx: SuppressionContext, link: UnsubscribeLink | UnsubscribeToken | null, env: Env): Promise<VerifiedLink | null> {
83
89
  if (link === null) return null;
84
90
  if (!("scheme" in link) || link.scheme === "signed") {
85
91
  const token = "scheme" in link ? link.token : link;
86
- return verifyUnsubscribeToken(token, readUnsubscribeSecrets(env)) ? token.recipientKey : null;
92
+ return verifyUnsubscribeToken(token, readUnsubscribeSecrets(env)) ? { recipientKey: token.recipientKey, link: { scheme: "signed" } } : null;
87
93
  }
88
94
  const legacy = getMailingOptions(ctx.config).legacyUnsubscribe;
89
95
  if (legacy === undefined) return null;
90
96
  const address: unknown = await legacy.verify(link.values, ctx);
91
- return typeof address === "string" && address.trim() !== "" ? getRecipientKey(address) : null;
97
+ if (typeof address !== "string" || address.trim() === "") return null;
98
+ return { recipientKey: getRecipientKey(address), link: { scheme: "legacy", values: link.values } };
92
99
  }
@@ -117,8 +117,9 @@ export interface LinkParams {
117
117
  /**
118
118
  * The link in a query or a form. A link with the recipient parameter (`r`) is a signed link, whatever else it
119
119
  * carries, so a crafted URL cannot route a signed link to the app's legacy check. Without it, and with
120
- * `mailing({ legacyUnsubscribe })`, a link that has every legacy parameter (non-empty, at most 512 characters) is
121
- * a legacy link. Anything else is `null`. Checks no signature: `unsubscribe` does.
120
+ * `mailing({ legacyUnsubscribe })`, a link that has every required legacy parameter (non-empty, at most 512
121
+ * characters) is a legacy link; its values also hold the optional parameters it has (an empty one is absent, one
122
+ * over 512 characters makes the link invalid). Anything else is `null`. Checks no signature: `unsubscribe` does.
122
123
  */
123
124
  export function readUnsubscribeLink(params: LinkParams, config: SoftureConfig): UnsubscribeLink | null {
124
125
  if (params.get(RECIPIENT_PARAM) !== null && params.get(RECIPIENT_PARAM) !== undefined) {
@@ -128,11 +129,17 @@ export function readUnsubscribeLink(params: LinkParams, config: SoftureConfig):
128
129
  const legacy = getMailingOptions(config).legacyUnsubscribe;
129
130
  if (legacy === undefined) return null;
130
131
  const values: Record<string, string> = {};
131
- for (const name of legacy.params) {
132
+ for (const name of legacy.params.required) {
132
133
  const value = params.get(name);
133
134
  if (typeof value !== "string" || value === "" || value.length > MAX_LEGACY_VALUE_LENGTH) return null;
134
135
  values[name] = value;
135
136
  }
137
+ for (const name of legacy.params.optional) {
138
+ const value = params.get(name);
139
+ if (typeof value !== "string" || value === "") continue;
140
+ if (value.length > MAX_LEGACY_VALUE_LENGTH) return null;
141
+ values[name] = value;
142
+ }
136
143
  return { scheme: "legacy", values };
137
144
  }
138
145
 
@@ -23,6 +23,7 @@ const mailSchema = z.object({
23
23
  .refine((html) => html.trim() !== "")
24
24
  .optional(),
25
25
  headers: headersSchema.optional(),
26
+ replyTo: z.string().trim().refine(isSingleAddress).optional(),
26
27
  kind: z.string().refine(isMailKind).default(TRANSACTIONAL_KIND),
27
28
  }).superRefine((mail, context) => {
28
29
  // The module writes these two on list mail; a second, unsigned copy would confuse clients.
@@ -41,6 +42,8 @@ export interface ValidMail {
41
42
  readonly text: string;
42
43
  readonly html: string | null;
43
44
  readonly headers: Readonly<Record<string, string>>;
45
+ /** The mail's own reply-to; null to use the configured one. */
46
+ readonly replyTo: string | null;
44
47
  /** `transactional` or a list kind. */
45
48
  readonly kind: string;
46
49
  readonly idempotencyKey: string | null;
@@ -50,7 +53,7 @@ export type MailValidation = { readonly ok: true; readonly value: ValidMail } |
50
53
 
51
54
  /**
52
55
  * Checks one mail and its send options. `fields` lists what failed (`to`, `subject`, `text`,
53
- * `html`, `headers`, `kind`, `idempotencyKey`, or `mail` when the input is not an object).
56
+ * `html`, `headers`, `replyTo`, `kind`, `idempotencyKey`, or `mail` when the input is not an object).
54
57
  */
55
58
  export function validateMail(mail: unknown, options: unknown = {}): MailValidation {
56
59
  const parsedMail = mailSchema.safeParse(mail);
@@ -60,9 +63,9 @@ export function validateMail(mail: unknown, options: unknown = {}): MailValidati
60
63
  const fields = new Set(issues.map((issue) => (issue.path.length === 0 ? "mail" : String(issue.path[0]))));
61
64
  return { ok: false, fields: [...fields] };
62
65
  }
63
- const { to, subject, text, html, headers, kind } = parsedMail.data;
66
+ const { to, subject, text, html, headers, replyTo, kind } = parsedMail.data;
64
67
  return {
65
68
  ok: true,
66
- value: { to, subject, text, html: html ?? null, headers: headers ?? {}, kind, idempotencyKey: parsedOptions.data.idempotencyKey ?? null },
69
+ value: { to, subject, text, html: html ?? null, headers: headers ?? {}, replyTo: replyTo ?? null, kind, idempotencyKey: parsedOptions.data.idempotencyKey ?? null },
67
70
  };
68
71
  }