@softure-ai/mailing 0.2.3 → 0.2.5
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 +43 -0
- package/README.md +67 -9
- package/dist/attachments.d.ts +32 -0
- package/dist/attachments.d.ts.map +1 -0
- package/dist/attachments.js +68 -0
- package/dist/attachments.js.map +1 -0
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +76 -32
- package/dist/cli/run.js.map +1 -1
- package/dist/contract.d.ts +40 -1
- package/dist/contract.d.ts.map +1 -1
- package/dist/index.d.ts +8 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/jobs/delivery-job.d.ts +8 -3
- package/dist/jobs/delivery-job.d.ts.map +1 -1
- package/dist/jobs/delivery-job.js +18 -2
- package/dist/jobs/delivery-job.js.map +1 -1
- package/dist/options.d.ts +8 -0
- package/dist/options.d.ts.map +1 -1
- package/dist/options.js +28 -0
- package/dist/options.js.map +1 -1
- package/dist/providers/resend.d.ts.map +1 -1
- package/dist/providers/resend.js +10 -0
- package/dist/providers/resend.js.map +1 -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/index.d.ts +1 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/index.js.map +1 -1
- package/dist/server/operator-output.d.ts +6 -0
- package/dist/server/operator-output.d.ts.map +1 -1
- package/dist/server/operator-output.js +23 -0
- package/dist/server/operator-output.js.map +1 -1
- package/dist/server/send-mail.d.ts +10 -3
- package/dist/server/send-mail.d.ts.map +1 -1
- package/dist/server/send-mail.js +64 -18
- package/dist/server/send-mail.js.map +1 -1
- package/dist/server/validate-mail.d.ts +10 -3
- package/dist/server/validate-mail.d.ts.map +1 -1
- package/dist/server/validate-mail.js +55 -12
- package/dist/server/validate-mail.js.map +1 -1
- package/dist/testing/fake-provider.d.ts.map +1 -1
- package/dist/testing/fake-provider.js +18 -3
- package/dist/testing/fake-provider.js.map +1 -1
- package/dist/testing/outbox.d.ts.map +1 -1
- package/dist/testing/outbox.js +10 -1
- package/dist/testing/outbox.js.map +1 -1
- package/module.json +1 -1
- package/package.json +1 -1
- package/src/attachments.ts +90 -0
- package/src/cli/run.ts +86 -32
- package/src/contract.ts +42 -1
- package/src/index.ts +12 -1
- package/src/jobs/delivery-job.ts +24 -4
- package/src/options.ts +31 -0
- package/src/providers/resend.ts +10 -0
- package/src/server/deliveries.ts +3 -2
- package/src/server/index.ts +1 -1
- package/src/server/operator-output.ts +24 -0
- package/src/server/send-mail.ts +63 -19
- package/src/server/validate-mail.ts +65 -13
- package/src/testing/fake-provider.ts +18 -3
- package/src/testing/outbox.ts +11 -1
package/src/options.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// The options an app passes to `mailing({ ... })` in softure.config.ts, parsed at startup.
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { isMailbox, isMailKind, isSingleAddress } from "./address.js";
|
|
4
|
+
import { DEFAULT_ATTACHMENT_CONTENT_TYPES, DEFAULT_MAX_ATTACHMENTS, getContentTypeEssence, isContentType, MAX_ATTACHMENT_BYTES } from "./attachments.js";
|
|
4
5
|
import type { CampaignRecipientFilter, CampaignRecipientSource, LegacyUnsubscribe, MailProvider, OnUnsubscribedHook } from "./contract.js";
|
|
5
6
|
|
|
6
7
|
export const DEFAULT_TIMEOUT_MS = 10_000;
|
|
@@ -22,6 +23,13 @@ const RESERVED_LINK_PARAMS: ReadonlySet<string> = new Set(["r", "status"]);
|
|
|
22
23
|
const LINK_PARAM = /^[A-Za-z][A-Za-z0-9_-]{0,31}$/;
|
|
23
24
|
|
|
24
25
|
const MAX_LINK_PARAMS = 8;
|
|
26
|
+
|
|
27
|
+
/** A name of `mailing({ senders })`, the value of `OutgoingMail.sender`: a letter, then letters, digits, `_` or `-`. */
|
|
28
|
+
export const SENDER_NAME = /^[A-Za-z][A-Za-z0-9_-]{0,31}$/;
|
|
29
|
+
|
|
30
|
+
const sendersSchema = z
|
|
31
|
+
.record(z.string(), z.string().trim().refine(isMailbox, "must be an address or Name <address>, e.g. Alerts <alerts@example.com>"))
|
|
32
|
+
.refine((senders) => Object.keys(senders).every((name) => SENDER_NAME.test(name)), "every name must be a letter, then letters, digits, _ or -, at most 32 characters");
|
|
25
33
|
const linkParamNames = z.array(z.string().regex(LINK_PARAM, "must be a query name: a letter, then letters, digits, _ or -, at most 32 characters"));
|
|
26
34
|
|
|
27
35
|
/** Both forms of `params`, normalised to required and optional names, with the limits on all names together. */
|
|
@@ -55,12 +63,33 @@ function isMailProvider(value: unknown): value is MailProvider {
|
|
|
55
63
|
return typeof candidate.name === "string" && candidate.name !== "" && typeof candidate.send === "function";
|
|
56
64
|
}
|
|
57
65
|
|
|
66
|
+
const attachmentsOptionsSchema = z.strictObject({
|
|
67
|
+
/** Files per mail, 1 to 100. Default 10. */
|
|
68
|
+
maxCount: z.number().int().min(1).max(100).default(DEFAULT_MAX_ATTACHMENTS),
|
|
69
|
+
/** Raw bytes of all attachments of one mail, at most (and by default) 28,000,000: about 37.3 MB base64. */
|
|
70
|
+
maxTotalBytes: z.number().int().min(1).max(MAX_ATTACHMENT_BYTES).default(MAX_ATTACHMENT_BYTES),
|
|
71
|
+
/**
|
|
72
|
+
* The content types an attachment may have, e.g. `["application/pdf"]`; replaces the default list
|
|
73
|
+
* (`DEFAULT_ATTACHMENT_CONTENT_TYPES`). Compared without parameters and case.
|
|
74
|
+
*/
|
|
75
|
+
contentTypes: z
|
|
76
|
+
.array(z.string().trim().refine(isContentType, "must be a MIME type such as application/pdf"))
|
|
77
|
+
.min(1)
|
|
78
|
+
.transform((types) => [...new Set(types.map(getContentTypeEssence))])
|
|
79
|
+
.default([...DEFAULT_ATTACHMENT_CONTENT_TYPES]),
|
|
80
|
+
});
|
|
81
|
+
|
|
58
82
|
export const mailingOptionsSchema = z.strictObject({
|
|
59
83
|
/**
|
|
60
84
|
* The sender of every mail: `hello@example.com` or `Plan <hello@example.com>`. It must be on a
|
|
61
85
|
* domain the provider signs (DKIM), or DMARC rejects the mail.
|
|
62
86
|
*/
|
|
63
87
|
from: z.string().trim().refine(isMailbox, "must be an address or Name <address>, e.g. Plan <hello@example.com>"),
|
|
88
|
+
/**
|
|
89
|
+
* Named extra senders a mail picks with `OutgoingMail.sender`, e.g. `{ alerts: "App <alerts@example.com>" }`. Each
|
|
90
|
+
* is validated like `from` and must sit on a domain the provider signs; `softure-mail dns` checks every one.
|
|
91
|
+
*/
|
|
92
|
+
senders: sendersSchema.optional(),
|
|
64
93
|
/** Where replies go; may sit on another domain than `from` (DMARC does not check it). */
|
|
65
94
|
replyTo: z.string().trim().refine(isSingleAddress, "must be one address, e.g. support@example.com").optional(),
|
|
66
95
|
/**
|
|
@@ -108,6 +137,8 @@ export const mailingOptionsSchema = z.strictObject({
|
|
|
108
137
|
* idempotency window. More than `staleClaimMs`, at most 30 days.
|
|
109
138
|
*/
|
|
110
139
|
uncertainClaimMs: z.number().int().min(60_000).max(30 * 24 * HOUR_MS).default(DEFAULT_UNCERTAIN_CLAIM_MS),
|
|
140
|
+
/** Limits on attachments: count, total size and allowed content types. Defaults fit Resend's 40 MB cap. */
|
|
141
|
+
attachments: attachmentsOptionsSchema.prefault({}),
|
|
111
142
|
}).refine((options) => options.uncertainClaimMs > options.staleClaimMs, { message: "must be more than staleClaimMs", path: ["uncertainClaimMs"] });
|
|
112
143
|
|
|
113
144
|
export type MailingOptionsInput = z.input<typeof mailingOptionsSchema>;
|
package/src/providers/resend.ts
CHANGED
|
@@ -32,6 +32,7 @@ export function resend(options: ResendOptions = {}): MailProvider {
|
|
|
32
32
|
const endpoint = options.endpoint ?? RESEND_ENDPOINT;
|
|
33
33
|
return {
|
|
34
34
|
name: "resend",
|
|
35
|
+
supportsAttachments: true,
|
|
35
36
|
send: async (message, { signal }) => {
|
|
36
37
|
const apiKey = (options.apiKey ?? process.env[RESEND_API_KEY_ENV] ?? "").trim();
|
|
37
38
|
if (apiKey === "") {
|
|
@@ -65,6 +66,15 @@ function toResendBody(message: ProviderMessage): Record<string, unknown> {
|
|
|
65
66
|
...(message.html === null ? {} : { html: message.html }),
|
|
66
67
|
...(message.replyTo === null ? {} : { reply_to: message.replyTo }),
|
|
67
68
|
...(Object.keys(message.headers).length === 0 ? {} : { headers: message.headers }),
|
|
69
|
+
...(message.attachments === undefined || message.attachments.length === 0
|
|
70
|
+
? {}
|
|
71
|
+
: {
|
|
72
|
+
attachments: message.attachments.map((attachment) => ({
|
|
73
|
+
filename: attachment.filename,
|
|
74
|
+
content: Buffer.from(attachment.content.buffer, attachment.content.byteOffset, attachment.content.byteLength).toString("base64"),
|
|
75
|
+
content_type: attachment.contentType,
|
|
76
|
+
})),
|
|
77
|
+
}),
|
|
68
78
|
};
|
|
69
79
|
}
|
|
70
80
|
|
package/src/server/deliveries.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { HALTING_ERROR_CODES, TRANSACTIONAL_KIND, type MailingErrorCode, type Ou
|
|
|
13
13
|
import { DEFAULT_MAX_ATTEMPTS, DEFAULT_STALE_CLAIM_MS, DEFAULT_UNCERTAIN_CLAIM_MS } from "../options.js";
|
|
14
14
|
import { deliveries } from "../schema.js";
|
|
15
15
|
import { getMailingOptions } from "./options.js";
|
|
16
|
-
import { sendMail } from "./send-mail.js";
|
|
16
|
+
import { resolveSender, sendMail } from "./send-mail.js";
|
|
17
17
|
import { isSuppressed } from "./suppressions.js";
|
|
18
18
|
import { getRecipientKey } from "./unsubscribe-link.js";
|
|
19
19
|
import { validateMail } from "./validate-mail.js";
|
|
@@ -167,7 +167,8 @@ export async function forecastDelivery(ctx: DeliveryContext, delivery: Delivery,
|
|
|
167
167
|
if (age <= windows.staleClaimMs) return { status: "in-flight" };
|
|
168
168
|
if (age > windows.uncertainClaimMs && !windows.retakeUncertain) return { status: "uncertain" };
|
|
169
169
|
}
|
|
170
|
-
|
|
170
|
+
const validation = validateMail(delivery.mail, {}, getMailingOptions(ctx.config).attachments);
|
|
171
|
+
if (!validation.ok || resolveSender(ctx.config, validation.value) === null) return { status: "rejected", reason: "mailing.invalid_input" };
|
|
171
172
|
if (kind !== TRANSACTIONAL_KIND && (await isSuppressed(ctx, delivery.mail.to))) return { status: "rejected", reason: "mailing.suppressed" };
|
|
172
173
|
return { status: "send" };
|
|
173
174
|
}
|
package/src/server/index.ts
CHANGED
|
@@ -61,7 +61,7 @@ export {
|
|
|
61
61
|
type ImportSummary,
|
|
62
62
|
} from "./import-deliveries.js";
|
|
63
63
|
export { addHtmlFooter, addTextFooter, getListUnsubscribeHeaders, SIGNATURE_SEPARATOR } from "./list-mail.js";
|
|
64
|
-
export { maskAddress, redactUnsubscribeSignatures } from "./operator-output.js";
|
|
64
|
+
export { formatMailPreview, maskAddress, redactUnsubscribeSignatures } from "./operator-output.js";
|
|
65
65
|
export { getMailingModule, getMailingOptions, getMailingRoutes, resolveMailKind, type MailingRoutes } from "./options.js";
|
|
66
66
|
export { runDeliveries, type DeliveryRunInput, type DeliveryRunSummary, type RunDeliveriesOptions } from "./run-deliveries.js";
|
|
67
67
|
export { previewMail, sendMail, type MailContext, type MailPreviewFailure, type MailPreviewResult, type PreviewMailOptions } from "./send-mail.js";
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// What an operator's terminal may show of a mail: addresses masked, link signatures redacted. Command output ends
|
|
2
2
|
// up in shell history, CI logs and pasted chat messages; a full address is personal data, and a signed unsubscribe
|
|
3
3
|
// link lets whoever holds it unsubscribe that address.
|
|
4
|
+
import { describeAttachments } from "../attachments.js";
|
|
5
|
+
import type { ProviderMessage } from "../contract.js";
|
|
4
6
|
import { SIGNATURE_PARAM } from "./unsubscribe-link.js";
|
|
5
7
|
|
|
6
8
|
/** `ada@example.org` becomes `a**@example.org`: the first character of the local part, then one `*` per other. */
|
|
@@ -18,3 +20,25 @@ const SIGNATURE = new RegExp(`([?&](?:amp;)?${SIGNATURE_PARAM}=)[A-Za-z0-9_-]+`,
|
|
|
18
20
|
export function redactUnsubscribeSignatures(text: string): string {
|
|
19
21
|
return text.replace(SIGNATURE, "$1<signature>");
|
|
20
22
|
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A mail as the provider would get it (`previewMail`'s value), as terminal lines: the addresses masked, link
|
|
26
|
+
* signatures redacted, and each attachment by filename, content type and size, never its bytes.
|
|
27
|
+
*/
|
|
28
|
+
export function formatMailPreview(message: ProviderMessage): string[] {
|
|
29
|
+
const headers = [
|
|
30
|
+
`From: ${message.from}`,
|
|
31
|
+
`To: ${maskAddress(message.to)}`,
|
|
32
|
+
...(message.replyTo === null ? [] : [`Reply-To: ${maskAddress(message.replyTo)}`]),
|
|
33
|
+
`Subject: ${message.subject}`,
|
|
34
|
+
...Object.entries(message.headers).map(([name, value]) => `${name}: ${value}`),
|
|
35
|
+
];
|
|
36
|
+
const sections = [`--- preview (addresses masked, link signatures redacted) ---`, ...headers, "", "--- text ---", message.text];
|
|
37
|
+
if (message.html !== null) sections.push("--- html ---", message.html);
|
|
38
|
+
const attachments = describeAttachments(message.attachments ?? []);
|
|
39
|
+
if (attachments.length > 0) {
|
|
40
|
+
sections.push("--- attachments ---", ...attachments.map(({ filename, contentType, size }) => `${filename} (${contentType}, ${String(size)} bytes)`));
|
|
41
|
+
}
|
|
42
|
+
sections.push("--- end of preview ---");
|
|
43
|
+
return redactUnsubscribeSignatures(sections.join("\n")).split("\n");
|
|
44
|
+
}
|
package/src/server/send-mail.ts
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
// Sends one mail through the configured provider, with three rules: it never throws for a failed
|
|
2
2
|
// send (every failure is a result), nothing of the mail (address, subject, body, key) reaches a log
|
|
3
3
|
// or the result, and `headers` cannot replace the envelope or the sender (a mail's own reply-to is
|
|
4
|
-
// the `replyTo` field, checked like `to
|
|
4
|
+
// the `replyTo` field, checked like `to`; its sender is a name of `mailing({ senders })`, never an
|
|
5
|
+
// address). Attachments go only to a provider that declares it delivers them, and a
|
|
6
|
+
// log line describes them by count, content type and size, never by name or content. List mail (any kind but `transactional`) also gets a signed unsubscribe
|
|
5
7
|
// link in a footer and the RFC 8058 headers, and is refused for a recipient who unsubscribed.
|
|
6
8
|
import { err, type Err, ok, type Ok, selectMessages, type SoftureConfig } from "@softure-ai/core";
|
|
7
9
|
import type { Queryable } from "@softure-ai/db";
|
|
10
|
+
import { getContentTypeEssence } from "../attachments.js";
|
|
8
11
|
import {
|
|
9
12
|
TRANSACTIONAL_KIND,
|
|
13
|
+
type MailAttachment,
|
|
10
14
|
type MailingErrorCode,
|
|
11
15
|
type MailProvider,
|
|
12
16
|
type OutgoingMail,
|
|
@@ -42,19 +46,21 @@ export interface MailContext {
|
|
|
42
46
|
* Throws only when the module is not enabled, or for list mail without `context.db`.
|
|
43
47
|
*/
|
|
44
48
|
export async function sendMail(context: MailContext, mail: OutgoingMail, options: SendMailOptions = {}): Promise<SendMailResult> {
|
|
45
|
-
const { provider, timeoutMs } = getMailingOptions(context.config);
|
|
49
|
+
const { provider, timeoutMs, attachments: limits } = getMailingOptions(context.config);
|
|
46
50
|
|
|
47
|
-
const validation = validateMail(mail, options);
|
|
51
|
+
const validation = validateMail(mail, options, limits);
|
|
48
52
|
if (!validation.ok) {
|
|
49
53
|
logFailure(provider, "mailing.invalid_input", { fields: validation.fields });
|
|
50
54
|
return err("mailing.invalid_input");
|
|
51
55
|
}
|
|
52
56
|
const valid = validation.value;
|
|
57
|
+
if (resolveSender(context.config, valid) === null) return refuseSender(provider, valid);
|
|
58
|
+
if (!canCarryAttachments(provider, valid)) return refuseAttachments(provider, valid);
|
|
53
59
|
const secret = readUnsubscribeSecrets().current;
|
|
54
60
|
if (valid.kind !== TRANSACTIONAL_KIND) {
|
|
55
61
|
const refusal = await checkListMail(context, valid, secret);
|
|
56
62
|
if (refusal !== null) {
|
|
57
|
-
logFailure(provider, refusal.error, { cause: refusal.cause });
|
|
63
|
+
logFailure(provider, refusal.error, { cause: refusal.cause, attachments: valid.attachments });
|
|
58
64
|
return err(refusal.error);
|
|
59
65
|
}
|
|
60
66
|
}
|
|
@@ -68,15 +74,17 @@ export async function sendMail(context: MailContext, mail: OutgoingMail, options
|
|
|
68
74
|
* code can send list mail to a real recipient past the suppression list.
|
|
69
75
|
*/
|
|
70
76
|
export async function sendMailUnchecked(config: SoftureConfig, mail: OutgoingMail, options: SendMailOptions = {}): Promise<SendMailResult> {
|
|
71
|
-
const { provider, timeoutMs } = getMailingOptions(config);
|
|
72
|
-
const validation = validateMail(mail, options);
|
|
77
|
+
const { provider, timeoutMs, attachments: limits } = getMailingOptions(config);
|
|
78
|
+
const validation = validateMail(mail, options, limits);
|
|
73
79
|
if (!validation.ok) {
|
|
74
80
|
logFailure(provider, "mailing.invalid_input", { fields: validation.fields });
|
|
75
81
|
return err("mailing.invalid_input");
|
|
76
82
|
}
|
|
83
|
+
if (resolveSender(config, validation.value) === null) return refuseSender(provider, validation.value);
|
|
84
|
+
if (!canCarryAttachments(provider, validation.value)) return refuseAttachments(provider, validation.value);
|
|
77
85
|
const secret = readUnsubscribeSecrets().current;
|
|
78
86
|
if (validation.value.kind !== TRANSACTIONAL_KIND && secret === null) {
|
|
79
|
-
logFailure(provider, "mailing.unavailable", { cause: "no_unsubscribe_secret" });
|
|
87
|
+
logFailure(provider, "mailing.unavailable", { cause: "no_unsubscribe_secret", attachments: validation.value.attachments });
|
|
80
88
|
return err("mailing.unavailable");
|
|
81
89
|
}
|
|
82
90
|
return deliver(provider, composeMessage(config, validation.value, secret), timeoutMs);
|
|
@@ -89,7 +97,7 @@ async function deliver(provider: MailProvider, message: ProviderMessage, timeout
|
|
|
89
97
|
return ok({ id: outcome.id, provider: provider.name });
|
|
90
98
|
}
|
|
91
99
|
const code = FAILURE_CODES[outcome.status];
|
|
92
|
-
logFailure(provider, code, { status: outcome.httpStatus });
|
|
100
|
+
logFailure(provider, code, { status: outcome.httpStatus, attachments: message.attachments ?? [] });
|
|
93
101
|
return {
|
|
94
102
|
...err(code),
|
|
95
103
|
...(outcome.httpStatus === undefined ? {} : { httpStatus: outcome.httpStatus }),
|
|
@@ -97,6 +105,31 @@ async function deliver(provider: MailProvider, message: ProviderMessage, timeout
|
|
|
97
105
|
};
|
|
98
106
|
}
|
|
99
107
|
|
|
108
|
+
/** False for a mail with attachments and a provider that does not declare it delivers them. */
|
|
109
|
+
function canCarryAttachments(provider: MailProvider, mail: ValidMail): boolean {
|
|
110
|
+
return mail.attachments.length === 0 || provider.supportsAttachments === true;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The sender of `mail`: its named sender of `mailing({ senders })`, `from` when it names none, or `null` when the name
|
|
115
|
+
* is not configured (the mail must not go out: a request cannot pick a sender the app does not have).
|
|
116
|
+
*/
|
|
117
|
+
export function resolveSender(config: SoftureConfig, mail: Pick<ValidMail, "sender">): string | null {
|
|
118
|
+
const { from, senders = {} } = getMailingOptions(config);
|
|
119
|
+
if (mail.sender === null) return from;
|
|
120
|
+
return Object.hasOwn(senders, mail.sender) ? (senders[mail.sender] ?? null) : null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function refuseSender(provider: MailProvider, mail: ValidMail): SendMailResult {
|
|
124
|
+
logFailure(provider, "mailing.invalid_input", { fields: ["sender"], attachments: mail.attachments });
|
|
125
|
+
return err("mailing.invalid_input");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function refuseAttachments(provider: MailProvider, mail: ValidMail): SendMailResult {
|
|
129
|
+
logFailure(provider, "mailing.invalid_input", { fields: ["attachments"], cause: "provider_without_attachments", attachments: mail.attachments });
|
|
130
|
+
return err("mailing.invalid_input");
|
|
131
|
+
}
|
|
132
|
+
|
|
100
133
|
/** Why `previewMail` cannot render a mail: invalid input (with the fields), or list mail without the secret. */
|
|
101
134
|
export type MailPreviewFailure =
|
|
102
135
|
| (Err<"mailing.invalid_input"> & { readonly fields: readonly string[] })
|
|
@@ -110,15 +143,17 @@ export interface PreviewMailOptions extends SendMailOptions {
|
|
|
110
143
|
}
|
|
111
144
|
|
|
112
145
|
/**
|
|
113
|
-
* Exactly what `sendMail` would hand the provider for `mail`: the sender, the reply-to, and for list
|
|
114
|
-
* footer with a link signed for `mail.to` and the RFC 8058 headers. Sends nothing, logs nothing and needs no
|
|
115
|
-
* database: it does not check the suppression list, so it renders a mail `sendMail` would refuse as suppressed
|
|
146
|
+
* Exactly what `sendMail` would hand the provider for `mail`: the sender, the reply-to, the attachments, and for list
|
|
147
|
+
* mail the footer with a link signed for `mail.to` and the RFC 8058 headers. Sends nothing, logs nothing and needs no
|
|
148
|
+
* database: it does not check the suppression list, so it renders a mail `sendMail` would refuse as suppressed, nor
|
|
149
|
+
* whether the provider delivers attachments.
|
|
116
150
|
* The links in it are credentials for `mail.to`. Throws only when the module is not enabled.
|
|
117
151
|
*/
|
|
118
152
|
export function previewMail(config: SoftureConfig, mail: OutgoingMail, options: PreviewMailOptions = {}): MailPreviewResult {
|
|
119
153
|
const { env, ...sendOptions } = options;
|
|
120
|
-
const validation = validateMail(mail, sendOptions);
|
|
154
|
+
const validation = validateMail(mail, sendOptions, getMailingOptions(config).attachments);
|
|
121
155
|
if (!validation.ok) return { ...err("mailing.invalid_input"), fields: validation.fields };
|
|
156
|
+
if (resolveSender(config, validation.value) === null) return { ...err("mailing.invalid_input"), fields: ["sender"] };
|
|
122
157
|
const secret = readUnsubscribeSecrets(env).current;
|
|
123
158
|
if (validation.value.kind !== TRANSACTIONAL_KIND && secret === null) return { ...err("mailing.unavailable"), cause: "no_unsubscribe_secret" };
|
|
124
159
|
return ok(composeMessage(config, validation.value, secret));
|
|
@@ -154,13 +189,16 @@ async function checkListMail(context: MailContext, mail: ValidMail, secret: stri
|
|
|
154
189
|
}
|
|
155
190
|
|
|
156
191
|
/**
|
|
157
|
-
* The message the provider receives: the
|
|
158
|
-
* mail the footer and headers signed with `secret` (which the caller checked is set).
|
|
192
|
+
* The message the provider receives: the resolved sender (see `resolveSender`), the mail's or the configured
|
|
193
|
+
* reply-to, and for list mail the footer and headers signed with `secret` (which the caller checked is set).
|
|
159
194
|
*/
|
|
160
195
|
function composeMessage(config: SoftureConfig, mail: ValidMail, secret: string | null): ProviderMessage {
|
|
161
|
-
const {
|
|
162
|
-
const { idempotencyKey, kind, replyTo: mailReplyTo, ...content } = mail;
|
|
163
|
-
const
|
|
196
|
+
const { replyTo } = getMailingOptions(config);
|
|
197
|
+
const { idempotencyKey, kind, replyTo: mailReplyTo, sender, attachments, ...content } = mail;
|
|
198
|
+
const from = resolveSender(config, { sender });
|
|
199
|
+
// Every caller refused an unknown sender before composing.
|
|
200
|
+
if (from === null) throw new Error("@softure-ai/mailing: composeMessage got a sender the config does not have");
|
|
201
|
+
const envelope = { from, replyTo: mailReplyTo ?? replyTo ?? null, idempotencyKey, ...(attachments.length === 0 ? {} : { attachments }) };
|
|
164
202
|
if (kind === TRANSACTIONAL_KIND || secret === null) return { ...content, ...envelope };
|
|
165
203
|
const links = buildUnsubscribeLinks(config, mail.to, secret);
|
|
166
204
|
// The module factory merged the dictionaries; their shape is the module's own.
|
|
@@ -226,15 +264,21 @@ function isHttpStatus(value: unknown): value is number {
|
|
|
226
264
|
|
|
227
265
|
/**
|
|
228
266
|
* The module's only log line. Deliberately takes nothing but the provider, the code, a status,
|
|
229
|
-
* field names
|
|
267
|
+
* field names, a fixed cause and the attachments' content types and sizes: anything added "for
|
|
268
|
+
* diagnosis" is where an address leaks first. Filenames stay out like the subject: they are the
|
|
269
|
+
* app's text and often name a person or an invoice.
|
|
230
270
|
*/
|
|
231
271
|
function logFailure(
|
|
232
272
|
provider: MailProvider,
|
|
233
273
|
code: MailingErrorCode,
|
|
234
|
-
details: { readonly status?: number; readonly fields?: readonly string[]; readonly cause?: string },
|
|
274
|
+
details: { readonly status?: number; readonly fields?: readonly string[]; readonly cause?: string; readonly attachments?: readonly MailAttachment[] },
|
|
235
275
|
): void {
|
|
236
276
|
const parts = [`provider=${provider.name}`, `reason=${code.slice("mailing.".length)}`, `status=${details.status === undefined ? "none" : String(details.status)}`];
|
|
237
277
|
if (details.fields !== undefined) parts.push(`fields=${details.fields.join(",")}`);
|
|
238
278
|
if (details.cause !== undefined) parts.push(`cause=${details.cause}`);
|
|
279
|
+
if (details.attachments !== undefined && details.attachments.length > 0) {
|
|
280
|
+
const attachments = details.attachments.map((attachment) => `${getContentTypeEssence(attachment.contentType)}:${String(attachment.content.byteLength)}`);
|
|
281
|
+
parts.push(`attachments=${attachments.join(",")}`);
|
|
282
|
+
}
|
|
239
283
|
console.error(`mailing: send failed ${parts.join(" ")}`);
|
|
240
284
|
}
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
// process. Failures name the fields, never their values (an address is personal data).
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { hasLineBreak, isHeaderName, isIdempotencyKey, isListMailHeader, isMailKind, isReservedHeader, isSingleAddress, MAX_SUBJECT_LENGTH } from "../address.js";
|
|
5
|
-
import {
|
|
5
|
+
import { DEFAULT_ATTACHMENT_LIMITS, getContentTypeEssence, isAttachmentFilename, isContentType, type AttachmentLimits } from "../attachments.js";
|
|
6
|
+
import { TRANSACTIONAL_KIND, type MailAttachment } from "../contract.js";
|
|
7
|
+
import { SENDER_NAME } from "../options.js";
|
|
6
8
|
|
|
7
9
|
const headersSchema = z.record(z.string(), z.string()).superRefine((headers, context) => {
|
|
8
10
|
for (const [name, value] of Object.entries(headers)) {
|
|
@@ -12,7 +14,24 @@ const headersSchema = z.record(z.string(), z.string()).superRefine((headers, con
|
|
|
12
14
|
}
|
|
13
15
|
});
|
|
14
16
|
|
|
15
|
-
const
|
|
17
|
+
const attachmentSchema = z.object({
|
|
18
|
+
filename: z.string().refine(isAttachmentFilename),
|
|
19
|
+
contentType: z.string().trim().refine(isContentType),
|
|
20
|
+
// A Node Buffer is a Uint8Array too; an empty file is almost always a caller's bug.
|
|
21
|
+
content: z.custom<Uint8Array>((value) => value instanceof Uint8Array && value.byteLength > 0),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
/** The attachments within `limits`: count, total bytes and an allowed content type for each. */
|
|
25
|
+
function createAttachmentsSchema(limits: AttachmentLimits) {
|
|
26
|
+
const allowed = new Set(limits.contentTypes);
|
|
27
|
+
return z
|
|
28
|
+
.array(attachmentSchema)
|
|
29
|
+
.max(limits.maxCount)
|
|
30
|
+
.refine((attachments) => attachments.every((attachment) => allowed.has(getContentTypeEssence(attachment.contentType))))
|
|
31
|
+
.refine((attachments) => attachments.reduce((total, attachment) => total + attachment.content.byteLength, 0) <= limits.maxTotalBytes);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const mailFields = {
|
|
16
35
|
to: z.string().trim().refine(isSingleAddress),
|
|
17
36
|
subject: z
|
|
18
37
|
.string()
|
|
@@ -24,13 +43,30 @@ const mailSchema = z.object({
|
|
|
24
43
|
.optional(),
|
|
25
44
|
headers: headersSchema.optional(),
|
|
26
45
|
replyTo: z.string().trim().refine(isSingleAddress).optional(),
|
|
46
|
+
sender: z.string().regex(SENDER_NAME).optional(),
|
|
27
47
|
kind: z.string().refine(isMailKind).default(TRANSACTIONAL_KIND),
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
function createMailSchema(limits: AttachmentLimits) {
|
|
51
|
+
return z.object({ ...mailFields, attachments: createAttachmentsSchema(limits).optional() }).superRefine((mail, context) => {
|
|
52
|
+
// The module writes these two on list mail; a second, unsigned copy would confuse clients.
|
|
53
|
+
if (mail.kind !== TRANSACTIONAL_KIND && Object.keys(mail.headers ?? {}).some(isListMailHeader)) {
|
|
54
|
+
context.addIssue({ code: "custom", path: ["headers"], message: "list mail gets its unsubscribe headers from the module" });
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** One schema per limits object: the configured limits are parsed once per config, so this builds each once. */
|
|
60
|
+
const mailSchemas = new WeakMap<AttachmentLimits, ReturnType<typeof createMailSchema>>();
|
|
61
|
+
|
|
62
|
+
function getMailSchema(limits: AttachmentLimits): ReturnType<typeof createMailSchema> {
|
|
63
|
+
let schema = mailSchemas.get(limits);
|
|
64
|
+
if (schema === undefined) {
|
|
65
|
+
schema = createMailSchema(limits);
|
|
66
|
+
mailSchemas.set(limits, schema);
|
|
32
67
|
}
|
|
33
|
-
|
|
68
|
+
return schema;
|
|
69
|
+
}
|
|
34
70
|
|
|
35
71
|
const optionsSchema = z.object({
|
|
36
72
|
idempotencyKey: z.string().refine(isIdempotencyKey).optional(),
|
|
@@ -44,28 +80,44 @@ export interface ValidMail {
|
|
|
44
80
|
readonly headers: Readonly<Record<string, string>>;
|
|
45
81
|
/** The mail's own reply-to; null to use the configured one. */
|
|
46
82
|
readonly replyTo: string | null;
|
|
83
|
+
/** The name of a configured sender; null for `from`. Its value is looked up in the config (`resolveSender`). */
|
|
84
|
+
readonly sender: string | null;
|
|
47
85
|
/** `transactional` or a list kind. */
|
|
48
86
|
readonly kind: string;
|
|
49
87
|
readonly idempotencyKey: string | null;
|
|
88
|
+
/** Empty when the mail has none. */
|
|
89
|
+
readonly attachments: readonly MailAttachment[];
|
|
50
90
|
}
|
|
51
91
|
|
|
52
92
|
export type MailValidation = { readonly ok: true; readonly value: ValidMail } | { readonly ok: false; readonly fields: readonly string[] };
|
|
53
93
|
|
|
54
94
|
/**
|
|
55
|
-
* Checks one mail and its send options
|
|
56
|
-
* `
|
|
95
|
+
* Checks one mail and its send options, its attachments against `limits` (default: the module's defaults; `sendMail`
|
|
96
|
+
* passes the configured ones). `fields` lists what failed (`to`, `subject`, `text`, `html`, `headers`, `replyTo`,
|
|
97
|
+
* `sender` (its shape only: whether the app configured it is `resolveSender`'s check), `kind`, `attachments`, `idempotencyKey`, or `mail` when the input is not an object).
|
|
57
98
|
*/
|
|
58
|
-
export function validateMail(mail: unknown, options: unknown = {}): MailValidation {
|
|
59
|
-
const parsedMail =
|
|
99
|
+
export function validateMail(mail: unknown, options: unknown = {}, limits: AttachmentLimits = DEFAULT_ATTACHMENT_LIMITS): MailValidation {
|
|
100
|
+
const parsedMail = getMailSchema(limits).safeParse(mail);
|
|
60
101
|
const parsedOptions = optionsSchema.safeParse(options ?? {});
|
|
61
102
|
if (!parsedMail.success || !parsedOptions.success) {
|
|
62
103
|
const issues = [...(parsedMail.error?.issues ?? []), ...(parsedOptions.error?.issues ?? [])];
|
|
63
104
|
const fields = new Set(issues.map((issue) => (issue.path.length === 0 ? "mail" : String(issue.path[0]))));
|
|
64
105
|
return { ok: false, fields: [...fields] };
|
|
65
106
|
}
|
|
66
|
-
const { to, subject, text, html, headers, replyTo, kind } = parsedMail.data;
|
|
107
|
+
const { to, subject, text, html, headers, replyTo, sender, kind, attachments = [] } = parsedMail.data;
|
|
67
108
|
return {
|
|
68
109
|
ok: true,
|
|
69
|
-
value: {
|
|
110
|
+
value: {
|
|
111
|
+
to,
|
|
112
|
+
subject,
|
|
113
|
+
text,
|
|
114
|
+
html: html ?? null,
|
|
115
|
+
headers: headers ?? {},
|
|
116
|
+
replyTo: replyTo ?? null,
|
|
117
|
+
sender: sender ?? null,
|
|
118
|
+
kind,
|
|
119
|
+
idempotencyKey: parsedOptions.data.idempotencyKey ?? null,
|
|
120
|
+
attachments,
|
|
121
|
+
},
|
|
70
122
|
};
|
|
71
123
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// A provider that sends nothing: it captures mail in memory (`provider.sent`) and, with an outbox
|
|
2
2
|
// file, appends each mail as one JSON line for a test in another process (an e2e against
|
|
3
|
-
// `next start`). It honours idempotency keys like a real provider: a repeated key answers the
|
|
3
|
+
// `next start`), attachment bytes as base64. It honours idempotency keys like a real provider: a repeated key answers the
|
|
4
4
|
// first id and captures nothing new.
|
|
5
5
|
import { appendFile } from "node:fs/promises";
|
|
6
6
|
import type { MailProvider, ProviderMessage, ProviderOutcome } from "../contract.js";
|
|
@@ -34,6 +34,7 @@ export function fakeMailProvider(options: FakeMailProviderOptions = {}): FakeMai
|
|
|
34
34
|
|
|
35
35
|
return {
|
|
36
36
|
name: "fake",
|
|
37
|
+
supportsAttachments: true,
|
|
37
38
|
get sent() {
|
|
38
39
|
return [...sent];
|
|
39
40
|
},
|
|
@@ -54,9 +55,14 @@ export function fakeMailProvider(options: FakeMailProviderOptions = {}): FakeMai
|
|
|
54
55
|
if (knownId !== undefined) return { status: "sent", id: knownId };
|
|
55
56
|
|
|
56
57
|
counter += 1;
|
|
57
|
-
const captured: CapturedMail = {
|
|
58
|
+
const captured: CapturedMail = {
|
|
59
|
+
...message,
|
|
60
|
+
headers: { ...message.headers },
|
|
61
|
+
...(message.attachments === undefined ? {} : { attachments: message.attachments.map((attachment) => ({ ...attachment, content: attachment.content.slice() })) }),
|
|
62
|
+
id: `fake-${String(counter)}-${crypto.randomUUID()}`,
|
|
63
|
+
};
|
|
58
64
|
if (options.outboxFile !== undefined) {
|
|
59
|
-
await appendFile(options.outboxFile, `${JSON.stringify(captured)}\n`, "utf8");
|
|
65
|
+
await appendFile(options.outboxFile, `${JSON.stringify(toOutboxLine(captured))}\n`, "utf8");
|
|
60
66
|
}
|
|
61
67
|
sent.push(captured);
|
|
62
68
|
if (message.idempotencyKey !== null) idsByKey.set(message.idempotencyKey, captured.id);
|
|
@@ -64,3 +70,12 @@ export function fakeMailProvider(options: FakeMailProviderOptions = {}): FakeMai
|
|
|
64
70
|
},
|
|
65
71
|
};
|
|
66
72
|
}
|
|
73
|
+
|
|
74
|
+
/** A captured mail as one outbox line: attachment bytes as base64 (`readMailOutbox` decodes them). */
|
|
75
|
+
function toOutboxLine(mail: CapturedMail): unknown {
|
|
76
|
+
if (mail.attachments === undefined) return mail;
|
|
77
|
+
return {
|
|
78
|
+
...mail,
|
|
79
|
+
attachments: mail.attachments.map((attachment) => ({ ...attachment, content: Buffer.from(attachment.content).toString("base64") })),
|
|
80
|
+
};
|
|
81
|
+
}
|
package/src/testing/outbox.ts
CHANGED
|
@@ -13,6 +13,10 @@ const capturedMailSchema = z.object({
|
|
|
13
13
|
html: z.string().nullable(),
|
|
14
14
|
headers: z.record(z.string(), z.string()),
|
|
15
15
|
idempotencyKey: z.string().nullable(),
|
|
16
|
+
// Base64 in the file, bytes again here; lines written before attachments existed have none.
|
|
17
|
+
attachments: z
|
|
18
|
+
.array(z.object({ filename: z.string(), contentType: z.string(), content: z.base64().transform((content) => new Uint8Array(Buffer.from(content, "base64"))) }))
|
|
19
|
+
.optional(),
|
|
16
20
|
});
|
|
17
21
|
|
|
18
22
|
/** Every mail in the outbox, oldest first, optionally only those to `to`. A missing file is an empty outbox. */
|
|
@@ -27,6 +31,12 @@ export async function readMailOutbox(file: string, filter: { readonly to?: strin
|
|
|
27
31
|
return text
|
|
28
32
|
.split("\n")
|
|
29
33
|
.filter((line) => line !== "")
|
|
30
|
-
.map((line) => capturedMailSchema.parse(JSON.parse(line)))
|
|
34
|
+
.map((line) => toCapturedMail(capturedMailSchema.parse(JSON.parse(line))))
|
|
31
35
|
.filter((mail) => filter.to === undefined || mail.to === filter.to);
|
|
32
36
|
}
|
|
37
|
+
|
|
38
|
+
/** A parsed line without an `attachments: undefined` key, so a mail without attachments equals what was sent. */
|
|
39
|
+
function toCapturedMail(line: z.output<typeof capturedMailSchema>): CapturedMail {
|
|
40
|
+
const { attachments, ...mail } = line;
|
|
41
|
+
return attachments === undefined ? mail : { ...mail, attachments };
|
|
42
|
+
}
|