@softure-ai/mailing 0.2.2 → 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 (69) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +57 -6
  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/command.js +8 -1
  8. package/dist/cli/command.js.map +1 -1
  9. package/dist/cli/run.d.ts.map +1 -1
  10. package/dist/cli/run.js +10 -20
  11. package/dist/cli/run.js.map +1 -1
  12. package/dist/contract.d.ts +31 -0
  13. package/dist/contract.d.ts.map +1 -1
  14. package/dist/index.d.ts +7 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +2 -1
  17. package/dist/index.js.map +1 -1
  18. package/dist/jobs/delivery-job.d.ts +8 -3
  19. package/dist/jobs/delivery-job.d.ts.map +1 -1
  20. package/dist/jobs/delivery-job.js +18 -2
  21. package/dist/jobs/delivery-job.js.map +1 -1
  22. package/dist/options.d.ts +5 -0
  23. package/dist/options.d.ts.map +1 -1
  24. package/dist/options.js +18 -0
  25. package/dist/options.js.map +1 -1
  26. package/dist/providers/resend.d.ts.map +1 -1
  27. package/dist/providers/resend.js +10 -0
  28. package/dist/providers/resend.js.map +1 -1
  29. package/dist/server/deliveries.js +1 -1
  30. package/dist/server/deliveries.js.map +1 -1
  31. package/dist/server/index.d.ts +1 -1
  32. package/dist/server/index.d.ts.map +1 -1
  33. package/dist/server/index.js +1 -1
  34. package/dist/server/index.js.map +1 -1
  35. package/dist/server/operator-output.d.ts +6 -0
  36. package/dist/server/operator-output.d.ts.map +1 -1
  37. package/dist/server/operator-output.js +23 -0
  38. package/dist/server/operator-output.js.map +1 -1
  39. package/dist/server/send-mail.d.ts +10 -3
  40. package/dist/server/send-mail.d.ts.map +1 -1
  41. package/dist/server/send-mail.js +57 -13
  42. package/dist/server/send-mail.js.map +1 -1
  43. package/dist/server/validate-mail.d.ts +8 -3
  44. package/dist/server/validate-mail.d.ts.map +1 -1
  45. package/dist/server/validate-mail.js +52 -12
  46. package/dist/server/validate-mail.js.map +1 -1
  47. package/dist/testing/fake-provider.d.ts.map +1 -1
  48. package/dist/testing/fake-provider.js +18 -3
  49. package/dist/testing/fake-provider.js.map +1 -1
  50. package/dist/testing/outbox.d.ts.map +1 -1
  51. package/dist/testing/outbox.js +10 -1
  52. package/dist/testing/outbox.js.map +1 -1
  53. package/module.json +1 -1
  54. package/package.json +1 -1
  55. package/src/attachments.ts +90 -0
  56. package/src/cli/command.ts +10 -2
  57. package/src/cli/run.ts +10 -20
  58. package/src/contract.ts +33 -0
  59. package/src/index.ts +12 -1
  60. package/src/jobs/delivery-job.ts +24 -4
  61. package/src/options.ts +19 -0
  62. package/src/providers/resend.ts +10 -0
  63. package/src/server/deliveries.ts +1 -1
  64. package/src/server/index.ts +1 -1
  65. package/src/server/operator-output.ts +24 -0
  66. package/src/server/send-mail.ts +61 -14
  67. package/src/server/validate-mail.ts +60 -13
  68. package/src/testing/fake-provider.ts +18 -3
  69. package/src/testing/outbox.ts +11 -1
