@softure-ai/mailing 0.1.14 → 0.2.1

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 (63) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +76 -7
  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 +5 -30
  23. package/dist/messages/index.d.ts.map +1 -1
  24. package/dist/messages/index.js +2 -3
  25. package/dist/messages/index.js.map +1 -1
  26. package/dist/next/messages.d.ts +1 -1
  27. package/dist/next/messages.d.ts.map +1 -1
  28. package/dist/next/messages.js +2 -1
  29. package/dist/next/messages.js.map +1 -1
  30. package/dist/providers/resend.d.ts +5 -0
  31. package/dist/providers/resend.d.ts.map +1 -1
  32. package/dist/providers/resend.js +23 -3
  33. package/dist/providers/resend.js.map +1 -1
  34. package/dist/server/deliveries.d.ts +6 -1
  35. package/dist/server/deliveries.d.ts.map +1 -1
  36. package/dist/server/deliveries.js +3 -2
  37. package/dist/server/deliveries.js.map +1 -1
  38. package/dist/server/dns.d.ts +2 -1
  39. package/dist/server/dns.d.ts.map +1 -1
  40. package/dist/server/dns.js +14 -6
  41. package/dist/server/dns.js.map +1 -1
  42. package/dist/server/send-mail.d.ts +2 -1
  43. package/dist/server/send-mail.d.ts.map +1 -1
  44. package/dist/server/send-mail.js +19 -7
  45. package/dist/server/send-mail.js.map +1 -1
  46. package/module.json +2 -2
  47. package/package.json +15 -4
  48. package/src/contract.ts +14 -3
  49. package/src/index.ts +4 -3
  50. package/src/jobs/delivery-job.ts +129 -0
  51. package/src/jobs/index.ts +12 -0
  52. package/src/messages/en.ts +2 -0
  53. package/src/messages/index.ts +4 -5
  54. package/src/next/messages.ts +2 -2
  55. package/src/providers/resend.ts +22 -3
  56. package/src/server/deliveries.ts +9 -5
  57. package/src/server/dns.ts +16 -7
  58. package/src/server/send-mail.ts +20 -7
  59. package/dist/messages/pl.d.ts +0 -3
  60. package/dist/messages/pl.d.ts.map +0 -1
  61. package/dist/messages/pl.js +0 -29
  62. package/dist/messages/pl.js.map +0 -1
  63. package/src/messages/pl.ts +0 -30
@@ -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,14 +1,13 @@
1
- import type { MailingErrorCode } from "../contract.js";
1
+ import type { DeliveryJobErrorCode, MailingErrorCode } from "../contract.js";
2
2
  import { en } from "./en.js";
3
- import { pl } from "./pl.js";
4
3
 
5
- /** Complete default dictionaries; apps pass partial overrides per locale. */
6
- export const mailingMessages = { en, pl };
4
+ /** The built-in English dictionary; an app passes partial overrides or its own translation per locale. */
5
+ export const mailingMessages = { en };
7
6
 
8
7
  export type MailingMessages = typeof en;
9
8
 
10
9
  /** The copy for a mailing error code; a code the dictionary does not know reads as `unavailable`. */
