@softure-ai/mailing 0.2.0 → 0.2.2

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 (70) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +85 -6
  3. package/dist/contract.d.ts +13 -1
  4. package/dist/contract.d.ts.map +1 -1
  5. package/dist/contract.js.map +1 -1
  6. package/dist/index.d.ts +4 -2
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +3 -3
  9. package/dist/index.js.map +1 -1
  10. package/dist/jobs/delivery-job.d.ts +72 -0
  11. package/dist/jobs/delivery-job.d.ts.map +1 -0
  12. package/dist/jobs/delivery-job.js +85 -0
  13. package/dist/jobs/delivery-job.js.map +1 -0
  14. package/dist/jobs/index.d.ts +2 -0
  15. package/dist/jobs/index.d.ts.map +1 -0
  16. package/dist/jobs/index.js +4 -0
  17. package/dist/jobs/index.js.map +1 -0
  18. package/dist/messages/en.d.ts +2 -0
  19. package/dist/messages/en.d.ts.map +1 -1
  20. package/dist/messages/en.js +2 -0
  21. package/dist/messages/en.js.map +1 -1
  22. package/dist/messages/index.d.ts +4 -2
  23. package/dist/messages/index.d.ts.map +1 -1
  24. package/dist/messages/index.js.map +1 -1
  25. package/dist/next/actions.d.ts.map +1 -1
  26. package/dist/next/actions.js +8 -26
  27. package/dist/next/actions.js.map +1 -1
  28. package/dist/next/page-submit.d.ts +12 -0
  29. package/dist/next/page-submit.d.ts.map +1 -0
  30. package/dist/next/page-submit.js +30 -0
  31. package/dist/next/page-submit.js.map +1 -0
  32. package/dist/next/pages.d.ts.map +1 -1
  33. package/dist/next/pages.js +8 -5
  34. package/dist/next/pages.js.map +1 -1
  35. package/dist/next/route.d.ts +4 -1
  36. package/dist/next/route.d.ts.map +1 -1
  37. package/dist/next/route.js +58 -2
  38. package/dist/next/route.js.map +1 -1
  39. package/dist/next/strip-link-query.d.ts +11 -0
  40. package/dist/next/strip-link-query.d.ts.map +1 -0
  41. package/dist/next/strip-link-query.js +25 -0
  42. package/dist/next/strip-link-query.js.map +1 -0
  43. package/dist/providers/resend.d.ts +5 -0
  44. package/dist/providers/resend.d.ts.map +1 -1
  45. package/dist/providers/resend.js +23 -3
  46. package/dist/providers/resend.js.map +1 -1
  47. package/dist/server/deliveries.d.ts +6 -1
  48. package/dist/server/deliveries.d.ts.map +1 -1
  49. package/dist/server/deliveries.js +3 -2
  50. package/dist/server/deliveries.js.map +1 -1
  51. package/dist/server/send-mail.d.ts +2 -1
  52. package/dist/server/send-mail.d.ts.map +1 -1
  53. package/dist/server/send-mail.js +17 -5
  54. package/dist/server/send-mail.js.map +1 -1
  55. package/module.json +2 -2
  56. package/package.json +12 -1
  57. package/src/contract.ts +14 -3
  58. package/src/index.ts +4 -3
  59. package/src/jobs/delivery-job.ts +129 -0
  60. package/src/jobs/index.ts +12 -0
  61. package/src/messages/en.ts +2 -0
  62. package/src/messages/index.ts +2 -2
  63. package/src/next/actions.ts +8 -24
  64. package/src/next/page-submit.ts +35 -0
  65. package/src/next/pages.tsx +9 -5
  66. package/src/next/route.ts +59 -4
  67. package/src/next/strip-link-query.tsx +30 -0
  68. package/src/providers/resend.ts +22 -3
  69. package/src/server/deliveries.ts +9 -5
  70. package/src/server/send-mail.ts +18 -5
@@ -1 +1 @@
1
- {"version":3,"file":"send-mail.js","sourceRoot":"","sources":["../../src/server/send-mail.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"send-mail.js","sourceRoot":"","sources":["../../src/server/send-mail.ts"],"names":[],"mappings":"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"}
package/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "mailing",
3
- "version": "0.2.0",
4
- "dependsOn": {},
3
+ "version": "0.2.2",
4
+ "dependsOn": { "jobs": "*?" },
5
5
  "dbSchema": "mailing",
6
6
  "tables": ["suppressions", "campaigns", "deliveries"],
7
7
  "env": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softure-ai/mailing",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "SOFTURE AI mailing module: sendMail over a provider adapter (Resend first) with plain-text and HTML bodies, sender and reply-to from config, idempotency keys, a timeout and a typed result; signed one-click unsubscribe (RFC 8058) with a suppression list for list mail; an exactly-once delivery ledger, campaigns sent from a content file by the softure-mail command, and a sender DNS check; a fake provider for tests.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -54,6 +54,11 @@
54
54
  "@softure-ai/source": "./src/cli/index.ts",
55
55
  "types": "./dist/cli/index.d.ts",
56
56
  "default": "./dist/cli/index.js"
57
+ },
58
+ "./jobs": {
59
+ "@softure-ai/source": "./src/jobs/index.ts",
60
+ "types": "./dist/jobs/index.d.ts",
61
+ "default": "./dist/jobs/index.js"
57
62
  }
58
63
  },
