@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 +1 @@
1
- {"version":3,"file":"validate-mail.d.ts","sourceRoot":"","sources":["../../src/server/validate-mail.ts"],"names":[],"mappings":"AAsCA,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,QAAQ,CAAC,OAAO,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACnD,+DAA+D;IAC/D,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,sCAAsC;IACtC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;CACxC;AAED,MAAM,MAAM,cAAc,GAAG;IAAE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAA;CAAE,GAAG;IAAE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAA;CAAE,CAAC;AAE3I;;;GAGG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,OAAO,EAAE,OAAO,GAAE,OAAY,GAAG,cAAc,CAajF"}
1
+ {"version":3,"file":"validate-mail.d.ts","sourceRoot":"","sources":["../../src/server/validate-mail.ts"],"names":[],"mappings":"AAIA,OAAO,EAAyF,KAAK,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AACjJ,OAAO,EAAsB,KAAK,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAmEzE,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,QAAQ,CAAC,OAAO,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACnD,+DAA+D;IAC/D,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,sCAAsC;IACtC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IACvC,oCAAoC;IACpC,QAAQ,CAAC,WAAW,EAAE,SAAS,cAAc,EAAE,CAAC;CACjD;AAED,MAAM,MAAM,cAAc,GAAG;IAAE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAA;CAAE,GAAG;IAAE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAA;CAAE,CAAC;AAE3I;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,OAAO,EAAE,OAAO,GAAE,OAAY,EAAE,MAAM,GAAE,gBAA4C,GAAG,cAAc,CAuBvI"}
@@ -2,6 +2,7 @@
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 { DEFAULT_ATTACHMENT_LIMITS, getContentTypeEssence, isAttachmentFilename, isContentType } from "../attachments.js";
5
6
  import { TRANSACTIONAL_KIND } from "../contract.js";
