@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.
Files changed (67) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +67 -9
  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.d.ts.map +1 -1
  8. package/dist/cli/run.js +76 -32
  9. package/dist/cli/run.js.map +1 -1
  10. package/dist/contract.d.ts +40 -1
  11. package/dist/contract.d.ts.map +1 -1
  12. package/dist/index.d.ts +8 -1
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +2 -1
  15. package/dist/index.js.map +1 -1
  16. package/dist/jobs/delivery-job.d.ts +8 -3
  17. package/dist/jobs/delivery-job.d.ts.map +1 -1
  18. package/dist/jobs/delivery-job.js +18 -2
  19. package/dist/jobs/delivery-job.js.map +1 -1
  20. package/dist/options.d.ts +8 -0
  21. package/dist/options.d.ts.map +1 -1
  22. package/dist/options.js +28 -0
  23. package/dist/options.js.map +1 -1
  24. package/dist/providers/resend.d.ts.map +1 -1
  25. package/dist/providers/resend.js +10 -0
  26. package/dist/providers/resend.js.map +1 -1
  27. package/dist/server/deliveries.d.ts.map +1 -1
  28. package/dist/server/deliveries.js +3 -2
  29. package/dist/server/deliveries.js.map +1 -1
  30. package/dist/server/index.d.ts +1 -1
  31. package/dist/server/index.d.ts.map +1 -1
  32. package/dist/server/index.js +1 -1
  33. package/dist/server/index.js.map +1 -1
  34. package/dist/server/operator-output.d.ts +6 -0
  35. package/dist/server/operator-output.d.ts.map +1 -1
  36. package/dist/server/operator-output.js +23 -0
  37. package/dist/server/operator-output.js.map +1 -1
  38. package/dist/server/send-mail.d.ts +10 -3
  39. package/dist/server/send-mail.d.ts.map +1 -1
  40. package/dist/server/send-mail.js +64 -18
  41. package/dist/server/send-mail.js.map +1 -1
  42. package/dist/server/validate-mail.d.ts +10 -3
  43. package/dist/server/validate-mail.d.ts.map +1 -1
  44. package/dist/server/validate-mail.js +55 -12
  45. package/dist/server/validate-mail.js.map +1 -1
  46. package/dist/testing/fake-provider.d.ts.map +1 -1
  47. package/dist/testing/fake-provider.js +18 -3
  48. package/dist/testing/fake-provider.js.map +1 -1
  49. package/dist/testing/outbox.d.ts.map +1 -1
  50. package/dist/testing/outbox.js +10 -1
  51. package/dist/testing/outbox.js.map +1 -1
  52. package/module.json +1 -1
  53. package/package.json +1 -1
  54. package/src/attachments.ts +90 -0
  55. package/src/cli/run.ts +86 -32
  56. package/src/contract.ts +42 -1
  57. package/src/index.ts +12 -1
  58. package/src/jobs/delivery-job.ts +24 -4
  59. package/src/options.ts +31 -0
  60. package/src/providers/resend.ts +10 -0
  61. package/src/server/deliveries.ts +3 -2
  62. package/src/server/index.ts +1 -1
  63. package/src/server/operator-output.ts +24 -0
  64. package/src/server/send-mail.ts +63 -19
  65. package/src/server/validate-mail.ts +65 -13
  66. package/src/testing/fake-provider.ts +18 -3
  67. 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>;
@@ -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
 
@@ -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
- if (!validateMail(delivery.mail).ok) return { status: "rejected", reason: "mailing.invalid_input" };
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
  }
@@ -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
+ }
@@ -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`). List mail (any kind but `transactional`) also gets a signed unsubscribe
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 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.
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 configured sender, the mail's or the configured reply-to, and for list
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 { from, replyTo } = getMailingOptions(config);
162
- const { idempotencyKey, kind, replyTo: mailReplyTo, ...content } = mail;
163
- const envelope = { from, replyTo: mailReplyTo ?? replyTo ?? null, idempotencyKey };
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 and a fixed cause: anything added "for diagnosis" is where an address leaks first.
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 { 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";
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 mailSchema = z.object({
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
- }).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" });
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. `fields` lists what failed (`to`, `subject`, `text`,
56
- * `html`, `headers`, `replyTo`, `kind`, `idempotencyKey`, or `mail` when the input is not an object).
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 = mailSchema.safeParse(mail);
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: { to, subject, text, html: html ?? null, headers: headers ?? {}, replyTo: replyTo ?? null, kind, idempotencyKey: parsedOptions.data.idempotencyKey ?? null },
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 = { ...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
+ }