59
64
  "scripts": {
@@ -65,11 +70,17 @@
65
70
  "zod": "^4.6.5"
66
71
  },
67
72
  "peerDependencies": {
73
+ "@softure-ai/jobs": "^0.1.0",
68
74
  "@softure-ai/ui": "^0.3.0",
69
75
  "drizzle-orm": "^0.45.2",
70
76
  "next": "^16.0.0",
71
77
  "react": "^19.0.0"
72
78
  },
79
+ "peerDependenciesMeta": {
80
+ "@softure-ai/jobs": {
81
+ "optional": true
82
+ }
83
+ },
73
84
  "devDependencies": {
74
85
  "@softure-ai/ui": "^0.3.0"
75
86
  }
package/src/contract.ts CHANGED
@@ -20,6 +20,13 @@ export type MailingErrorCode =
20
20
  /** The account's sending quota is spent (HTTP 429 that is not a rate limit). Stop sending; retry when it renews. */
21
21
  | "mailing.quota_exceeded";
22
22
 
23
+ /** The codes a delivery job fails or retries with that are not a provider's answer (`@softure-ai/mailing/jobs`). */
24
+ export type DeliveryJobErrorCode =
25
+ /** Another sender holds a fresh claim on the delivery; the job looks again once that claim could be stale. */
26
+ | "mailing.delivery_in_flight"
27
+ /** A claim older than the provider keeps its idempotency key is open: the mail may have gone out. Not retried. */
28
+ | "mailing.delivery_uncertain";
29
+
23
30
  /** The codes that are about the sending account, not the mail: a run of sends stops at the first one. */
24
31
  export const HALTING_ERROR_CODES: ReadonlySet<MailingErrorCode> = new Set(["mailing.provider_refused", "mailing.quota_exceeded"]);
25
32
 
@@ -149,8 +156,11 @@ export interface SentMail {
149
156
  readonly provider: string;
150
157
  }
151
158
 
152
- /** A failed send: the code, and the provider's HTTP status when it answered with one. */
153
- export type SendMailFailure = Err<MailingErrorCode> & { readonly httpStatus?: number };
159
+ /**
160
+ * A failed send: the code, the provider's HTTP status when it answered with one, and how many seconds it asked to
161
+ * wait before the next try when it said so (`Retry-After`).
162
+ */
163
+ export type SendMailFailure = Err<MailingErrorCode> & { readonly httpStatus?: number; readonly retryAfterSeconds?: number };
154
164
 
155
165
  export type SendMailResult = Ok<SentMail> | SendMailFailure;
156
166
 