6
7
  const headersSchema = z.record(z.string(), z.string()).superRefine((headers, context) => {
7
8
  for (const [name, value] of Object.entries(headers)) {
@@ -10,7 +11,22 @@ const headersSchema = z.record(z.string(), z.string()).superRefine((headers, con
10
11
  }
11
12
  }
12
13
  });
13
- const mailSchema = z.object({
14
+ const attachmentSchema = z.object({
15
+ filename: z.string().refine(isAttachmentFilename),
16
+ contentType: z.string().trim().refine(isContentType),
17
+ // A Node Buffer is a Uint8Array too; an empty file is almost always a caller's bug.
18
+ content: z.custom((value) => value instanceof Uint8Array && value.byteLength > 0),
19
+ });
20
+ /** The attachments within `limits`: count, total bytes and an allowed content type for each. */
21
+ function createAttachmentsSchema(limits) {
22
+ const allowed = new Set(limits.contentTypes);
23
+ return z
24
+ .array(attachmentSchema)
25
+ .max(limits.maxCount)
26
+ .refine((attachments) => attachments.every((attachment) => allowed.has(getContentTypeEssence(attachment.contentType))))
27
+ .refine((attachments) => attachments.reduce((total, attachment) => total + attachment.content.byteLength, 0) <= limits.maxTotalBytes);
28
+ }
29
+ const mailFields = {
14
30
  to: z.string().trim().refine(isSingleAddress),
15
31
  subject: z
16
32
  .string()
@@ -23,31 +39,55 @@ const mailSchema = z.object({
23
39
  headers: headersSchema.optional(),
24
40
  replyTo: z.string().trim().refine(isSingleAddress).optional(),
25
41
  kind: z.string().refine(isMailKind).default(TRANSACTIONAL_KIND),
26
- }).superRefine((mail, context) => {
27
- // The module writes these two on list mail; a second, unsigned copy would confuse clients.
28
- if (mail.kind !== TRANSACTIONAL_KIND && Object.keys(mail.headers ?? {}).some(isListMailHeader)) {
29
- context.addIssue({ code: "custom", path: ["headers"], message: "list mail gets its unsubscribe headers from the module" });
42
+ };
43
+ function createMailSchema(limits) {
44
+ return z.object({ ...mailFields, attachments: createAttachmentsSchema(limits).optional() }).superRefine((mail, context) => {
45
+ // The module writes these two on list mail; a second, unsigned copy would confuse clients.
46
+ if (mail.kind !== TRANSACTIONAL_KIND && Object.keys(mail.headers ?? {}).some(isListMailHeader)) {
47
+ context.addIssue({ code: "custom", path: ["headers"], message: "list mail gets its unsubscribe headers from the module" });
48
+ }
49
+ });
50
+ }
51
+ /** One schema per limits object: the configured limits are parsed once per config, so this builds each once. */
52
+ const mailSchemas = new WeakMap();
53
+ function getMailSchema(limits) {
54
+ let schema = mailSchemas.get(limits);
55
+ if (schema === undefined) {
56
+ schema = createMailSchema(limits);
57
+ mailSchemas.set(limits, schema);
30
58
  }
31
- });
59
+ return schema;
60
+ }
32
61
  const optionsSchema = z.object({
33
62
  idempotencyKey: z.string().refine(isIdempotencyKey).optional(),
34
63
  });
35
64
  /**
36
- * Checks one mail and its send options. `fields` lists what failed (`to`, `subject`, `text`,
37
- * `html`, `headers`, `replyTo`, `kind`, `idempotencyKey`, or `mail` when the input is not an object).
65
+ * Checks one mail and its send options, its attachments against `limits` (default: the module's defaults; `sendMail`
66
+ * passes the configured ones). `fields` lists what failed (`to`, `subject`, `text`, `html`, `headers`, `replyTo`,
67
+ * `kind`, `attachments`, `idempotencyKey`, or `mail` when the input is not an object).
38
68
  */
39
- export function validateMail(mail, options = {}) {
40
- const parsedMail = mailSchema.safeParse(mail);
69
+ export function validateMail(mail, options = {}, limits = DEFAULT_ATTACHMENT_LIMITS) {
70
+ const parsedMail = getMailSchema(limits).safeParse(mail);
41
71
  const parsedOptions = optionsSchema.safeParse(options ?? {});
42
72
  if (!parsedMail.success || !parsedOptions.success) {
43
73
  const issues = [...(parsedMail.error?.issues ?? []), ...(parsedOptions.error?.issues ?? [])];
44
74
  const fields = new Set(issues.map((issue) => (issue.path.length === 0 ? "mail" : String(issue.path[0]))));
45
75
  return { ok: false, fields: [...fields] };
46
76
  }
47
- const { to, subject, text, html, headers, replyTo, kind } = parsedMail.data;
77
+ const { to, subject, text, html, headers, replyTo, kind, attachments = [] } = parsedMail.data;
48
78
  return {
49
79
  ok: true,
50
- value: { to, subject, text, html: html ?? null, headers: headers ?? {}, replyTo: replyTo ?? null, kind, idempotencyKey: parsedOptions.data.idempotencyKey ?? null },
80
+ value: {
81
+ to,
82
+ subject,
83
+ text,
84
+ html: html ?? null,
85
+ headers: headers ?? {},
86
+ replyTo: replyTo ?? null,
87
+ kind,
88
+ idempotencyKey: parsedOptions.data.idempotencyKey ?? null,
89
+ attachments,
90
+ },
51
91
  };
52
92
  }
53
93
  //# sourceMappingURL=validate-mail.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"validate-mail.js","sourceRoot":"","sources":["../../src/server/validate-mail.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"validate-mail.js","sourceRoot":"","sources":["../../src/server/validate-mail.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"fake-provider.d.ts","sourceRoot":"","sources":["../../src/testing/fake-provider.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,YAAY,EAAE,eAAe,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAErF,4EAA4E;AAC5E,MAAM,WAAW,YAAa,SAAQ,eAAe;IACnD,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,uBAAuB;IACtC,4FAA4F;IAC5F,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B;;;OAGG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,CAAC,OAAO,EAAE,eAAe,KAAK,OAAO,CAAC,eAAe,EAAE;QAAE,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,GAAG,SAAS,CAAC;CAC3G;AAED,MAAM,WAAW,gBAAiB,SAAQ,YAAY;IACpD,gDAAgD;IAChD,QAAQ,CAAC,IAAI,EAAE,SAAS,YAAY,EAAE,CAAC;IACvC,0DAA0D;IAC1D,KAAK,IAAI,IAAI,CAAC;CACf;AAED,wBAAgB,gBAAgB,CAAC,OAAO,GAAE,uBAA4B,GAAG,gBAAgB,CAoCxF"}
1
+ {"version":3,"file":"fake-provider.d.ts","sourceRoot":"","sources":["../../src/testing/fake-provider.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,YAAY,EAAE,eAAe,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAErF,4EAA4E;AAC5E,MAAM,WAAW,YAAa,SAAQ,eAAe;IACnD,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,uBAAuB;IACtC,4FAA4F;IAC5F,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B;;;OAGG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,CAAC,OAAO,EAAE,eAAe,KAAK,OAAO,CAAC,eAAe,EAAE;QAAE,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,GAAG,SAAS,CAAC;CAC3G;AAED,MAAM,WAAW,gBAAiB,SAAQ,YAAY;IACpD,gDAAgD;IAChD,QAAQ,CAAC,IAAI,EAAE,SAAS,YAAY,EAAE,CAAC;IACvC,0DAA0D;IAC1D,KAAK,IAAI,IAAI,CAAC;CACf;AAED,wBAAgB,gBAAgB,CAAC,OAAO,GAAE,uBAA4B,GAAG,gBAAgB,CA0CxF"}
@@ -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
  export function fakeMailProvider(options = {}) {
@@ -9,6 +9,7 @@ export function fakeMailProvider(options = {}) {
9
9
  let counter = 0;
10
10
  return {
11
11
  name: "fake",
12
+ supportsAttachments: true,
12
13
  get sent() {
13
14
  return [...sent];
14
15
  },
@@ -29,9 +30,14 @@ export function fakeMailProvider(options = {}) {
29
30
  if (knownId !== undefined)
30
31
  return { status: "sent", id: knownId };
31
32
  counter += 1;
32
- const captured = { ...message, headers: { ...message.headers }, id: `fake-${String(counter)}-${crypto.randomUUID()}` };
33
+ const captured = {
34
+ ...message,
35
+ headers: { ...message.headers },
36
+ ...(message.attachments === undefined ? {} : { attachments: message.attachments.map((attachment) => ({ ...attachment, content: attachment.content.slice() })) }),
37
+ id: `fake-${String(counter)}-${crypto.randomUUID()}`,
38
+ };
33
39
  if (options.outboxFile !== undefined) {
34
- await appendFile(options.outboxFile, `${JSON.stringify(captured)}\n`, "utf8");
40
+ await appendFile(options.outboxFile, `${JSON.stringify(toOutboxLine(captured))}\n`, "utf8");
35
41
  }
36
42
  sent.push(captured);
37
43
  if (message.idempotencyKey !== null)
@@ -40,4 +46,13 @@ export function fakeMailProvider(options = {}) {
40
46
  },
41
47
  };
42
48
  }
49
+ /** A captured mail as one outbox line: attachment bytes as base64 (`readMailOutbox` decodes them). */
50
+ function toOutboxLine(mail) {
51
+ if (mail.attachments === undefined)
52
+ return mail;
53
+ return {
54
+ ...mail,
55
+ attachments: mail.attachments.map((attachment) => ({ ...attachment, content: Buffer.from(attachment.content).toString("base64") })),
56
+ };
57
+ }
43
58
  //# sourceMappingURL=fake-provider.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"fake-provider.js","sourceRoot":"","sources":["../../src/testing/fake-provider.ts"],"names":[],"mappings":"AAAA,kGAAkG;AAClG,yFAAyF;AACzF,8FAA8F;AAC9F,qCAAqC;AACrC,OAAO,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAyB9C,MAAM,UAAU,gBAAgB,CAAC,UAAmC,EAAE;IACpE,MAAM,IAAI,GAAmB,EAAE,CAAC;IAChC,MAAM,QAAQ,GAAG,IAAI,GAAG,EAAkB,CAAC;IAC3C,IAAI,OAAO,GAAG,CAAC,CAAC;IAEhB,OAAO;QACL,IAAI,EAAE,MAAM;QACZ,IAAI,IAAI;YACN,OAAO,CAAC,GAAG,IAAI,CAAC,CAAC;QACnB,CAAC;QACD,KAAK;YACH,IAAI,CAAC,MAAM,GAAG,CAAC,CAAC;YAChB,QAAQ,CAAC,KAAK,EAAE,CAAC;QACnB,CAAC;QACD,KAAK,CAAC,IAAI,CAAC,OAAO;YAChB,IAAI,OAAO,CAAC,UAAU,KAAK,SAAS,IAAI,OAAO,CAAC,GAAG,CAAC,QAAQ,KAAK,YAAY,EAAE,CAAC;gBAC9E,2DAA2D;gBAC3D,OAAO,CAAC,KAAK,CAAC,2GAA2G,CAAC,CAAC;gBAC3H,OAAO,EAAE,MAAM,EAAE,aAAa,EAAE,CAAC;YACnC,CAAC;YACD,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,EAAE,CAAC,OAAO,CAAC,CAAC;YAC3C,IAAI,OAAO,KAAK,SAAS;gBAAE,OAAO,OAAO,CAAC;YAE1C,MAAM,OAAO,GAAG,OAAO,CAAC,cAAc,KAAK,IAAI,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,QAAQ,CAAC,GAAG,CAAC,OAAO,CAAC,cAAc,CAAC,CAAC;YACnG,IAAI,OAAO,KAAK,SAAS;gBAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,EAAE,EAAE,OAAO,EAAE,CAAC;YAElE,OAAO,IAAI,CAAC,CAAC;YACb,MAAM,QAAQ,GAAiB,EAAE,GAAG,OAAO,EAAE,OAAO,EAAE,EAAE,GAAG,OAAO,CAAC,OAAO,EAAE,EAAE,EAAE,EAAE,QAAQ,MAAM,CAAC,OAAO,CAAC,IAAI,MAAM,CAAC,UAAU,EAAE,EAAE,EAAE,CAAC;YACrI,IAAI,OAAO,CAAC,UAAU,KAAK,SAAS,EAAE,CAAC;gBACrC,MAAM,UAAU,CAAC,OAAO,CAAC,UAAU,EAAE,GAAG,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;YAChF,CAAC;YACD,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;YACpB,IAAI,OAAO,CAAC,cAAc,KAAK,IAAI;gBAAE,QAAQ,CAAC,GAAG,CAAC,OAAO,CAAC,cAAc,EAAE,QAAQ,CAAC,EAAE,CAAC,CAAC;YACvF,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,EAAE,EAAE,QAAQ,CAAC,EAAE,EAAE,CAAC;QAC7C,CAAC;KACF,CAAC;AACJ,CAAC"}
1
+ {"version":3,"file":"fake-provider.js","sourceRoot":"","sources":["../../src/testing/fake-provider.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"outbox.d.ts","sourceRoot":"","sources":["../../src/testing/outbox.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAcvD,gHAAgH;AAChH,wBAAsB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,GAAE;IAAE,QAAQ,CAAC,EAAE,CAAC,EAAE,MAAM,CAAA;CAAO,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC,CAajH"}
1
+ {"version":3,"file":"outbox.d.ts","sourceRoot":"","sources":["../../src/testing/outbox.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAkBvD,gHAAgH;AAChH,wBAAsB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,GAAE;IAAE,QAAQ,CAAC,EAAE,CAAC,EAAE,MAAM,CAAA;CAAO,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC,CAajH"}
@@ -11,6 +11,10 @@ const capturedMailSchema = z.object({
11
11
  html: z.string().nullable(),
12
12
  headers: z.record(z.string(), z.string()),
13
13
  idempotencyKey: z.string().nullable(),
14
+ // Base64 in the file, bytes again here; lines written before attachments existed have none.
15
+ attachments: z
16
+ .array(z.object({ filename: z.string(), contentType: z.string(), content: z.base64().transform((content) => new Uint8Array(Buffer.from(content, "base64"))) }))
17
+ .optional(),
14
18
  });
15
19
  /** Every mail in the outbox, oldest first, optionally only those to `to`. A missing file is an empty outbox. */
16
20
  export async function readMailOutbox(file, filter = {}) {
@@ -26,7 +30,12 @@ export async function readMailOutbox(file, filter = {}) {
26
30
  return text
27
31
  .split("\n")
28
32
  .filter((line) => line !== "")
29
- .map((line) => capturedMailSchema.parse(JSON.parse(line)))
33
+ .map((line) => toCapturedMail(capturedMailSchema.parse(JSON.parse(line))))
30
34
  .filter((mail) => filter.to === undefined || mail.to === filter.to);
31
35
  }
36
+ /** A parsed line without an `attachments: undefined` key, so a mail without attachments equals what was sent. */
37
+ function toCapturedMail(line) {
38
+ const { attachments, ...mail } = line;
39
+ return attachments === undefined ? mail : { ...mail, attachments };
40
+ }
32
41
  //# sourceMappingURL=outbox.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"outbox.js","sourceRoot":"","sources":["../../src/testing/outbox.ts"],"names":[],"mappings":"AAAA,4FAA4F;AAC5F,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAC5C,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAGxB,MAAM,kBAAkB,GAAG,CAAC,CAAC,MAAM,CAAC;IAClC,EAAE,EAAE,CAAC,CAAC,MAAM,EAAE;IACd,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE;IAChB,EAAE,EAAE,CAAC,CAAC,MAAM,EAAE;IACd,OAAO,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;IAC9B,OAAO,EAAE,CAAC,CAAC,MAAM,EAAE;IACnB,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE;IAChB,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;IAC3B,OAAO,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC;IACzC,cAAc,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;CACtC,CAAC,CAAC;AAEH,gHAAgH;AAChH,MAAM,CAAC,KAAK,UAAU,cAAc,CAAC,IAAY,EAAE,SAAmC,EAAE;IACtF,IAAI,IAAY,CAAC;IACjB,IAAI,CAAC;QACH,IAAI,GAAG,MAAM,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;IACtC,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,IAAK,KAA+B,CAAC,IAAI,KAAK,QAAQ;YAAE,OAAO,EAAE,CAAC;QAClE,MAAM,KAAK,CAAC;IACd,CAAC;IACD,OAAO,IAAI;SACR,KAAK,CAAC,IAAI,CAAC;SACX,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,KAAK,EAAE,CAAC;SAC7B,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,kBAAkB,CAAC,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC;SACzD,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,MAAM,CAAC,EAAE,KAAK,SAAS,IAAI,IAAI,CAAC,EAAE,KAAK,MAAM,CAAC,EAAE,CAAC,CAAC;AACxE,CAAC"}
1
+ {"version":3,"file":"outbox.js","sourceRoot":"","sources":["../../src/testing/outbox.ts"],"names":[],"mappings":"AAAA,4FAA4F;AAC5F,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAC5C,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAGxB,MAAM,kBAAkB,GAAG,CAAC,CAAC,MAAM,CAAC;IAClC,EAAE,EAAE,CAAC,CAAC,MAAM,EAAE;IACd,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE;IAChB,EAAE,EAAE,CAAC,CAAC,MAAM,EAAE;IACd,OAAO,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;IAC9B,OAAO,EAAE,CAAC,CAAC,MAAM,EAAE;IACnB,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE;IAChB,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;IAC3B,OAAO,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC;IACzC,cAAc,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE;IACrC,4FAA4F;IAC5F,WAAW,EAAE,CAAC;SACX,KAAK,CAAC,CAAC,CAAC,MAAM,CAAC,EAAE,QAAQ,EAAE,CAAC,CAAC,MAAM,EAAE,EAAE,WAAW,EAAE,CAAC,CAAC,MAAM,EAAE,EAAE,OAAO,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,SAAS,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,IAAI,UAAU,CAAC,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,QAAQ,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;SAC9J,QAAQ,EAAE;CACd,CAAC,CAAC;AAEH,gHAAgH;AAChH,MAAM,CAAC,KAAK,UAAU,cAAc,CAAC,IAAY,EAAE,SAAmC,EAAE;IACtF,IAAI,IAAY,CAAC;IACjB,IAAI,CAAC;QACH,IAAI,GAAG,MAAM,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;IACtC,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,IAAK,KAA+B,CAAC,IAAI,KAAK,QAAQ;YAAE,OAAO,EAAE,CAAC;QAClE,MAAM,KAAK,CAAC;IACd,CAAC;IACD,OAAO,IAAI;SACR,KAAK,CAAC,IAAI,CAAC;SACX,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,KAAK,EAAE,CAAC;SAC7B,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,cAAc,CAAC,kBAAkB,CAAC,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;SACzE,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,MAAM,CAAC,EAAE,KAAK,SAAS,IAAI,IAAI,CAAC,EAAE,KAAK,MAAM,CAAC,EAAE,CAAC,CAAC;AACxE,CAAC;AAED,iHAAiH;AACjH,SAAS,cAAc,CAAC,IAAyC;IAC/D,MAAM,EAAE,WAAW,EAAE,GAAG,IAAI,EAAE,GAAG,IAAI,CAAC;IACtC,OAAO,WAAW,KAAK,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,GAAG,IAAI,EAAE,WAAW,EAAE,CAAC;AACrE,CAAC"}
package/module.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "mailing",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "dependsOn": { "jobs": "*?" },
5
5
  "dbSchema": "mailing",
6
6
  "tables": ["suppressions", "campaigns", "deliveries"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softure-ai/mailing",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "SOFTURE AI mailing module: sendMail over a provider adapter (Resend first) with plain-text and HTML bodies, sender and reply-to from config, idempotency keys, a timeout and a typed result; signed one-click unsubscribe (RFC 8058) with a suppression list for list mail; an exactly-once delivery ledger, campaigns sent from a content file by the softure-mail command, and a sender DNS check; a fake provider for tests.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,90 @@
1
+ // The rules for mail attachments: the default limits and allow-list, the filename and content type checks, and the
2
+ // summary that stands in for an attachment wherever the bytes must not go (logs, dry runs, an app's records).
3
+ import type { AttachmentSummary, MailAttachment } from "./contract.js";
4
+
5
+ /** Files per mail when `attachments.maxCount` is not set. */
6
+ export const DEFAULT_MAX_ATTACHMENTS = 10;
7
+
8
+ /**
9
+ * The most raw bytes all attachments of one mail may hold, and the default. Base64 grows them by a third on the
10
+ * wire: 28,000,000 bytes encode to about 37.3 MB, under the 40 MB a mail may have at Resend and similar providers,
11
+ * with room for the bodies. `attachments.maxTotalBytes` can only lower it.
12
+ */
13
+ export const MAX_ATTACHMENT_BYTES = 28_000_000;
14
+
15
+ /**
16
+ * The content types allowed when `attachments.contentTypes` is not set: documents, data exports, images and calendar
17
+ * invites. HTML, SVG, scripts and executables are left out: a mail client may run or render them.
18
+ */
19
+ export const DEFAULT_ATTACHMENT_CONTENT_TYPES: readonly string[] = [
20
+ "application/pdf",
21
+ "text/plain",
22
+ "text/csv",
23
+ "text/calendar",
24
+ "application/json",
25
+ "application/xml",
26
+ "text/xml",
27
+ "application/zip",
28
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
29
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
30
+ "image/png",
31
+ "image/jpeg",
32
+ "image/gif",
33
+ "image/webp",
34
+ ];
35
+
36
+ export const MAX_ATTACHMENT_FILENAME_LENGTH = 255;
37
+
38
+ /** The limits a mail's attachments are checked against (`mailing({ attachments })`, resolved). */
39
+ export interface AttachmentLimits {
40
+ readonly maxCount: number;
41
+ readonly maxTotalBytes: number;
42
+ /** Lowercase `type/subtype` values. */
43
+ readonly contentTypes: readonly string[];
44
+ }
45
+
46
+ export const DEFAULT_ATTACHMENT_LIMITS: AttachmentLimits = {
47
+ maxCount: DEFAULT_MAX_ATTACHMENTS,
48
+ maxTotalBytes: MAX_ATTACHMENT_BYTES,
49
+ contentTypes: DEFAULT_ATTACHMENT_CONTENT_TYPES,
50
+ };
51
+
52
+ const TOKEN = "[A-Za-z0-9!#$&^_.+-]+";
53
+ const CONTENT_TYPE = new RegExp(`^${TOKEN}/${TOKEN}(\\s*;\\s*${TOKEN}=("[^"\\r\\n]*"|${TOKEN}))*$`);
54
+
55
+ /** A plain filename: 1 to 255 characters, not blank, no `/` or `\`, no control characters, not `.` or `..`. */
56
+ export function isAttachmentFilename(value: string): boolean {
57
+ return (
58
+ value.trim() !== "" &&
59
+ value.length <= MAX_ATTACHMENT_FILENAME_LENGTH &&
60
+ !value.includes("/") &&
61
+ !value.includes("\\") &&
62
+ !hasControlCharacter(value) &&
63
+ value !== "." &&
64
+ value !== ".."
65
+ );
66
+ }
67
+
68
+ /** A C0 control character or DEL. */
69
+ function hasControlCharacter(value: string): boolean {
70
+ for (let index = 0; index < value.length; index += 1) {
71
+ const code = value.charCodeAt(index);
72
+ if (code < 0x20 || code === 0x7f) return true;
73
+ }
74
+ return false;
75
+ }
76
+
77
+ /** A MIME type `type/subtype`, optionally with parameters (`text/csv; charset=utf-8`). */
78
+ export function isContentType(value: string): boolean {
79
+ return CONTENT_TYPE.test(value);
80
+ }
81
+
82
+ /** The `type/subtype` of a content type, lowercase, without its parameters. */
83
+ export function getContentTypeEssence(value: string): string {
84
+ return (value.split(";")[0] ?? "").trim().toLowerCase();
85
+ }
86
+
87
+ /** Every attachment without its bytes: filename, content type and size. Safe to keep in an app's own records. */
88
+ export function describeAttachments(attachments: readonly MailAttachment[]): AttachmentSummary[] {
89
+ return attachments.map(({ filename, contentType, content }) => ({ filename, contentType, size: content.byteLength }));
90
+ }
package/src/cli/run.ts CHANGED
@@ -18,7 +18,7 @@ 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
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";
@@ -636,17 +636,7 @@ const PREVIEW_ADDRESS = "recipient@example.com";
636
636
 
637
637
  /** The mail as the provider would get it, one output line per line, the address masked and signatures redacted. */
638
638
  function printPreview(message: ProviderMessage, output: CliOutput): void {
639
- const headers = [
640
- `From: ${message.from}`,
641
- `To: ${maskAddress(message.to)}`,
642
- ...(message.replyTo === null ? [] : [`Reply-To: ${maskAddress(message.replyTo)}`]),
643
- `Subject: ${message.subject}`,
644
- ...Object.entries(message.headers).map(([name, value]) => `${name}: ${value}`),
645
- ];
646
- const sections = [`--- preview (addresses masked, link signatures redacted) ---`, ...headers, "", "--- text ---", message.text];
647
- if (message.html !== null) sections.push("--- html ---", message.html);
648
- sections.push("--- end of preview ---");
649
- for (const line of redactUnsubscribeSignatures(sections.join("\n")).split("\n")) output.log(line);
639
+ for (const line of formatMailPreview(message)) output.log(line);
650
640
  }
651
641
 
652
642
  /** The fixed mail of `softure-mail test` without a content file. Operator text: the test address is the operator's. */
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.3",
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";