@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.
Files changed (65) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +51 -5
  3. package/dist/attachments.d.ts +32 -0
  4. package/dist/attachments.d.ts.map +1 -0
  5. package/dist/attachments.js +68 -0
  6. package/dist/attachments.js.map +1 -0
  7. package/dist/cli/run.js +2 -13
  8. package/dist/cli/run.js.map +1 -1
  9. package/dist/contract.d.ts +31 -0
  10. package/dist/contract.d.ts.map +1 -1
  11. package/dist/index.d.ts +7 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +2 -1
  14. package/dist/index.js.map +1 -1
  15. package/dist/jobs/delivery-job.d.ts +8 -3
  16. package/dist/jobs/delivery-job.d.ts.map +1 -1
  17. package/dist/jobs/delivery-job.js +18 -2
  18. package/dist/jobs/delivery-job.js.map +1 -1
  19. package/dist/options.d.ts +5 -0
  20. package/dist/options.d.ts.map +1 -1
  21. package/dist/options.js +18 -0
  22. package/dist/options.js.map +1 -1
  23. package/dist/providers/resend.d.ts.map +1 -1
  24. package/dist/providers/resend.js +10 -0
  25. package/dist/providers/resend.js.map +1 -1
  26. package/dist/server/deliveries.js +1 -1
  27. package/dist/server/deliveries.js.map +1 -1
  28. package/dist/server/index.d.ts +1 -1
  29. package/dist/server/index.d.ts.map +1 -1
  30. package/dist/server/index.js +1 -1
  31. package/dist/server/index.js.map +1 -1
  32. package/dist/server/operator-output.d.ts +6 -0
  33. package/dist/server/operator-output.d.ts.map +1 -1
  34. package/dist/server/operator-output.js +23 -0
  35. package/dist/server/operator-output.js.map +1 -1
  36. package/dist/server/send-mail.d.ts +4 -3
  37. package/dist/server/send-mail.d.ts.map +1 -1
  38. package/dist/server/send-mail.js +36 -15
  39. package/dist/server/send-mail.js.map +1 -1
  40. package/dist/server/validate-mail.d.ts +8 -3
  41. package/dist/server/validate-mail.d.ts.map +1 -1
  42. package/dist/server/validate-mail.js +52 -12
  43. package/dist/server/validate-mail.js.map +1 -1
  44. package/dist/testing/fake-provider.d.ts.map +1 -1
  45. package/dist/testing/fake-provider.js +18 -3
  46. package/dist/testing/fake-provider.js.map +1 -1
  47. package/dist/testing/outbox.d.ts.map +1 -1
  48. package/dist/testing/outbox.js +10 -1
  49. package/dist/testing/outbox.js.map +1 -1
  50. package/module.json +1 -1
  51. package/package.json +1 -1
  52. package/src/attachments.ts +90 -0
  53. package/src/cli/run.ts +2 -12
  54. package/src/contract.ts +33 -0
  55. package/src/index.ts +12 -1
  56. package/src/jobs/delivery-job.ts +24 -4
  57. package/src/options.ts +19 -0
  58. package/src/providers/resend.ts +10 -0
  59. package/src/server/deliveries.ts +1 -1
  60. package/src/server/index.ts +1 -1
  61. package/src/server/operator-output.ts +24 -0
  62. package/src/server/send-mail.ts +38 -16
  63. package/src/server/validate-mail.ts +60 -13
  64. package/src/testing/fake-provider.ts +18 -3
  65. 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
+ }
@@ -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`). List mail (any kind but `transactional`) also gets a signed unsubscribe
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 mail the
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 and a fixed cause: anything added "for diagnosis" is where an address leaks first.
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 { TRANSACTIONAL_KIND } from "../contract.js";
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 mailSchema = z.object({
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
- }).superRefine((mail, context) => {
29
- // The module writes these two on list mail; a second, unsigned copy would confuse clients.
30
- if (mail.kind !== TRANSACTIONAL_KIND && Object.keys(mail.headers ?? {}).some(isListMailHeader)) {
31
- context.addIssue({ code: "custom", path: ["headers"], message: "list mail gets its unsubscribe headers from the module" });
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. `fields` lists what failed (`to`, `subject`, `text`,
56
- * `html`, `headers`, `replyTo`, `kind`, `idempotencyKey`, or `mail` when the input is not an object).
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 = mailSchema.safeParse(mail);
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: { to, subject, text, html: html ?? null, headers: headers ?? {}, replyTo: replyTo ?? null, kind, idempotencyKey: parsedOptions.data.idempotencyKey ?? null },
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 = { ...message, headers: { ...message.headers }, id: `fake-${String(counter)}-${crypto.randomUUID()}` };
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
+ }
@@ -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
+ }