@@ -169,10 +179,11 @@ export interface ProviderMessage {
169
179
  /**
170
180
  * What a provider answers. `httpStatus` is logged and returned with the failure; the mail never is. `refused` is the
171
181
  * provider refusing the sender (key, account), `quota_exceeded` a spent sending quota; `rejected` is about this mail.
182
+ * `retryAfterSeconds` is the wait the provider asked for (`Retry-After`), a whole number of seconds, 0 or more.
172
183
  */
173
184
  export type ProviderOutcome =
174
185
  | { readonly status: "sent"; readonly id: string }
175
- | { readonly status: ProviderFailureStatus; readonly httpStatus?: number };
186
+ | { readonly status: ProviderFailureStatus; readonly httpStatus?: number; readonly retryAfterSeconds?: number };
176
187
 
177
188
  export type ProviderFailureStatus = "rejected" | "unavailable" | "refused" | "quota_exceeded";
178
189
 
package/src/index.ts CHANGED
@@ -17,8 +17,8 @@ export const MODULE_ID = "mailing";
17
17
  export const mailing = defineModule({
18
18
  manifest: {
19
19
  id: MODULE_ID,
20
- version: "0.2.0",
21
- dependsOn: {},
20
+ version: "0.2.2",
21
+ dependsOn: { jobs: "*?" },
22
22
  dbSchema: "mailing",
23
23
  tables: ["suppressions", "campaigns", "deliveries"],
24
24
  env: [
@@ -69,6 +69,7 @@ export type {
69
69
  CampaignRecipient,
70
70
  CampaignRecipientFilter,
71
71
  CampaignRecipientSource,
72
+ DeliveryJobErrorCode,
72
73
  LegacyUnsubscribe,
73
74
  LegacyUnsubscribeParams,
74
75
  MailingErrorCode,
@@ -89,5 +90,5 @@ export type {
89
90
  } from "./contract.js";
90
91
  export { getMailingErrorMessage, mailingMessages, type MailingMessages } from "./messages/index.js";
91
92
  export { DEFAULT_STALE_CLAIM_MS, DEFAULT_TIMEOUT_MS, DEFAULT_UNCERTAIN_CLAIM_MS, type MailingOptions, type MailingOptionsInput } from "./options.js";
92
- export { resend, RESEND_API_KEY_ENV, RESEND_ENDPOINT, type ResendOptions } from "./providers/resend.js";
93
+ export { readRetryAfter, resend, RESEND_API_KEY_ENV, RESEND_ENDPOINT, type ResendOptions } from "./providers/resend.js";
93
94
  export { campaigns, deliveries, mailingSchema, suppressions, type DeliveryStatus } from "./schema.js";
@@ -0,0 +1,129 @@
1
+ // Deliveries through @softure-ai/jobs: the app enqueues a delivery in its own transaction and the jobs worker sends
2
+ // it, retrying while the provider is unavailable. The job runs at least once; the delivery ledger and its idempotency
3
+ // key make the mail go out once. The job payload holds the ledger scope and the app's ids, never the mail: the
4
+ // handler builds the mail from the app's data when it runs.
5
+ import { err, ok, type Ok } from "@softure-ai/core";
6
+ import { defineJobHandler, failJob, retryJobLater, type JobFailure, type JobHandler, type JobHandlerContext } from "@softure-ai/jobs";
7
+ import { enqueueJob, type EnqueuedJob, type JobsContext } from "@softure-ai/jobs/server";
8
+ import { z } from "zod";
9
+ import type { OutgoingMail } from "../contract.js";
10
+ export type { DeliveryJobErrorCode } from "../contract.js";
11
+
12
+ /** What a delivery job returns: done, or a failure the jobs worker retries or fails. */
13
+ export type DeliveryJobResult = Ok<undefined> | JobFailure;
14
+ import { deliverOnce, isDeliveryScope, type DeliverOptions, type DeliveryOutcome } from "../server/deliveries.js";
15
+ import { getMailingOptions } from "../server/options.js";
16
+
17
+ export interface EnqueueDeliveryInput<TPayload> {
18
+ /** A queue defined in `jobs({ queues })` whose handler is a `defineDeliveryJobHandler`. */
19
+ readonly queue: string;
20
+ /**
21
+ * The ledger scope of the mail (`deliverOnce`'s `scope`), e.g. `auth.password-reset:<request id>`. Also the job's
22
+ * dedup key: while a job for this scope is pending or running, a second enqueue returns it.
23
+ */
24
+ readonly scope: string;
25
+ /** The app's ids the handler builds the mail from. JSON; no addresses, names or tokens. */
26
+ readonly payload: TPayload;
27
+ /** When the job becomes due; default now. Not together with `delaySeconds`. */
28
+ readonly runAt?: Date;
29
+ readonly delaySeconds?: number;
30
+ /** Overrides the queue's `maxAttempts` for this job. */
31
+ readonly maxAttempts?: number;
32
+ }
33
+
34
+ /**
35
+ * Enqueues a delivery. Pass the caller's transaction as `ctx.db`, so the job exists exactly when the caller's own
36
+ * change commits. Throws for a malformed scope, and like `enqueueJob` (an unknown queue, a database failure).
37
+ */
38
+ export async function enqueueDelivery<TPayload>(ctx: JobsContext, input: EnqueueDeliveryInput<TPayload>): Promise<EnqueuedJob> {
39
+ if (!isDeliveryScope(input.scope)) {
40
+ throw new Error(`@softure-ai/mailing: enqueueDelivery scope "${input.scope}" must be lowercase letters, digits and ._:- (at most 128 characters)`);
41
+ }
42
+ return enqueueJob(ctx, {
43
+ queue: input.queue,
44
+ payload: { scope: input.scope, payload: input.payload },
45
+ dedupKey: input.scope,
46
+ ...(input.runAt === undefined ? {} : { runAt: input.runAt }),
47
+ ...(input.delaySeconds === undefined ? {} : { delaySeconds: input.delaySeconds }),
48
+ ...(input.maxAttempts === undefined ? {} : { maxAttempts: input.maxAttempts }),
49
+ });
50
+ }
51
+
52
+ /** What `buildMail` receives besides the app's payload: the job's context and the delivery's scope. */
53
+ export interface DeliveryJobContext extends JobHandlerContext {
54
+ readonly scope: string;
55
+ }
56
+
57
+ export interface DeliveryJobHandlerOptions<TSchema extends z.ZodType> {
58
+ /** The schema of the app's payload (the `payload` given to `enqueueDelivery`). */
59
+ readonly payload: TSchema;
60
+ /**
61
+ * Builds the mail from the app's data. Called on every attempt, before the ledger is read, so it must have no side
62
+ * effects and return the same mail each time: a different body under the same idempotency key is refused by the
63
+ * provider. Return `null` when there is nothing to send any more (the account is gone); the job then succeeds.
64
+ */
65
+ readonly buildMail: (payload: z.output<TSchema>, context: DeliveryJobContext) => Promise<OutgoingMail | null>;
66
+ /** The ledger's claim windows; `maxAttempts` is the job's, so the ledger does not count attempts here. */
67
+ readonly deliverOptions?: Omit<DeliverOptions, "maxAttempts">;
68
+ }
69
+
70
+ /**
71
+ * A jobs handler that sends one delivery through the ledger (`deliverOnce`) and turns its outcome into the job's
72
+ * (`getDeliveryJobResult`). Register it for the queue `enqueueDelivery` writes to:
73
+ *
74
+ * export const handlers: JobHandlers = {
75
+ * "mail.welcome": defineDeliveryJobHandler({ payload: z.object({ userId: z.string() }), buildMail: buildWelcomeMail }),
76
+ * };
77
+ */
78
+ export function defineDeliveryJobHandler<TSchema extends z.ZodType>(
79
+ options: DeliveryJobHandlerOptions<TSchema>,
80
+ ): JobHandler<{ scope: string; payload: z.output<TSchema> }> {
81
+ // The cast keeps the payload's own output type, which zod's object inference widens for a generic schema.
82
+ const schema = z.object({
83
+ scope: z.string().refine(isDeliveryScope),
84
+ payload: options.payload,
85
+ }) as unknown as z.ZodType<{ scope: string; payload: z.output<TSchema> }>;
86
+ return defineJobHandler({
87
+ payload: schema,
88
+ run: async ({ scope, payload }, context) => {
89
+ const mail = await options.buildMail(payload, { ...context, scope });
90
+ if (mail === null) return ok();
91
+ const outcome = await deliverOnce(context, { scope, mail }, { ...options.deliverOptions, maxAttempts: null });
92
+ const staleClaimMs = options.deliverOptions?.staleClaimMs ?? getMailingOptions(context.config).staleClaimMs;
93
+ return getDeliveryJobResult(outcome, { staleClaimMs });
94
+ },
95
+ });
96
+ }
97
+
98
+ /**
99
+ * Turns a `deliverOnce` outcome into a job result, for an app that writes its own handler:
100
+ *
101
+ * - `sent`, `done`: done.
102
+ * - `rejected` (invalid, suppressed, refused mail): failed at once with its reason.
103
+ * - `retry-later`, `halted` (the provider is down, the account refused or out of quota): retried after the provider's
104
+ * `Retry-After`, else after the queue's backoff.
105
+ * - `in-flight`: retried once the other sender's claim could be stale (`staleClaimMs`).
106
+ * - `uncertain`: failed with `mailing.delivery_uncertain`; the operator decides whether to send it again.
107
+ */
108
+ export function getDeliveryJobResult(outcome: DeliveryOutcome, options: { readonly staleClaimMs: number }): DeliveryJobResult {
109
+ switch (outcome.status) {
110
+ case "sent":
111
+ case "done":
112
+ return ok();
113
+ case "rejected":
114
+ return failJob(outcome.reason);
115
+ case "retry-later":
116
+ return retryAfter("mailing.unavailable", outcome.retryAfterSeconds);
117
+ case "halted":
118
+ return retryAfter(outcome.reason, outcome.retryAfterSeconds);
119
+ case "in-flight":
120
+ // One second past the window: a claim exactly `staleClaimMs` old is still fresh.
121
+ return retryJobLater("mailing.delivery_in_flight", Math.ceil(options.staleClaimMs / 1000) + 1);
122
+ case "uncertain":
123
+ return failJob("mailing.delivery_uncertain");
124
+ }
125
+ }
126
+
127
+ function retryAfter(code: "mailing.unavailable" | "mailing.provider_refused" | "mailing.quota_exceeded", seconds: number | undefined): DeliveryJobResult {
128
+ return seconds === undefined ? err(code) : retryJobLater(code, seconds);
129
+ }
@@ -0,0 +1,12 @@
1
+ // The jobs adapter of @softure-ai/mailing: deliveries sent by the @softure-ai/jobs worker, with retries (an optional
2
+ // peer dependency; only apps that import this entry need it).
3
+ export {
4
+ defineDeliveryJobHandler,
5
+ enqueueDelivery,
6
+ getDeliveryJobResult,
7
+ type DeliveryJobContext,
8
+ type DeliveryJobErrorCode,
9
+ type DeliveryJobHandlerOptions,
10
+ type DeliveryJobResult,
11
+ type EnqueueDeliveryInput,
12
+ } from "./delivery-job.js";
@@ -7,6 +7,8 @@ export const en = {
7
7
  suppressed: "This address unsubscribed from these emails, so nothing was sent.",
8
8
  provider_refused: "The mail service refused our account, so nothing was sent. We need to fix it on our side.",
9
9
  quota_exceeded: "We reached our sending limit for now. Try again later.",
10
+ delivery_in_flight: "This message is being sent right now.",
11
+ delivery_uncertain: "This message may already have been sent. Check before sending it again.",
10
12
  },
11
13
  },
12
14
  footer: {
@@ -1,4 +1,4 @@
1
- import type { MailingErrorCode } from "../contract.js";
1
+ import type { DeliveryJobErrorCode, MailingErrorCode } from "../contract.js";
2
2
  import { en } from "./en.js";
3
3
 
4
4
  /** The built-in English dictionary; an app passes partial overrides or its own translation per locale. */
@@ -7,7 +7,7 @@ export const mailingMessages = { en };
7
7
  export type MailingMessages = typeof en;
8
8
 
9
9
  /** The copy for a mailing error code; a code the dictionary does not know reads as `unavailable`. */
10
- export function getMailingErrorMessage(messages: MailingMessages, code: MailingErrorCode): string {
10
+ export function getMailingErrorMessage(messages: MailingMessages, code: MailingErrorCode | DeliveryJobErrorCode): string {
11
11
  const group: Readonly<Record<string, string>> = messages.errors.mailing;
12
12
  const name = code.slice("mailing.".length);
13
13
  return (Object.hasOwn(group, name) ? group[name] : undefined) ?? messages.errors.mailing.unavailable;
@@ -1,34 +1,18 @@
1
1
  "use server";
2
2
 
3
- // The unsubscribe page's server action. It takes the signed link from the form (the same values
4
- // the page got in its URL), records the opt-out and redirects to the page's outcome. No session is
5
- // involved: the signature is the authorization. Next refuses an action whose Origin does not match
6
- // the host.
7
- import { errorLogLabel } from "@softure-ai/core";
3
+ // The unsubscribe server action, for an app whose own form posts to it: it takes the signed link
4
+ // from the form body, records the opt-out and redirects to the page's outcome. The package page
5
+ // does not use it: a server action posts to the page's URL, which carries the link, so the page's
6
+ // form posts to the one-click route's bare path instead (route.ts). Next refuses an action whose
7
+ // Origin does not match the host.
8
8
  import { getSoftureConfig } from "@softure-ai/core/next";
9
9
  import { redirect } from "next/navigation";
10
- import { getMailingRoutes } from "../server/options.js";
11
- import { unsubscribe } from "../server/suppressions.js";
12
- import { getUnsubscribeLinkParams, readUnsubscribeLink } from "../server/unsubscribe-link.js";
10
+ import { readUnsubscribeLink } from "../server/unsubscribe-link.js";
13
11
  import { getMailingContext } from "./context.js";
14
- import { UNSUBSCRIBE_STATUS_PARAM, type UnsubscribeStatus } from "./params.js";
12
+ import { submitPageUnsubscribe } from "./page-submit.js";
15
13
 
16
14
  export async function unsubscribeAction(formData: FormData): Promise<void> {
17
15
  const config = getSoftureConfig();
18
16
  const link = readUnsubscribeLink(formData, config);
19
- let status: UnsubscribeStatus;
20
- try {
21
- const result = await unsubscribe(await getMailingContext(config), link, "page");
22
- status = result.ok ? "done" : "invalid";
23
- } catch (error) {
24
- // The label names the kind of failure, never its text: that can carry the link's values.
25
- console.error(`@softure-ai/mailing: unsubscribe failed: ${errorLogLabel(error)}`);
26
- status = "failed";
27
- }
28
- const query = new URLSearchParams({ [UNSUBSCRIBE_STATUS_PARAM]: status });
29
- if (status === "failed" && link !== null) {
30
- // The form comes back with the same link, so the person can try again.
31
- for (const [name, value] of Object.entries(getUnsubscribeLinkParams(link))) query.set(name, value);
32
- }
33
- redirect(`${getMailingRoutes(config).unsubscribe}?${query.toString()}`);
17
+ redirect(await submitPageUnsubscribe({ config, link, getContext: () => getMailingContext(config) }));
34
18
  }
@@ -0,0 +1,35 @@
1
+ // The page's submit, shared by the route (the package page's form posts there) and the server
2
+ // action (for an app's own form): record the opt-out and name the page's outcome to send the
3
+ // browser to. No session is involved: the signature is the authorization.
4
+ import { errorLogLabel, type SoftureConfig } from "@softure-ai/core";
5
+ import { getMailingRoutes } from "../server/options.js";
6
+ import { unsubscribe, type SuppressionContext } from "../server/suppressions.js";
7
+ import { getUnsubscribeLinkParams, type UnsubscribeLink } from "../server/unsubscribe-link.js";
8
+ import { UNSUBSCRIBE_STATUS_PARAM, type UnsubscribeStatus } from "./params.js";
9
+
10
+ export interface PageSubmit {
11
+ readonly config: SoftureConfig;
12
+ readonly link: UnsubscribeLink | null;
13
+ /** Opens the database; a failure there is a failed submit like any other. */
14
+ readonly getContext: () => Promise<SuppressionContext>;
15
+ }
16
+
17
+ /** Records the link from the page and returns the page's outcome URL (a path with its query). */
18
+ export async function submitPageUnsubscribe({ config, link, getContext }: PageSubmit): Promise<string> {
19
+ let status: UnsubscribeStatus;
20
+ try {
21
+ const result = await unsubscribe(await getContext(), link, "page");
22
+ status = result.ok ? "done" : "invalid";
23
+ } catch (error) {
24
+ // The label names the kind of failure, never its text: that can carry the link's values.
25
+ console.error(`@softure-ai/mailing: unsubscribe failed: ${errorLogLabel(error)}`);
26
+ status = "failed";
27
+ }
28
+ const query = new URLSearchParams({ [UNSUBSCRIBE_STATUS_PARAM]: status });
29
+ if (status === "failed" && link !== null) {
30
+ // The form comes back with the same link, so the person can try again; the page strips it
31
+ // from the address bar again.
32
+ for (const [name, value] of Object.entries(getUnsubscribeLinkParams(link))) query.set(name, value);
33
+ }
34
+ return `${getMailingRoutes(config).unsubscribe}?${query.toString()}`;
35
+ }
@@ -1,17 +1,20 @@
1
1
  // The unsubscribe page, ready to mount with one line:
2
2
  // `export { UnsubscribePage as default } from "@softure-ai/mailing/next"`.
3
- // A server component without client JavaScript: the button is a plain form posting a server
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. It takes the
3
+ // A server component: the button is a plain form posting the link, in hidden fields, to the
4
+ // one-click route's bare path (route.ts), so it works without JavaScript in any browser a mail
5
+ // client opens and the signed token never sits in a POST's request line. With JavaScript a small
6
+ // client component also takes the link out of the address bar. Opening the page changes nothing
7
+ // (mail scanners open links too) and does not check the signature: the submit does. It takes the
6
8
  // module's signed links and, with `mailing({ legacyUnsubscribe })`, the app's older ones: mount it
7
9
  // at the old links' path too.
8
10
  import { getSoftureConfig } from "@softure-ai/core/next";
9
11
  import { Button, Card, createSlotClassGetter, FormError, type ClassNames } from "@softure-ai/ui";
10
12
  import type { ComponentType, ReactNode } from "react";
13
+ import { getMailingRoutes } from "../server/options.js";
11
14
  import { getUnsubscribeLinkParams, readUnsubscribeLink } from "../server/unsubscribe-link.js";
12
- import { unsubscribeAction } from "./actions.js";
13
15
  import { getMailingMessages } from "./messages.js";
14
16
  import { UNSUBSCRIBE_STATUS_PARAM } from "./params.js";
17
+ import { StripLinkQuery } from "./strip-link-query.js";
15
18
 
16
19
  type SearchParams = Promise<Record<string, string | string[] | undefined>>;
17
20
 
@@ -111,7 +114,8 @@ async function renderUnsubscribePage(searchParams: SearchParams | undefined, opt
111
114
  const submit = slot("submit");
112
115
  return (
113
116
  <UnsubscribeLayout title={messages.title} lead={messages.lead} slot={slot} options={options}>
114
- <form action={unsubscribeAction} className={slot("form")}>
117
+ <StripLinkQuery names={Object.keys(linkParams)} />
118
+ <form action={getMailingRoutes(config).oneClick} method="post" className={slot("form")}>
115
119
  {Object.entries(linkParams).map(([name, value]) => (
116
120
  <input key={name} type="hidden" name={name} value={value} />
117
121
  ))}
package/src/next/route.ts CHANGED
@@ -8,27 +8,44 @@
8
8
  // purpose: a few provider addresses act for millions of people, so a bucket would refuse real
9
9
  // unsubscribes. The link is checked by shape and signature before the database is touched, and
10
10
  // the only write is an idempotent insert.
11
- import { errorLogLabel } from "@softure-ai/core";
11
+ //
12
+ // The package page's form posts here too, to the bare path with the link in its body, so the
13
+ // signed token never sits in a POST's request line (a server action would post to the page's URL,
14
+ // query included). No Origin check, as for a mail client: the signature is the authorization.
15
+ import { errorLogLabel, type SoftureConfig } from "@softure-ai/core";
12
16
  import { getSoftureConfig } from "@softure-ai/core/next";
13
17
  import { getMailingOptions, getMailingRoutes } from "../server/options.js";
14
18
  import { unsubscribe } from "../server/suppressions.js";
15
- import { readUnsubscribeLink } from "../server/unsubscribe-link.js";
19
+ import { readUnsubscribeLink, type UnsubscribeLink } from "../server/unsubscribe-link.js";
16
20
  import { getMailingContext } from "./context.js";
21
+ import { submitPageUnsubscribe } from "./page-submit.js";
17
22
 
18
23
  const NO_STORE = { "cache-control": "no-store" };
19
24
 
20
25
  /**
21
- * Records the opt-out of the link in the URL. The body (`List-Unsubscribe=One-Click`) is not read:
26
+ * Records the opt-out of the link in the URL. Its body (`List-Unsubscribe=One-Click`) is not read:
22
27
  * anyone can send it, the signature is the proof. 200 once recorded (also again), 400 for a link
23
28
  * that does not verify (200 with `mailing({ oneClickInvalidLinkStatus: 200 })`, so the answer never
24
29
  * tells whether a token is live), 500 when the database fails: the client may retry, and 200 would
25
30
  * claim an opt-out that did not happen. A legacy link (`mailing({ legacyUnsubscribe })`) is handled the same
26
31
  * way; mount this route at the old link's path too.
32
+ *
33
+ * A POST without a link in the URL is the unsubscribe page's form: its link comes from the form
34
+ * body, and the answer is a 303 to the page's outcome (`?status=done`, `invalid` or `failed`).
27
35
  */
28
36
  export async function postUnsubscribeRoute(request: Request): Promise<Response> {
37
+ const config = getSoftureConfig();
38
+ const { searchParams } = new URL(request.url);
39
+ if (readUnsubscribeLink(searchParams, config) === null) {
40
+ const formLink = await readFormLink(request, config);
41
+ if (formLink !== null) {
42
+ const location = await submitPageUnsubscribe({ config, link: formLink, getContext: () => getMailingContext(config) });
43
+ return new Response(null, { status: 303, headers: { ...NO_STORE, location } });
44
+ }
45
+ }
29
46
  try {
30
47
  const ctx = await getMailingContext();
31
- const link = readUnsubscribeLink(new URL(request.url).searchParams, ctx.config);
48
+ const link = readUnsubscribeLink(searchParams, ctx.config);
32
49
  const result = await unsubscribe(ctx, link, "one-click");
33
50
  return new Response(null, { status: result.ok ? 200 : getMailingOptions(ctx.config).oneClickInvalidLinkStatus, headers: NO_STORE });
34
51
  } catch (error) {
@@ -46,3 +63,41 @@ export function getUnsubscribeRoute(request: Request): Response {
46
63
  const { search } = new URL(request.url);
47
64
  return new Response(null, { status: 303, headers: { ...NO_STORE, location: `${getMailingRoutes(getSoftureConfig()).unsubscribe}${search}` } });
48
65
  }
66
+
67
+ /**
68
+ * The page's form is URL-encoded and small (a signed link is about 100 bytes; a legacy one at most a few 512-character
69
+ * values). A larger body is no page submit, and is not read past this size.
70
+ */
71
+ const MAX_FORM_BYTES = 8192;
72
+ const FORM_CONTENT_TYPE = "application/x-www-form-urlencoded";
73
+
74
+ /** The link in a URL-encoded form body, or null for any other body. */
75
+ async function readFormLink(request: Request, config: SoftureConfig): Promise<UnsubscribeLink | null> {
76
+ if (request.headers.get("content-type")?.toLowerCase().startsWith(FORM_CONTENT_TYPE) !== true) return null;
77
+ const body = await readBoundedText(request, MAX_FORM_BYTES);
78
+ return body === null ? null : readUnsubscribeLink(new URLSearchParams(body), config);
79
+ }
80
+
81
+ /** The body as text, or null when it is longer than `maxBytes` (the rest is not read) or cannot be read. */
82
+ async function readBoundedText(request: Request, maxBytes: number): Promise<string | null> {
83
+ if (request.body === null) return "";
84
+ const reader = request.body.getReader();
85
+ const chunks: Uint8Array[] = [];
86
+ let size = 0;
87
+ try {
88
+ for (;;) {
89
+ const { done, value } = await reader.read();
90
+ if (done) break;
91
+ size += value.byteLength;
92
+ if (size > maxBytes) {
93
+ await reader.cancel();
94
+ return null;
95
+ }
96
+ chunks.push(value);
97
+ }
98
+ } catch {
99
+ // A client that drops the connection mid-body: no form to read.
100
+ return null;
101
+ }
102
+ return Buffer.concat(chunks).toString("utf8");
103
+ }
@@ -0,0 +1,30 @@
1
+ "use client";
2
+
3
+ // Takes the unsubscribe link's parameters out of the address bar once the page has loaded, so the
4
+ // signed token does not stay in the tab's URL and the browser history. It renders nothing; without
5
+ // JavaScript the address bar keeps the link, and the form still works.
6
+ import { useEffect } from "react";
7
+
8
+ export interface StripLinkQueryProps {
9
+ /** The query parameters that carry the link. */
10
+ readonly names: readonly string[];
11
+ }
12
+
13
+ export function StripLinkQuery({ names }: StripLinkQueryProps) {
14
+ useEffect(() => {
15
+ stripQueryParams(names);
16
+ }, [names]);
17
+ return null;
18
+ }
19
+
20
+ /**
21
+ * Replaces the current history entry with the URL minus `names`. The current `history.state` goes
22
+ * along: it carries Next's own marker, so its router lets the call through without a navigation.
23
+ */
24
+ export function stripQueryParams(names: readonly string[]): void {
25
+ const url = new URL(location.href);
26
+ const present = names.filter((name) => url.searchParams.has(name));
27
+ if (present.length === 0) return;
28
+ for (const name of present) url.searchParams.delete(name);
29
+ history.replaceState(history.state, "", `${url.pathname}${url.search}${url.hash}`);
30
+ }
@@ -77,10 +77,29 @@ async function readOutcome(response: Response): Promise<ProviderOutcome> {
77
77
  // Only the error's `name` (an enum) is read: its `message` can echo the address back.
78
78
  const name = readString(payload, "name");
79
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 };
80
+ const retryAfterSeconds = readRetryAfter(response.headers.get("retry-after"), Date.now());
81
+ const failure = retryAfterSeconds === null ? { httpStatus } : { httpStatus, retryAfterSeconds };
82
+ if (REFUSED_STATUSES.has(httpStatus)) return { status: "refused", ...failure };
83
+ if (httpStatus === 429) return { status: name === RATE_LIMIT_EXCEEDED ? "unavailable" : "quota_exceeded", ...failure };
82
84
  const isRetryable = httpStatus >= 500 || httpStatus === 408 || (httpStatus === 409 && name === CONCURRENT_IDEMPOTENT_REQUESTS);
83
- return { status: isRetryable ? "unavailable" : "rejected", httpStatus };
85
+ return { status: isRetryable ? "unavailable" : "rejected", ...failure };
86
+ }
87
+
88
+ const DELAY_SECONDS = /^\d{1,10}$/;
89
+ /** The IMF-fixdate form of an HTTP date, the one senders must produce (RFC 9110 §5.6.7). */
90
+ const HTTP_DATE = /^[A-Z][a-z]{2}, \d{2} [A-Z][a-z]{2} \d{4} \d{2}:\d{2}:\d{2} GMT$/;
91
+
92
+ /**
93
+ * Reads a `Retry-After` header (RFC 9110 §10.2.3): a whole number of seconds, or an HTTP date (IMF-fixdate) turned into the seconds
94
+ * from `nowMs` to it (0 for a date in the past). `null` when there is none or it is malformed.
95
+ */
96
+ export function readRetryAfter(value: string | null, nowMs: number): number | null {
97
+ const text = value?.trim() ?? "";
98
+ if (text === "") return null;
99
+ if (DELAY_SECONDS.test(text)) return Number(text);
100
+ const at = HTTP_DATE.test(text) ? Date.parse(text) : Number.NaN;
101
+ if (Number.isNaN(at)) return null;
102
+ return Math.max(0, Math.ceil((at - nowMs) / 1000));
84
103
  }
85
104
 
86
105
  function readString(payload: unknown, key: string): string | null {
@@ -92,13 +92,16 @@ export type DeliveryOutcome =
92
92
  * `retakeUncertain` sends it again.
93
93
  */
94
94
  | { readonly status: "uncertain" }
95
- /** The provider was unavailable; the claim is released for a later run. */
96
- | { readonly status: "retry-later"; readonly httpStatus?: number }
95
+ /**
96
+ * The provider was unavailable; the claim is released for a later run. `retryAfterSeconds` is the wait the provider
97
+ * asked for, when it named one.
98
+ */
99
+ | { readonly status: "retry-later"; readonly httpStatus?: number; readonly retryAfterSeconds?: number }
97
100
  /**
98
101
  * The provider refused the account or its quota is spent: the claim is released, its attempt given back. Stop
99
102
  * sending (every other mail fails the same way) and run again once the account can send.
100
103
  */
101
- | { readonly status: "halted"; readonly reason: HaltingErrorCode; readonly httpStatus?: number };
104
+ | { readonly status: "halted"; readonly reason: HaltingErrorCode; readonly httpStatus?: number; readonly retryAfterSeconds?: number };
102
105
 
103
106
  /**
104
107
  * Sends `delivery.mail` unless the ledger already has an outcome (or a fresh claim) for its scope
@@ -125,13 +128,14 @@ export async function deliverOnce(ctx: DeliveryContext, delivery: Delivery, opti
125
128
  }
126
129
  const providerStatus = result.httpStatus ?? null;
127
130
  const httpStatus = result.httpStatus === undefined ? {} : { httpStatus: result.httpStatus };
131
+ const retryAfter = result.retryAfterSeconds === undefined ? {} : { retryAfterSeconds: result.retryAfterSeconds };
128
132
  if (isHaltingCode(result.error)) {
129
133
  await releaseDelivery(ctx, fence, { providerStatus, giveAttemptBack: true });
130
- return { status: "halted", reason: result.error, ...httpStatus };
134
+ return { status: "halted", reason: result.error, ...httpStatus, ...retryAfter };
131
135
  }
132
136
  if (result.error === "mailing.unavailable" && (maxAttempts === null || attempt < maxAttempts)) {
133
137
  await releaseDelivery(ctx, fence, { providerStatus, giveAttemptBack: false });
134
- return { status: "retry-later", ...httpStatus };
138
+ return { status: "retry-later", ...httpStatus, ...retryAfter };
135
139
  }
136
140
  await closeDelivery(ctx, fence, { status: "rejected", providerMessageId: null, reason: result.error, providerStatus });
137
141
  return { status: "rejected", reason: result.error, ...httpStatus };