11
- export function getMailingErrorMessage(messages: MailingMessages, code: MailingErrorCode): string {
10
+ export function getMailingErrorMessage(messages: MailingMessages, code: MailingErrorCode | DeliveryJobErrorCode): string {
12
11
  const group: Readonly<Record<string, string>> = messages.errors.mailing;
13
12
  const name = code.slice("mailing.".length);
14
13
  return (Object.hasOwn(group, name) ? group[name] : undefined) ?? messages.errors.mailing.unavailable;
@@ -1,9 +1,9 @@
1
- import type { SoftureConfig } from "@softure-ai/core";
1
+ import { selectMessages, type SoftureConfig } from "@softure-ai/core";
2
2
  import type { MailingMessages } from "../messages/index.js";
3
3
  import { getMailingModule } from "../server/options.js";
4
4
 
5
5
  /** The module's copy in the app's locale, with the app's overrides applied. */
6
6
  export function getMailingMessages(config: SoftureConfig): MailingMessages {
7
7
  // The module factory merged the dictionaries; their shape is the module's own.
8
- return getMailingModule(config).messages[config.locale] as MailingMessages;
8
+ return selectMessages(getMailingModule(config).messages, config.locale) as MailingMessages;
9
9
  }
@@ -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 };
package/src/server/dns.ts CHANGED
@@ -96,7 +96,8 @@ export interface CheckSenderDnsOptions {
96
96
  readonly returnPath?: readonly ReturnPathHost[];
97
97
  /**
98
98
  * Checks that this service receives mail for the reply-to domain (else the checked domain): every MX record is the
99
- * service's, and the one SPF record includes the service's, so replies arrive and forwarding passes SPF.
99
+ * service's, and the one SPF record includes the service's and ends in `-all` or `~all`, so replies arrive,
100
+ * forwarding passes SPF and nobody else can send as the domain.
100
101
  */
101
102
  readonly inbound?: InboundService;
102
103
  readonly resolveTxt?: ResolveTxt;
@@ -334,7 +335,7 @@ function isUnder(host: string, domain: string): boolean {
334
335
  return host === domain || host.endsWith(`.${domain}`);
335
336
  }
336
337
 
337
- /** The inbound service's MX (every record its own) and SPF (exactly one record, including the service's). */
338
+ /** The inbound service's MX (every record its own) and SPF (exactly one record, including the service's, ending in `-all` or `~all`). */
338
339
  async function checkInbound(
339
340
  { lookup, lookupMx }: { readonly lookup: Lookup; readonly lookupMx: MxLookup },
340
341
  host: string,
@@ -359,9 +360,17 @@ function checkInboundSpf(txt: TxtAnswer, host: string, include: string): DnsChec
359
360
  if (records.length > 1) return { status: "fail", finding: "multiple", host, record: null };
360
361
  const record = records[0];
361
362
  if (record === undefined) return { status: "fail", finding: "missing", host, record: null };
362
- const includes = record
363
- .trim()
364
- .split(/\s+/)
365
- .some((term) => term.toLowerCase().replace(/^\+/, "").replace(/\.$/, "") === `include:${include}`);
366
- return includes ? { status: "pass", finding: "found", host, record } : { status: "fail", finding: "missing-include", host, record };
363
+ const terms = record.trim().toLowerCase().split(/\s+/);
364
+ const includes = terms.some((term) => term.replace(/^\+/, "").replace(/\.$/, "") === `include:${include}`);
365
+ if (!includes) return { status: "fail", finding: "missing-include", host, record };
366
+ return isStrictAll(terms) ? { status: "pass", finding: "found", host, record } : { status: "fail", finding: "weak", host, record };
367
+ }
368
+
369
+ /**
370
+ * Whether the first `all` (RFC 7208 §5.1: terms after it are never tested) is `-all` or `~all`. `+all`, a bare `all`
371
+ * and `?all` let anyone send as the domain, as does a record without `all` (neutral, or a `redirect=` not followed).
372
+ */
373
+ function isStrictAll(terms: readonly string[]): boolean {
374
+ const all = terms.find((term) => /^[-~+?]?all$/.test(term));
375
+ return all === "-all" || all === "~all";
367
376
  }
@@ -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 Err, type Ok, type SoftureConfig } from "@softure-ai/core";
6
+ import { err, type Err, ok, type Ok, selectMessages, type SoftureConfig } from "@softure-ai/core";
7
7
  import type { Queryable } from "@softure-ai/db";
8
8
  import {
9
9
  TRANSACTIONAL_KIND,
@@ -37,7 +37,8 @@ export interface MailContext {
37
37
  * `invalid_input` (fix the input), `rejected` (do not retry), `unavailable` (retry later with the
38
38
  * same `idempotencyKey`), `suppressed` (a list mail to someone who unsubscribed; do not retry),
39
39
  * `provider_refused` / `quota_exceeded` (the account cannot send: stop, fix or wait, then retry). A
40
- * failure the provider answered with an HTTP status carries it as `httpStatus`.
40
+ * failure the provider answered with an HTTP status carries it as `httpStatus`, and the wait it asked for
41
+ * (`Retry-After`) as `retryAfterSeconds`.
41
42
  * Throws only when the module is not enabled, or for list mail without `context.db`.
42
43
  */
43
44
  export async function sendMail(context: MailContext, mail: OutgoingMail, options: SendMailOptions = {}): Promise<SendMailResult> {
@@ -64,7 +65,11 @@ export async function sendMail(context: MailContext, mail: OutgoingMail, options
64
65
  }
65
66
  const code = FAILURE_CODES[outcome.status];
66
67
  logFailure(provider, code, { status: outcome.httpStatus });
67
- return outcome.httpStatus === undefined ? err(code) : { ...err(code), httpStatus: outcome.httpStatus };
68
+ return {
69
+ ...err(code),
70
+ ...(outcome.httpStatus === undefined ? {} : { httpStatus: outcome.httpStatus }),
71
+ ...(outcome.retryAfterSeconds === undefined ? {} : { retryAfterSeconds: outcome.retryAfterSeconds }),
72
+ };
68
73
  }
69
74
 
70
75
  /** Why `previewMail` cannot render a mail: invalid input (with the fields), or list mail without the secret. */
@@ -134,7 +139,7 @@ function composeMessage(config: SoftureConfig, mail: ValidMail, secret: string |
134
139
  if (kind === TRANSACTIONAL_KIND || secret === null) return { ...content, ...envelope };
135
140
  const links = buildUnsubscribeLinks(config, mail.to, secret);
136
141
  // The module factory merged the dictionaries; their shape is the module's own.
137
- const copy = (getMailingModule(config).messages[config.locale] as MailingMessages).footer;
142
+ const copy = (selectMessages(getMailingModule(config).messages, config.locale) as MailingMessages).footer;
138
143
  return {
139
144
  ...envelope,
140
145
  to: mail.to,
@@ -171,17 +176,25 @@ async function callProvider(provider: MailProvider, message: Parameters<MailProv
171
176
  /** A provider's answer, narrowed: anything that is not a known outcome reads as `unavailable`. */
172
177
  function readOutcome(outcome: unknown): ProviderOutcome {
173
178
  if (typeof outcome !== "object" || outcome === null) return { status: "unavailable" };
174
- const { status, id, httpStatus } = outcome as { status?: unknown; id?: unknown; httpStatus?: unknown };
179
+ const { status, id, httpStatus, retryAfterSeconds } = outcome as { status?: unknown; id?: unknown; httpStatus?: unknown; retryAfterSeconds?: unknown };
175
180
  if (status === "sent") {
176
181
  return typeof id === "string" && id !== "" ? { status, id } : { status: "unavailable" };
177
182
  }
178
183
  if (typeof status === "string" && Object.hasOwn(FAILURE_CODES, status)) {
179
- const failure = status as ProviderFailureStatus;
180
- return isHttpStatus(httpStatus) ? { status: failure, httpStatus } : { status: failure };
184
+ return {
185
+ status: status as ProviderFailureStatus,
186
+ ...(isHttpStatus(httpStatus) ? { httpStatus } : {}),
187
+ ...(isRetryAfter(retryAfterSeconds) ? { retryAfterSeconds } : {}),
188
+ };
181
189
  }
182
190
  return { status: "unavailable" };
183
191
  }
184
192
 
193
+ /** A provider's `Retry-After` in whole seconds; anything else is dropped. */
194
+ function isRetryAfter(value: unknown): value is number {
195
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
196
+ }
197
+
185
198
  function isHttpStatus(value: unknown): value is number {
186
199
  return typeof value === "number" && Number.isInteger(value) && value >= 100 && value <= 599;
187
200
  }
@@ -1,3 +0,0 @@
1
- import type { en } from "./en.js";
2
- export declare const pl: typeof en;
3
- //# sourceMappingURL=pl.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"pl.d.ts","sourceRoot":"","sources":["../../src/messages/pl.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,SAAS,CAAC;AAGlC,eAAO,MAAM,EAAE,EAAE,OAAO,EA0BvB,CAAC"}
@@ -1,29 +0,0 @@
1
- // Polish copy lives only in message dictionaries; the language gate exempts `messages/` folders.
2
- export const pl = {
3
- errors: {
4
- mailing: {
5
- invalid_input: "Tej wiadomości nie da się wysłać. Sprawdź adres i spróbuj ponownie.",
6
- rejected: "Usługa pocztowa odrzuciła tę wiadomość.",
7
- unavailable: "Usługa pocztowa nie odpowiada. Spróbuj ponownie za chwilę.",
8
- suppressed: "Ten adres wypisał się z takich wiadomości, więc nic nie wysłano.",
9
- provider_refused: "Usługa pocztowa odrzuciła nasze konto, więc nic nie wysłano. Musimy to naprawić po naszej stronie.",
10
- quota_exceeded: "Wyczerpaliśmy na razie limit wysyłki. Spróbuj ponownie później.",
11
- },
12
- },
13
- footer: {
14
- text: "Nie chcesz więcej takich wiadomości? Wypisz się tutaj:",
15
- htmlLead: "Nie chcesz więcej takich wiadomości?",
16
- htmlLink: "Wypisz się",
17
- },
18
- unsubscribe: {
19
- title: "Wypisanie",
20
- lead: "Przestaniesz dostawać od nas takie wiadomości. Poczta o Twoim koncie, na przykład reset hasła, nadal będzie przychodzić.",
21
- submit: "Wypisz mnie",
22
- doneTitle: "Wypisano",
23
- doneBody: "Nie wyślemy Ci już takich wiadomości.",
24
- invalidTitle: "Ten odnośnik nie działa",
25
- invalidBody: "Otwórz jeszcze raz odnośnik z wiadomości, w całości. Jeśli dalej nie działa, odpisz na wiadomość i poproś o wypisanie.",
26
- failed: "Nie udało się tego zapisać. Spróbuj ponownie za chwilę.",
27
- },
28
- };
29
- //# sourceMappingURL=pl.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"pl.js","sourceRoot":"","sources":["../../src/messages/pl.ts"],"names":[],"mappings":"AAEA,iGAAiG;AACjG,MAAM,CAAC,MAAM,EAAE,GAAc;IAC3B,MAAM,EAAE;QACN,OAAO,EAAE;YACP,aAAa,EAAE,qEAAqE;YACpF,QAAQ,EAAE,yCAAyC;YACnD,WAAW,EAAE,4DAA4D;YACzE,UAAU,EAAE,kEAAkE;YAC9E,gBAAgB,EAAE,oGAAoG;YACtH,cAAc,EAAE,iEAAiE;SAClF;KACF;IACD,MAAM,EAAE;QACN,IAAI,EAAE,wDAAwD;QAC9D,QAAQ,EAAE,sCAAsC;QAChD,QAAQ,EAAE,YAAY;KACvB;IACD,WAAW,EAAE;QACX,KAAK,EAAE,WAAW;QAClB,IAAI,EAAE,0HAA0H;QAChI,MAAM,EAAE,aAAa;QACrB,SAAS,EAAE,UAAU;QACrB,QAAQ,EAAE,uCAAuC;QACjD,YAAY,EAAE,yBAAyB;QACvC,WAAW,EAAE,wHAAwH;QACrI,MAAM,EAAE,yDAAyD;KAClE;CACF,CAAC"}
@@ -1,30 +0,0 @@
1
- import type { en } from "./en.js";
2
-
3
- // Polish copy lives only in message dictionaries; the language gate exempts `messages/` folders.
4
- export const pl: typeof en = {
5
- errors: {
6
- mailing: {
7
- invalid_input: "Tej wiadomości nie da się wysłać. Sprawdź adres i spróbuj ponownie.",
8
- rejected: "Usługa pocztowa odrzuciła tę wiadomość.",
9
- unavailable: "Usługa pocztowa nie odpowiada. Spróbuj ponownie za chwilę.",
10
- suppressed: "Ten adres wypisał się z takich wiadomości, więc nic nie wysłano.",
11
- provider_refused: "Usługa pocztowa odrzuciła nasze konto, więc nic nie wysłano. Musimy to naprawić po naszej stronie.",
12
- quota_exceeded: "Wyczerpaliśmy na razie limit wysyłki. Spróbuj ponownie później.",
13
- },
14
- },
15
- footer: {
16
- text: "Nie chcesz więcej takich wiadomości? Wypisz się tutaj:",
17
- htmlLead: "Nie chcesz więcej takich wiadomości?",
18
- htmlLink: "Wypisz się",
19
- },
20
- unsubscribe: {
21
- title: "Wypisanie",
22
- lead: "Przestaniesz dostawać od nas takie wiadomości. Poczta o Twoim koncie, na przykład reset hasła, nadal będzie przychodzić.",
23
- submit: "Wypisz mnie",
24
- doneTitle: "Wypisano",
25
- doneBody: "Nie wyślemy Ci już takich wiadomości.",
26
- invalidTitle: "Ten odnośnik nie działa",
27
- invalidBody: "Otwórz jeszcze raz odnośnik z wiadomości, w całości. Jeśli dalej nie działa, odpisz na wiadomość i poproś o wypisanie.",
28
- failed: "Nie udało się tego zapisać. Spróbuj ponownie za chwilę.",
29
- },
30
- };