package/src/cli/run.ts CHANGED
@@ -18,9 +18,9 @@ import { listConfiguredCampaignRecipients, planCampaign, sendCampaign, type Camp
18
18
  import type { DeliveryContext, RunHaltReason } from "../server/deliveries.js";
19
19
  import { checkSenderDns, getSenderDomain, resendReturnPath, type DmarcAlignment, type DmarcExpectation, type DmarcPolicy, type DnsCheck, type ResolveCname, type ResolveMx, type ResolveTxt, type ReturnPathHost } from "../server/dns.js";
20
20
  import { checkImportedDeliveries, importDeliveries, type ImportedDelivery, type ImportProblem } from "../server/import-deliveries.js";
21
- import { maskAddress, redactUnsubscribeSignatures } from "../server/operator-output.js";
21
+ import { formatMailPreview, maskAddress } from "../server/operator-output.js";
22
22
  import { getMailingOptions, resolveMailKind } from "../server/options.js";
23
- import { previewMail, sendMail } from "../server/send-mail.js";
23
+ import { previewMail, sendMail, sendMailUnchecked } from "../server/send-mail.js";
24
24
  import { MIN_UNSUBSCRIBE_SECRET_LENGTH, readUnsubscribeSecrets, UNSUBSCRIBE_SECRET_ENV, type Env } from "../server/unsubscribe-link.js";
25
25
 
26
26
  export interface CliOutput {
@@ -85,7 +85,8 @@ test Sends one mail to testAddress of the mailing options, and to no one el
85
85
  campaign in <content-file> (without the ledger: it can be sent again), or a short
86
86
  fixed test mail.
87
87
  --kind <kind> the mail's kind (default: the file's, else transactional); list kinds
88
- get the unsubscribe footer and headers
88
+ get the unsubscribe footer and headers, and are refused for a test address
89
+ that unsubscribed; without a database that check is skipped
89
90
  --preview print the mail instead of sending it
90
91
 
91
92
  import Writes deliveries the app made before it adopted the module into the ledger, so
@@ -635,17 +636,7 @@ const PREVIEW_ADDRESS = "recipient@example.com";
635
636
 
636
637
  /** The mail as the provider would get it, one output line per line, the address masked and signatures redacted. */
637
638
  function printPreview(message: ProviderMessage, output: CliOutput): void {
638
- const headers = [
639
- `From: ${message.from}`,
640
- `To: ${maskAddress(message.to)}`,
641
- ...(message.replyTo === null ? [] : [`Reply-To: ${maskAddress(message.replyTo)}`]),
642
- `Subject: ${message.subject}`,
643
- ...Object.entries(message.headers).map(([name, value]) => `${name}: ${value}`),
644
- ];
645
- const sections = [`--- preview (addresses masked, link signatures redacted) ---`, ...headers, "", "--- text ---", message.text];
646
- if (message.html !== null) sections.push("--- html ---", message.html);
647
- sections.push("--- end of preview ---");
648
- for (const line of redactUnsubscribeSignatures(sections.join("\n")).split("\n")) output.log(line);
639
+ for (const line of formatMailPreview(message)) output.log(line);
649
640
  }
650
641
 
651
642
  /** The fixed mail of `softure-mail test` without a content file. Operator text: the test address is the operator's. */
@@ -687,10 +678,8 @@ async function runTest(command: Extract<Command, { kind: "test" }>, options: Run
687
678
  return EXIT_OK;
688
679
  }
689
680
  if (kind === TRANSACTIONAL_KIND) return reportTestSend(await sendMail({ config }, mail), { masked, kind }, output);
690
- if (config.database === null) {
691
- output.error("softure-mail test: list mail needs the database (its suppression list); set database.url in softure.config");
692
- return EXIT_FAILED;
693
- }
681
+ // The test address is the operator's own: without a database (CI, an operator machine) its suppression check guards nothing.
682
+ if (config.database === null) return reportTestSend(await sendMailUnchecked(config, mail), { masked, kind, unchecked: true }, output);
694
683
  let opened: CommandDatabase;
695
684
  try {
696
685
  opened = await openDatabase(config.database, options.openDatabase);
@@ -705,9 +694,10 @@ async function runTest(command: Extract<Command, { kind: "test" }>, options: Run
705
694
  }
706
695
  }
707
696
 
708
- function reportTestSend(result: Awaited<ReturnType<typeof sendMail>>, sent: { readonly masked: string; readonly kind: string }, output: CliOutput): number {
697
+ function reportTestSend(result: Awaited<ReturnType<typeof sendMail>>, sent: { readonly masked: string; readonly kind: string; readonly unchecked?: boolean }, output: CliOutput): number {
709
698
  if (result.ok) {
710
- output.log(`test mail (${sent.kind}) sent to ${sent.masked}; provider message id ${result.value.id}`);
699
+ const note = sent.unchecked === true ? "; no database, so the suppression list was not checked" : "";
700
+ output.log(`test mail (${sent.kind}) sent to ${sent.masked}; provider message id ${result.value.id}${note}`);
711
701
  return EXIT_OK;
712
702
  }
713
703
  const status = result.httpStatus === undefined ? "" : ` (HTTP ${String(result.httpStatus)})`;
package/src/contract.ts CHANGED
@@ -138,6 +138,31 @@ export interface OutgoingMail {
138
138
  * list kind.
139
139
  */
140
140
  readonly kind?: string;
141
+ /**
142
+ * Files sent with the mail, e.g. an invoice PDF or a report. Checked before anything leaves the process: at most
143
+ * `mailing({ attachments: { maxCount } })` files and `maxTotalBytes` bytes in all, a plain filename and a content
144
+ * type from the allow-list. The provider must declare `supportsAttachments`. Never logged: a failure logs the count,
145
+ * the content types and the sizes only.
146
+ */
147
+ readonly attachments?: readonly MailAttachment[];
148
+ }
149
+
150
+ /** One file attached to a mail. */
151
+ export interface MailAttachment {
152
+ /** The name the recipient sees, e.g. `invoice-2026-10.pdf`: 1 to 255 characters, no path separators or control characters. */
153
+ readonly filename: string;
154
+ /** The MIME type, e.g. `application/pdf`; it must be on the module's allow-list (`attachments.contentTypes`). */
155
+ readonly contentType: string;
156
+ /** The file's bytes, at least one. A Node `Buffer` is a `Uint8Array` too. */
157
+ readonly content: Uint8Array;
158
+ }
159
+
160
+ /** What an attachment looks like in a log, a dry run or an app's own records: everything but the bytes. */
161
+ export interface AttachmentSummary {
162
+ readonly filename: string;
163
+ readonly contentType: string;
164
+ /** The content's length in bytes. */
165
+ readonly size: number;
141
166
  }
142
167
 
143
168
  export interface SendMailOptions {
@@ -174,6 +199,8 @@ export interface ProviderMessage {
174
199
  readonly html: string | null;
175
200
  readonly headers: Readonly<Record<string, string>>;
176
201
  readonly idempotencyKey: string | null;
202
+ /** The mail's attachments, only when it has any; given only to a provider that declares `supportsAttachments`. */
203
+ readonly attachments?: readonly MailAttachment[];
177
204
  }
178
205
 
179
206
  /**
@@ -194,5 +221,11 @@ export type ProviderFailureStatus = "rejected" | "unavailable" | "refused" | "qu
194
221
  export interface MailProvider {
195
222
  /** A short lowercase name for logs and `SentMail.provider`. */
196
223
  readonly name: string;
224
+ /**
225
+ * True when `send` delivers `ProviderMessage.attachments`. Without it, a mail with attachments is refused as
226
+ * `mailing.invalid_input` before the provider is called, so an adapter written before attachments existed never
227
+ * drops a file silently.
228
+ */
229
+ readonly supportsAttachments?: boolean;
197
230
  send(message: ProviderMessage, context: { readonly signal: AbortSignal }): Promise<ProviderOutcome>;
198
231
  }
package/src/index.ts CHANGED
@@ -17,7 +17,7 @@ export const MODULE_ID = "mailing";
17
17
  export const mailing = defineModule({
18
18
  manifest: {
19
19
  id: MODULE_ID,
20
- version: "0.2.2",
20
+ version: "0.2.4",
21
21
  dependsOn: { jobs: "*?" },
22
22
  dbSchema: "mailing",
23
23
  tables: ["suppressions", "campaigns", "deliveries"],
@@ -64,14 +64,25 @@ export {
64
64
  MAX_SUBJECT_LENGTH,
65
65
  RESERVED_HEADERS,
66
66
  } from "./address.js";
67
+ export {
68
+ DEFAULT_ATTACHMENT_CONTENT_TYPES,
69
+ DEFAULT_MAX_ATTACHMENTS,
70
+ describeAttachments,
71
+ isAttachmentFilename,
72
+ MAX_ATTACHMENT_BYTES,
73
+ MAX_ATTACHMENT_FILENAME_LENGTH,
74
+ type AttachmentLimits,
75
+ } from "./attachments.js";
67
76
  export { HALTING_ERROR_CODES, TRANSACTIONAL_KIND } from "./contract.js";
68
77
  export type {
78
+ AttachmentSummary,
69
79
  CampaignRecipient,
70
80
  CampaignRecipientFilter,
71
81
  CampaignRecipientSource,
72
82
  DeliveryJobErrorCode,
73
83
  LegacyUnsubscribe,
74
84
  LegacyUnsubscribeParams,
85
+ MailAttachment,
75
86
  MailingErrorCode,
76
87
  MailProvider,
77
88
  OnUnsubscribedHook,
@@ -1,7 +1,8 @@
1
1
  // Deliveries through @softure-ai/jobs: the app enqueues a delivery in its own transaction and the jobs worker sends
2
2
  // it, retrying while the provider is unavailable. The job runs at least once; the delivery ledger and its idempotency
3
3
  // key make the mail go out once. The job payload holds the ledger scope and the app's ids, never the mail: the
4
- // handler builds the mail from the app's data when it runs.
4
+ // handler builds the mail from the app's data when it runs. Attachments too: the app keeps the file in its own storage
5
+ // (with its own retention) and `buildMail` loads it by reference on every attempt; bytes in the payload are refused.
5
6
  import { err, ok, type Ok } from "@softure-ai/core";
6
7
  import { defineJobHandler, failJob, retryJobLater, type JobFailure, type JobHandler, type JobHandlerContext } from "@softure-ai/jobs";
7
8
  import { enqueueJob, type EnqueuedJob, type JobsContext } from "@softure-ai/jobs/server";
@@ -22,7 +23,10 @@ export interface EnqueueDeliveryInput<TPayload> {
22
23
  * dedup key: while a job for this scope is pending or running, a second enqueue returns it.
23
24
  */
24
25
  readonly scope: string;
25
- /** The app's ids the handler builds the mail from. JSON; no addresses, names or tokens. */
26
+ /**
27
+ * The app's ids the handler builds the mail from. JSON; no addresses, names or tokens, and no file content: an
28
+ * attachment is referenced by the app's id for it and loaded in `buildMail`.
29
+ */
26
30
  readonly payload: TPayload;
27
31
  /** When the job becomes due; default now. Not together with `delaySeconds`. */
28
32
  readonly runAt?: Date;
@@ -33,12 +37,18 @@ export interface EnqueueDeliveryInput<TPayload> {
33
37
 
34
38
  /**
35
39
  * Enqueues a delivery. Pass the caller's transaction as `ctx.db`, so the job exists exactly when the caller's own
36
- * change commits. Throws for a malformed scope, and like `enqueueJob` (an unknown queue, a database failure).
40
+ * change commits. Throws for a malformed scope, for a payload that holds binary data (a `Uint8Array`, `Buffer` or
41
+ * `ArrayBuffer`: reference the file instead), and like `enqueueJob` (an unknown queue, a database failure).
37
42
  */
38
43
  export async function enqueueDelivery<TPayload>(ctx: JobsContext, input: EnqueueDeliveryInput<TPayload>): Promise<EnqueuedJob> {
39
44
  if (!isDeliveryScope(input.scope)) {
40
45
  throw new Error(`@softure-ai/mailing: enqueueDelivery scope "${input.scope}" must be lowercase letters, digits and ._:- (at most 128 characters)`);
41
46
  }
47
+ if (hasBinaryData(input.payload)) {
48
+ throw new Error(
49
+ `@softure-ai/mailing: enqueueDelivery payload for scope "${input.scope}" holds binary data; store the file in the app and pass its id, then load it in buildMail`,
50
+ );
51
+ }
42
52
  return enqueueJob(ctx, {
43
53
  queue: input.queue,
44
54
  payload: { scope: input.scope, payload: input.payload },
@@ -49,6 +59,15 @@ export async function enqueueDelivery<TPayload>(ctx: JobsContext, input: Enqueue
49
59
  });
50
60
  }
51
61
 
62
+ /** True when `value` holds an `ArrayBuffer` or a view of one (`Uint8Array`, `Buffer`) anywhere in its objects and arrays. */
63
+ function hasBinaryData(value: unknown, seen: Set<object> = new Set()): boolean {
64
+ if (typeof value !== "object" || value === null) return false;
65
+ if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) return true;
66
+ if (seen.has(value)) return false;
67
+ seen.add(value);
68
+ return Object.values(value).some((item) => hasBinaryData(item, seen));
69
+ }
70
+
52
71
  /** What `buildMail` receives besides the app's payload: the job's context and the delivery's scope. */
53
72
  export interface DeliveryJobContext extends JobHandlerContext {
54
73
  readonly scope: string;
@@ -60,7 +79,8 @@ export interface DeliveryJobHandlerOptions<TSchema extends z.ZodType> {
60
79
  /**
61
80
  * Builds the mail from the app's data. Called on every attempt, before the ledger is read, so it must have no side
62
81
  * effects and return the same mail each time: a different body under the same idempotency key is refused by the
63
- * provider. Return `null` when there is nothing to send any more (the account is gone); the job then succeeds.
82
+ * provider. Attachments are loaded here from the app's storage by the ids in the payload; keep the file until the
83
+ * job is done. Return `null` when there is nothing to send any more (the account is gone); the job then succeeds.
64
84
  */
65
85
  readonly buildMail: (payload: z.output<TSchema>, context: DeliveryJobContext) => Promise<OutgoingMail | null>;
66
86
  /** The ledger's claim windows; `maxAttempts` is the job's, so the ledger does not count attempts here. */
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;
@@ -55,6 +56,22 @@ function isMailProvider(value: unknown): value is MailProvider {
55
56
  return typeof candidate.name === "string" && candidate.name !== "" && typeof candidate.send === "function";
56
57
  }
57
58
 
59
+ const attachmentsOptionsSchema = z.strictObject({
60
+ /** Files per mail, 1 to 100. Default 10. */
61
+ maxCount: z.number().int().min(1).max(100).default(DEFAULT_MAX_ATTACHMENTS),
62
+ /** Raw bytes of all attachments of one mail, at most (and by default) 28,000,000: about 37.3 MB base64. */
63
+ maxTotalBytes: z.number().int().min(1).max(MAX_ATTACHMENT_BYTES).default(MAX_ATTACHMENT_BYTES),
64
+ /**
65
+ * The content types an attachment may have, e.g. `["application/pdf"]`; replaces the default list
66
+ * (`DEFAULT_ATTACHMENT_CONTENT_TYPES`). Compared without parameters and case.
67
+ */
68
+ contentTypes: z
69
+ .array(z.string().trim().refine(isContentType, "must be a MIME type such as application/pdf"))
70
+ .min(1)
71
+ .transform((types) => [...new Set(types.map(getContentTypeEssence))])
72
+ .default([...DEFAULT_ATTACHMENT_CONTENT_TYPES]),
73
+ });
74
+
58
75
  export const mailingOptionsSchema = z.strictObject({
59
76
  /**
60
77
  * The sender of every mail: `hello@example.com` or `Plan <hello@example.com>`. It must be on a
@@ -108,6 +125,8 @@ export const mailingOptionsSchema = z.strictObject({
108
125
  * idempotency window. More than `staleClaimMs`, at most 30 days.
109
126
  */
110
127
  uncertainClaimMs: z.number().int().min(60_000).max(30 * 24 * HOUR_MS).default(DEFAULT_UNCERTAIN_CLAIM_MS),
128
+ /** Limits on attachments: count, total size and allowed content types. Defaults fit Resend's 40 MB cap. */
129
+ attachments: attachmentsOptionsSchema.prefault({}),
111
130
  }).refine((options) => options.uncertainClaimMs > options.staleClaimMs, { message: "must be more than staleClaimMs", path: ["uncertainClaimMs"] });
112
131
 
113
132
  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
 
@@ -167,7 +167,7 @@ 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
+ if (!validateMail(delivery.mail, {}, getMailingOptions(ctx.config).attachments).ok) return { status: "rejected", reason: "mailing.invalid_input" };
171
171
  if (kind !== TRANSACTIONAL_KIND && (await isSuppressed(ctx, delivery.mail.to))) return { status: "rejected", reason: "mailing.suppressed" };
172
172
  return { status: "send" };
173
173
  }
@@ -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,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,29 +45,56 @@ 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
  }
61
65
 
62
- const outcome = await callProvider(provider, composeMessage(context.config, valid, secret), timeoutMs);
66
+ return deliver(provider, composeMessage(context.config, valid, secret), timeoutMs);
67
+ }
68
+
69
+ /**
70
+ * `sendMail` without the suppression check, for `softure-mail test` without a database: its recipient is always the
71
+ * operator's own `testAddress`. List mail still needs the secret. Internal: not exported from `/server`, so no app
72
+ * code can send list mail to a real recipient past the suppression list.
73
+ */
74
+ export async function sendMailUnchecked(config: SoftureConfig, mail: OutgoingMail, options: SendMailOptions = {}): Promise<SendMailResult> {
75
+ const { provider, timeoutMs, attachments: limits } = getMailingOptions(config);
76
+ const validation = validateMail(mail, options, limits);
77
+ if (!validation.ok) {
78
+ logFailure(provider, "mailing.invalid_input", { fields: validation.fields });
79
+ return err("mailing.invalid_input");
80
+ }
81
+ if (!canCarryAttachments(provider, validation.value)) return refuseAttachments(provider, validation.value);
82
+ const secret = readUnsubscribeSecrets().current;
83
+ if (validation.value.kind !== TRANSACTIONAL_KIND && secret === null) {
84
+ logFailure(provider, "mailing.unavailable", { cause: "no_unsubscribe_secret", attachments: validation.value.attachments });
85
+ return err("mailing.unavailable");
86
+ }
87
+ return deliver(provider, composeMessage(config, validation.value, secret), timeoutMs);
88
+ }
89
+
90
+ /** Hands `message` to the provider and maps its outcome to the result, logging a failure. */
91
+ async function deliver(provider: MailProvider, message: ProviderMessage, timeoutMs: number): Promise<SendMailResult> {
92
+ const outcome = await callProvider(provider, message, timeoutMs);
63
93
  if (outcome.status === "sent") {
64
94
  return ok({ id: outcome.id, provider: provider.name });
65
95
  }
66
96
  const code = FAILURE_CODES[outcome.status];
67
- logFailure(provider, code, { status: outcome.httpStatus });
97
+ logFailure(provider, code, { status: outcome.httpStatus, attachments: message.attachments ?? [] });
68
98
  return {
69
99
  ...err(code),
70
100
  ...(outcome.httpStatus === undefined ? {} : { httpStatus: outcome.httpStatus }),
@@ -72,6 +102,16 @@ export async function sendMail(context: MailContext, mail: OutgoingMail, options
72
102
  };
73
103
  }
74
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
+
75
115
  /** Why `previewMail` cannot render a mail: invalid input (with the fields), or list mail without the secret. */
76
116
  export type MailPreviewFailure =
77
117
  | (Err<"mailing.invalid_input"> & { readonly fields: readonly string[] })
@@ -85,14 +125,15 @@ export interface PreviewMailOptions extends SendMailOptions {
85
125
  }
86
126
 
87
127
  /**
88
- * Exactly what `sendMail` would hand the provider for `mail`: the sender, the reply-to, and for list mail the
89
- * footer with a link signed for `mail.to` and the RFC 8058 headers. Sends nothing, logs nothing and needs no
90
- * 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.
91
132
  * The links in it are credentials for `mail.to`. Throws only when the module is not enabled.
92
133
  */
93
134
  export function previewMail(config: SoftureConfig, mail: OutgoingMail, options: PreviewMailOptions = {}): MailPreviewResult {
94
135
  const { env, ...sendOptions } = options;
95
- const validation = validateMail(mail, sendOptions);
136
+ const validation = validateMail(mail, sendOptions, getMailingOptions(config).attachments);
96
137
  if (!validation.ok) return { ...err("mailing.invalid_input"), fields: validation.fields };
97
138
  const secret = readUnsubscribeSecrets(env).current;
98
139
  if (validation.value.kind !== TRANSACTIONAL_KIND && secret === null) return { ...err("mailing.unavailable"), cause: "no_unsubscribe_secret" };
@@ -134,8 +175,8 @@ async function checkListMail(context: MailContext, mail: ValidMail, secret: stri
134
175
  */
135
176
  function composeMessage(config: SoftureConfig, mail: ValidMail, secret: string | null): ProviderMessage {
136
177
  const { from, replyTo } = getMailingOptions(config);
137
- const { idempotencyKey, kind, replyTo: mailReplyTo, ...content } = mail;
138
- 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 }) };
139
180
  if (kind === TRANSACTIONAL_KIND || secret === null) return { ...content, ...envelope };
140
181
  const links = buildUnsubscribeLinks(config, mail.to, secret);
141
182
  // The module factory merged the dictionaries; their shape is the module's own.
@@ -201,15 +242,21 @@ function isHttpStatus(value: unknown): value is number {
201
242
 
202
243
  /**
203
244
  * The module's only log line. Deliberately takes nothing but the provider, the code, a status,
204
- * 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.
205
248
  */
206
249
  function logFailure(
207
250
  provider: MailProvider,
208
251
  code: MailingErrorCode,
209
- 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[] },
210
253
  ): void {
211
254
  const parts = [`provider=${provider.name}`, `reason=${code.slice("mailing.".length)}`, `status=${details.status === undefined ? "none" : String(details.status)}`];
212
255
  if (details.fields !== undefined) parts.push(`fields=${details.fields.join(",")}`);
213
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
+ }
214
261
  console.error(`mailing: send failed ${parts.join(" ")}`);
215
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
  }