@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.
- package/CHANGELOG.md +38 -0
- package/README.md +76 -7
- package/dist/contract.d.ts +13 -1
- package/dist/contract.d.ts.map +1 -1
- package/dist/contract.js.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/jobs/delivery-job.d.ts +72 -0
- package/dist/jobs/delivery-job.d.ts.map +1 -0
- package/dist/jobs/delivery-job.js +85 -0
- package/dist/jobs/delivery-job.js.map +1 -0
- package/dist/jobs/index.d.ts +2 -0
- package/dist/jobs/index.d.ts.map +1 -0
- package/dist/jobs/index.js +4 -0
- package/dist/jobs/index.js.map +1 -0
- package/dist/messages/en.d.ts +2 -0
- package/dist/messages/en.d.ts.map +1 -1
- package/dist/messages/en.js +2 -0
- package/dist/messages/en.js.map +1 -1
- package/dist/messages/index.d.ts +5 -30
- package/dist/messages/index.d.ts.map +1 -1
- package/dist/messages/index.js +2 -3
- package/dist/messages/index.js.map +1 -1
- package/dist/next/messages.d.ts +1 -1
- package/dist/next/messages.d.ts.map +1 -1
- package/dist/next/messages.js +2 -1
- package/dist/next/messages.js.map +1 -1
- package/dist/providers/resend.d.ts +5 -0
- package/dist/providers/resend.d.ts.map +1 -1
- package/dist/providers/resend.js +23 -3
- package/dist/providers/resend.js.map +1 -1
- package/dist/server/deliveries.d.ts +6 -1
- package/dist/server/deliveries.d.ts.map +1 -1
- package/dist/server/deliveries.js +3 -2
- package/dist/server/deliveries.js.map +1 -1
- package/dist/server/dns.d.ts +2 -1
- package/dist/server/dns.d.ts.map +1 -1
- package/dist/server/dns.js +14 -6
- package/dist/server/dns.js.map +1 -1
- package/dist/server/send-mail.d.ts +2 -1
- package/dist/server/send-mail.d.ts.map +1 -1
- package/dist/server/send-mail.js +19 -7
- package/dist/server/send-mail.js.map +1 -1
- package/module.json +2 -2
- package/package.json +15 -4
- package/src/contract.ts +14 -3
- package/src/index.ts +4 -3
- package/src/jobs/delivery-job.ts +129 -0
- package/src/jobs/index.ts +12 -0
- package/src/messages/en.ts +2 -0
- package/src/messages/index.ts +4 -5
- package/src/next/messages.ts +2 -2
- package/src/providers/resend.ts +22 -3
- package/src/server/deliveries.ts +9 -5
- package/src/server/dns.ts +16 -7
- package/src/server/send-mail.ts +20 -7
- package/dist/messages/pl.d.ts +0 -3
- package/dist/messages/pl.d.ts.map +0 -1
- package/dist/messages/pl.js +0 -29
- package/dist/messages/pl.js.map +0 -1
- 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";
|
package/src/messages/en.ts
CHANGED
|
@@ -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: {
|
package/src/messages/index.ts
CHANGED
|
@@ -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
|
-
/**
|
|
6
|
-
export const mailingMessages = { en
|
|
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;
|
package/src/next/messages.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import type
|
|
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
|
|
8
|
+
return selectMessages(getMailingModule(config).messages, config.locale) as MailingMessages;
|
|
9
9
|
}
|
package/src/providers/resend.ts
CHANGED
|
@@ -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
|
-
|
|
81
|
-
|
|
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",
|
|
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 {
|
package/src/server/deliveries.ts
CHANGED
|
@@ -92,13 +92,16 @@ export type DeliveryOutcome =
|
|
|
92
92
|
* `retakeUncertain` sends it again.
|
|
93
93
|
*/
|
|
94
94
|
| { readonly status: "uncertain" }
|
|
95
|
-
/**
|
|
96
|
-
|
|
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
|
|
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
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
}
|
package/src/server/send-mail.ts
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
|
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
|
-
|
|
180
|
-
|
|
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
|
}
|
package/dist/messages/pl.d.ts
DELETED
|
@@ -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"}
|
package/dist/messages/pl.js
DELETED
|
@@ -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
|
package/dist/messages/pl.js.map
DELETED
|
@@ -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"}
|
package/src/messages/pl.ts
DELETED
|
@@ -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
|
-
};
|