@softure-ai/mailing 0.2.3 → 0.2.4
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 +27 -0
- package/README.md +51 -5
- 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.js +2 -13
- package/dist/cli/run.js.map +1 -1
- package/dist/contract.d.ts +31 -0
- package/dist/contract.d.ts.map +1 -1
- package/dist/index.d.ts +7 -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 +5 -0
- package/dist/options.d.ts.map +1 -1
- package/dist/options.js +18 -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.js +1 -1
- 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 +4 -3
- package/dist/server/send-mail.d.ts.map +1 -1
- package/dist/server/send-mail.js +36 -15
- package/dist/server/send-mail.js.map +1 -1
- package/dist/server/validate-mail.d.ts +8 -3
- package/dist/server/validate-mail.d.ts.map +1 -1
- package/dist/server/validate-mail.js +52 -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 +2 -12
- package/src/contract.ts +33 -0
- package/src/index.ts +12 -1
- package/src/jobs/delivery-job.ts +24 -4
- package/src/options.ts +19 -0
- package/src/providers/resend.ts +10 -0
- package/src/server/deliveries.ts +1 -1
- package/src/server/index.ts +1 -1
- package/src/server/operator-output.ts +24 -0
- package/src/server/send-mail.ts +38 -16
- package/src/server/validate-mail.ts +60 -13
- package/src/testing/fake-provider.ts +18 -3
- package/src/testing/outbox.ts +11 -1
|
@@ -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,15 @@
|
|
|
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`). Attachments go only to a provider that declares it delivers them, and a
|
|
5
|
+
// 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
6
|
// link in a footer and the RFC 8058 headers, and is refused for a recipient who unsubscribed.
|
|
6
7
|
import { err, type Err, ok, type Ok, selectMessages, type SoftureConfig } from "@softure-ai/core";
|
|
7
8
|
import type { Queryable } from "@softure-ai/db";
|
|
9
|
+
import { getContentTypeEssence } from "../attachments.js";
|
|
8
10
|
import {
|
|
9
11
|
TRANSACTIONAL_KIND,
|
|
12
|
+
type MailAttachment,
|
|
10
13
|
type MailingErrorCode,
|
|
11
14
|
type MailProvider,
|
|
12
15
|
type OutgoingMail,
|
|
@@ -42,19 +45,20 @@ export interface MailContext {
|
|
|
42
45
|
* Throws only when the module is not enabled, or for list mail without `context.db`.
|
|
43
46
|
*/
|
|
44
47
|
export async function sendMail(context: MailContext, mail: OutgoingMail, options: SendMailOptions = {}): Promise<SendMailResult> {
|
|
45
|
-
const { provider, timeoutMs } = getMailingOptions(context.config);
|
|
48
|
+
const { provider, timeoutMs, attachments: limits } = getMailingOptions(context.config);
|
|
46
49
|
|
|
47
|
-
const validation = validateMail(mail, options);
|
|
50
|
+
const validation = validateMail(mail, options, limits);
|
|
48
51
|
if (!validation.ok) {
|
|
49
52
|
logFailure(provider, "mailing.invalid_input", { fields: validation.fields });
|
|
50
53
|
return err("mailing.invalid_input");
|
|
51
54
|
}
|
|
52
55
|
const valid = validation.value;
|
|
56
|
+
if (!canCarryAttachments(provider, valid)) return refuseAttachments(provider, valid);
|
|
53
57
|
const secret = readUnsubscribeSecrets().current;
|
|
54
58
|
if (valid.kind !== TRANSACTIONAL_KIND) {
|
|
55
59
|
const refusal = await checkListMail(context, valid, secret);
|
|
56
60
|
if (refusal !== null) {
|
|
57
|
-
logFailure(provider, refusal.error, { cause: refusal.cause });
|
|
61
|
+
logFailure(provider, refusal.error, { cause: refusal.cause, attachments: valid.attachments });
|
|
58
62
|
return err(refusal.error);
|
|
59
63
|
}
|
|
60
64
|
}
|
|
@@ -68,15 +72,16 @@ export async function sendMail(context: MailContext, mail: OutgoingMail, options
|
|
|
68
72
|
* code can send list mail to a real recipient past the suppression list.
|
|
69
73
|
*/
|
|
70
74
|
export async function sendMailUnchecked(config: SoftureConfig, mail: OutgoingMail, options: SendMailOptions = {}): Promise<SendMailResult> {
|
|
71
|
-
const { provider, timeoutMs } = getMailingOptions(config);
|
|
72
|
-
const validation = validateMail(mail, options);
|
|
75
|
+
const { provider, timeoutMs, attachments: limits } = getMailingOptions(config);
|
|
76
|
+
const validation = validateMail(mail, options, limits);
|
|
73
77
|
if (!validation.ok) {
|
|
74
78
|
logFailure(provider, "mailing.invalid_input", { fields: validation.fields });
|
|
75
79
|
return err("mailing.invalid_input");
|
|
76
80
|
}
|
|
81
|
+
if (!canCarryAttachments(provider, validation.value)) return refuseAttachments(provider, validation.value);
|
|
77
82
|
const secret = readUnsubscribeSecrets().current;
|
|
78
83
|
if (validation.value.kind !== TRANSACTIONAL_KIND && secret === null) {
|
|
79
|
-
logFailure(provider, "mailing.unavailable", { cause: "no_unsubscribe_secret" });
|
|
84
|
+
logFailure(provider, "mailing.unavailable", { cause: "no_unsubscribe_secret", attachments: validation.value.attachments });
|
|
80
85
|
return err("mailing.unavailable");
|
|
81
86
|
}
|
|
82
87
|
return deliver(provider, composeMessage(config, validation.value, secret), timeoutMs);
|
|
@@ -89,7 +94,7 @@ async function deliver(provider: MailProvider, message: ProviderMessage, timeout
|
|
|
89
94
|
return ok({ id: outcome.id, provider: provider.name });
|
|
90
95
|
}
|
|
91
96
|
const code = FAILURE_CODES[outcome.status];
|
|
92
|
-
logFailure(provider, code, { status: outcome.httpStatus });
|
|
97
|
+
logFailure(provider, code, { status: outcome.httpStatus, attachments: message.attachments ?? [] });
|
|
93
98
|
return {
|
|
94
99
|
...err(code),
|
|
95
100
|
...(outcome.httpStatus === undefined ? {} : { httpStatus: outcome.httpStatus }),
|
|
@@ -97,6 +102,16 @@ async function deliver(provider: MailProvider, message: ProviderMessage, timeout
|
|
|
97
102
|
};
|
|
98
103
|
}
|
|
99
104
|
|
|
105
|
+
/** False for a mail with attachments and a provider that does not declare it delivers them. */
|
|
106
|
+
function canCarryAttachments(provider: MailProvider, mail: ValidMail): boolean {
|
|
107
|
+
return mail.attachments.length === 0 || provider.supportsAttachments === true;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function refuseAttachments(provider: MailProvider, mail: ValidMail): SendMailResult {
|
|
111
|
+
logFailure(provider, "mailing.invalid_input", { fields: ["attachments"], cause: "provider_without_attachments", attachments: mail.attachments });
|
|
112
|
+
return err("mailing.invalid_input");
|
|
113
|
+
}
|
|
114
|
+
|
|
100
115
|
/** Why `previewMail` cannot render a mail: invalid input (with the fields), or list mail without the secret. */
|
|
101
116
|
export type MailPreviewFailure =
|
|
102
117
|
| (Err<"mailing.invalid_input"> & { readonly fields: readonly string[] })
|
|
@@ -110,14 +125,15 @@ export interface PreviewMailOptions extends SendMailOptions {
|
|
|
110
125
|
}
|
|
111
126
|
|
|
112
127
|
/**
|
|
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
|
|
128
|
+
* Exactly what `sendMail` would hand the provider for `mail`: the sender, the reply-to, the attachments, and for list
|
|
129
|
+
* mail the footer with a link signed for `mail.to` and the RFC 8058 headers. Sends nothing, logs nothing and needs no
|
|
130
|
+
* database: it does not check the suppression list, so it renders a mail `sendMail` would refuse as suppressed, nor
|
|
131
|
+
* whether the provider delivers attachments.
|
|
116
132
|
* The links in it are credentials for `mail.to`. Throws only when the module is not enabled.
|
|
117
133
|
*/
|
|
118
134
|
export function previewMail(config: SoftureConfig, mail: OutgoingMail, options: PreviewMailOptions = {}): MailPreviewResult {
|
|
119
135
|
const { env, ...sendOptions } = options;
|
|
120
|
-
const validation = validateMail(mail, sendOptions);
|
|
136
|
+
const validation = validateMail(mail, sendOptions, getMailingOptions(config).attachments);
|
|
121
137
|
if (!validation.ok) return { ...err("mailing.invalid_input"), fields: validation.fields };
|
|
122
138
|
const secret = readUnsubscribeSecrets(env).current;
|
|
123
139
|
if (validation.value.kind !== TRANSACTIONAL_KIND && secret === null) return { ...err("mailing.unavailable"), cause: "no_unsubscribe_secret" };
|
|
@@ -159,8 +175,8 @@ async function checkListMail(context: MailContext, mail: ValidMail, secret: stri
|
|
|
159
175
|
*/
|
|
160
176
|
function composeMessage(config: SoftureConfig, mail: ValidMail, secret: string | null): ProviderMessage {
|
|
161
177
|
const { from, replyTo } = getMailingOptions(config);
|
|
162
|
-
const { idempotencyKey, kind, replyTo: mailReplyTo, ...content } = mail;
|
|
163
|
-
const envelope = { from, replyTo: mailReplyTo ?? replyTo ?? null, idempotencyKey };
|
|
178
|
+
const { idempotencyKey, kind, replyTo: mailReplyTo, attachments, ...content } = mail;
|
|
179
|
+
const envelope = { from, replyTo: mailReplyTo ?? replyTo ?? null, idempotencyKey, ...(attachments.length === 0 ? {} : { attachments }) };
|
|
164
180
|
if (kind === TRANSACTIONAL_KIND || secret === null) return { ...content, ...envelope };
|
|
165
181
|
const links = buildUnsubscribeLinks(config, mail.to, secret);
|
|
166
182
|
// The module factory merged the dictionaries; their shape is the module's own.
|
|
@@ -226,15 +242,21 @@ function isHttpStatus(value: unknown): value is number {
|
|
|
226
242
|
|
|
227
243
|
/**
|
|
228
244
|
* The module's only log line. Deliberately takes nothing but the provider, the code, a status,
|
|
229
|
-
* field names
|
|
245
|
+
* field names, a fixed cause and the attachments' content types and sizes: anything added "for
|
|
246
|
+
* diagnosis" is where an address leaks first. Filenames stay out like the subject: they are the
|
|
247
|
+
* app's text and often name a person or an invoice.
|
|
230
248
|
*/
|
|
231
249
|
function logFailure(
|
|
232
250
|
provider: MailProvider,
|
|
233
251
|
code: MailingErrorCode,
|
|
234
|
-
details: { readonly status?: number; readonly fields?: readonly string[]; readonly cause?: string },
|
|
252
|
+
details: { readonly status?: number; readonly fields?: readonly string[]; readonly cause?: string; readonly attachments?: readonly MailAttachment[] },
|
|
235
253
|
): void {
|
|
236
254
|
const parts = [`provider=${provider.name}`, `reason=${code.slice("mailing.".length)}`, `status=${details.status === undefined ? "none" : String(details.status)}`];
|
|
237
255
|
if (details.fields !== undefined) parts.push(`fields=${details.fields.join(",")}`);
|
|
238
256
|
if (details.cause !== undefined) parts.push(`cause=${details.cause}`);
|
|
257
|
+
if (details.attachments !== undefined && details.attachments.length > 0) {
|
|
258
|
+
const attachments = details.attachments.map((attachment) => `${getContentTypeEssence(attachment.contentType)}:${String(attachment.content.byteLength)}`);
|
|
259
|
+
parts.push(`attachments=${attachments.join(",")}`);
|
|
260
|
+
}
|
|
239
261
|
console.error(`mailing: send failed ${parts.join(" ")}`);
|
|
240
262
|
}
|
|
@@ -2,7 +2,8 @@
|
|
|
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";
|
|
6
7
|
|
|
7
8
|
const headersSchema = z.record(z.string(), z.string()).superRefine((headers, context) => {
|
|
8
9
|
for (const [name, value] of Object.entries(headers)) {
|
|
@@ -12,7 +13,24 @@ const headersSchema = z.record(z.string(), z.string()).superRefine((headers, con
|
|
|
12
13
|
}
|
|
13
14
|
});
|
|
14
15
|
|
|
15
|
-
const
|
|
16
|
+
const attachmentSchema = z.object({
|
|
17
|
+
filename: z.string().refine(isAttachmentFilename),
|
|
18
|
+
contentType: z.string().trim().refine(isContentType),
|
|
19
|
+
// A Node Buffer is a Uint8Array too; an empty file is almost always a caller's bug.
|
|
20
|
+
content: z.custom<Uint8Array>((value) => value instanceof Uint8Array && value.byteLength > 0),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
/** The attachments within `limits`: count, total bytes and an allowed content type for each. */
|
|
24
|
+
function createAttachmentsSchema(limits: AttachmentLimits) {
|
|
25
|
+
const allowed = new Set(limits.contentTypes);
|
|
26
|
+
return z
|
|
27
|
+
.array(attachmentSchema)
|
|
28
|
+
.max(limits.maxCount)
|
|
29
|
+
.refine((attachments) => attachments.every((attachment) => allowed.has(getContentTypeEssence(attachment.contentType))))
|
|
30
|
+
.refine((attachments) => attachments.reduce((total, attachment) => total + attachment.content.byteLength, 0) <= limits.maxTotalBytes);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const mailFields = {
|
|
16
34
|
to: z.string().trim().refine(isSingleAddress),
|
|
17
35
|
subject: z
|
|
18
36
|
.string()
|
|
@@ -25,12 +43,28 @@ const mailSchema = z.object({
|
|
|
25
43
|
headers: headersSchema.optional(),
|
|
26
44
|
replyTo: z.string().trim().refine(isSingleAddress).optional(),
|
|
27
45
|
kind: z.string().refine(isMailKind).default(TRANSACTIONAL_KIND),
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
function createMailSchema(limits: AttachmentLimits) {
|
|
49
|
+
return z.object({ ...mailFields, attachments: createAttachmentsSchema(limits).optional() }).superRefine((mail, context) => {
|
|
50
|
+
// The module writes these two on list mail; a second, unsigned copy would confuse clients.
|
|
51
|
+
if (mail.kind !== TRANSACTIONAL_KIND && Object.keys(mail.headers ?? {}).some(isListMailHeader)) {
|
|
52
|
+
context.addIssue({ code: "custom", path: ["headers"], message: "list mail gets its unsubscribe headers from the module" });
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** One schema per limits object: the configured limits are parsed once per config, so this builds each once. */
|
|
58
|
+
const mailSchemas = new WeakMap<AttachmentLimits, ReturnType<typeof createMailSchema>>();
|
|
59
|
+
|
|
60
|
+
function getMailSchema(limits: AttachmentLimits): ReturnType<typeof createMailSchema> {
|
|
61
|
+
let schema = mailSchemas.get(limits);
|
|
62
|
+
if (schema === undefined) {
|
|
63
|
+
schema = createMailSchema(limits);
|
|
64
|
+
mailSchemas.set(limits, schema);
|
|
32
65
|
}
|
|
33
|
-
|
|
66
|
+
return schema;
|
|
67
|
+
}
|
|
34
68
|
|
|
35
69
|
const optionsSchema = z.object({
|
|
36
70
|
idempotencyKey: z.string().refine(isIdempotencyKey).optional(),
|
|
@@ -47,25 +81,38 @@ export interface ValidMail {
|
|
|
47
81
|
/** `transactional` or a list kind. */
|
|
48
82
|
readonly kind: string;
|
|
49
83
|
readonly idempotencyKey: string | null;
|
|
84
|
+
/** Empty when the mail has none. */
|
|
85
|
+
readonly attachments: readonly MailAttachment[];
|
|
50
86
|
}
|
|
51
87
|
|
|
52
88
|
export type MailValidation = { readonly ok: true; readonly value: ValidMail } | { readonly ok: false; readonly fields: readonly string[] };
|
|
53
89
|
|
|
54
90
|
/**
|
|
55
|
-
* Checks one mail and its send options
|
|
56
|
-
* `
|
|
91
|
+
* Checks one mail and its send options, its attachments against `limits` (default: the module's defaults; `sendMail`
|
|
92
|
+
* passes the configured ones). `fields` lists what failed (`to`, `subject`, `text`, `html`, `headers`, `replyTo`,
|
|
93
|
+
* `kind`, `attachments`, `idempotencyKey`, or `mail` when the input is not an object).
|
|
57
94
|
*/
|
|
58
|
-
export function validateMail(mail: unknown, options: unknown = {}): MailValidation {
|
|
59
|
-
const parsedMail =
|
|
95
|
+
export function validateMail(mail: unknown, options: unknown = {}, limits: AttachmentLimits = DEFAULT_ATTACHMENT_LIMITS): MailValidation {
|
|
96
|
+
const parsedMail = getMailSchema(limits).safeParse(mail);
|
|
60
97
|
const parsedOptions = optionsSchema.safeParse(options ?? {});
|
|
61
98
|
if (!parsedMail.success || !parsedOptions.success) {
|
|
62
99
|
const issues = [...(parsedMail.error?.issues ?? []), ...(parsedOptions.error?.issues ?? [])];
|
|
63
100
|
const fields = new Set(issues.map((issue) => (issue.path.length === 0 ? "mail" : String(issue.path[0]))));
|
|
64
101
|
return { ok: false, fields: [...fields] };
|
|
65
102
|
}
|
|
66
|
-
const { to, subject, text, html, headers, replyTo, kind } = parsedMail.data;
|
|
103
|
+
const { to, subject, text, html, headers, replyTo, kind, attachments = [] } = parsedMail.data;
|
|
67
104
|
return {
|
|
68
105
|
ok: true,
|
|
69
|
-
value: {
|
|
106
|
+
value: {
|
|
107
|
+
to,
|
|
108
|
+
subject,
|
|
109
|
+
text,
|
|
110
|
+
html: html ?? null,
|
|
111
|
+
headers: headers ?? {},
|
|
112
|
+
replyTo: replyTo ?? null,
|
|
113
|
+
kind,
|
|
114
|
+
idempotencyKey: parsedOptions.data.idempotencyKey ?? null,
|
|
115
|
+
attachments,
|
|
116
|
+
},
|
|
70
117
|
};
|
|
71
118
|
}
|
|
@@ -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
|
+
}
|