@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
@@ -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.5",
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.5",
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";
@@ -96,11 +96,13 @@ import Writes deliveries the app made before it adopted the module into the l
96
96
  stops the import before anything is written.
97
97
  --dry-run check the file and change nothing
98
98
 
99
- dns Checks SPF, DKIM and DMARC for the sender domain (default: the domain of "from").
99
+ dns Checks SPF, DKIM and DMARC for every sender domain (default: the domains of "from"
100
+ and "senders"; the reply-to and inbound checks go with the domain of "from").
100
101
  --domain <domain> check this domain instead
101
102
  --dkim-selector <name> DKIM selector to look up (default resend; repeatable)
102
103
  --spf-host <host> where SPF is published (default the domain; repeatable;
103
- Resend publishes it on send.<domain>)
104
+ Resend publishes it on send.<domain>); with several sender
105
+ domains, a host counts for the domain it sits under
104
106
  --dmarc-policy <p> require at least this DMARC policy (quarantine or reject)
105
107
  and pct=100; a weaker record fails
106
108
  --dmarc-sp <p> require at least this subdomain policy (sp, else p)
@@ -108,7 +110,8 @@ dns Checks SPF, DKIM and DMARC for the sender domain (default: the domain
108
110
  --dmarc-aspf <a> require this SPF alignment (r or s; absent counts as r)
109
111
  --reply-to <address> check that this address's domain accepts mail (MX, one SPF)
110
112
  (default: replyTo of the config, when --domain is not given)
111
- --return-path <host> a return-path host that must be a CNAME or have MX (repeatable)
113
+ --return-path <host> a return-path host that must be a CNAME or have MX (repeatable;
114
+ with several sender domains, for the domain it sits under)
112
115
  --resend-return-path check Resend's send.<domain> and rsend.<domain> CNAMEs
113
116
  --inbound cloudflare check that Cloudflare Email Routing receives the reply-to domain's
114
117
  mail (its MX and one SPF record that includes it)
@@ -333,26 +336,86 @@ function parseDmarcExpectation(values: ParsedValues): DmarcExpectation | undefin
333
336
  return Object.keys(expectation).length === 0 ? undefined : expectation;
334
337
  }
335
338
 
336
- async function runDns(command: Extract<Command, { kind: "dns" }>, options: RunMailCliOptions, output: CliOutput): Promise<number> {
337
- let domain = command.domain;
338
- let replyTo = command.replyTo;
339
- if (domain === undefined) {
340
- if (options.config === undefined) {
341
- output.error("softure-mail dns: pass --domain <domain>, or run it with the app's config to check the sender's domain");
342
- return EXIT_FAILED;
343
- }
344
- const mailing = getMailingOptions(options.config);
345
- domain = getSenderDomain(mailing.from);
346
- replyTo ??= mailing.replyTo;
339
+ type DnsCommand = Extract<Command, { kind: "dns" }>;
340
+
341
+ /** One domain `softure-mail dns` checks, with the hosts given for it and, for the first, the reply-path checks. */
342
+ interface DnsTarget {
343
+ readonly domain: string;
344
+ readonly spfHosts: readonly string[] | undefined;
345
+ readonly returnPathHosts: readonly string[];
346
+ readonly replyTo: string | undefined;
347
+ readonly inbound: "cloudflare" | undefined;
348
+ }
349
+
350
+ async function runDns(command: DnsCommand, options: RunMailCliOptions, output: CliOutput): Promise<number> {
351
+ const targets = getDnsTargets(command, options.config);
352
+ if (typeof targets === "string") {
353
+ output.error(`softure-mail dns: ${targets}`);
354
+ return EXIT_FAILED;
347
355
  }
348
- const returnPath: ReturnPathHost[] = [...(command.resendReturnPath ? resendReturnPath(domain) : []), ...command.returnPathHosts.map((host) => ({ host }))];
356
+ let failed = 0;
357
+ for (const target of targets) failed += await checkDnsTarget(command, target, options, output);
358
+ return failed === 0 ? EXIT_OK : EXIT_FAILED;
359
+ }
360
+
361
+ /**
362
+ * The domains to check: `--domain` alone, else the distinct domains of `from` and `senders`, `from`'s first. With
363
+ * several, a host option goes to the domain it sits under (the longest that matches); one under none is an error.
364
+ */
365
+ function getDnsTargets(command: DnsCommand, config: SoftureConfig | undefined): DnsTarget[] | string {
366
+ const single = (domain: string, replyTo: string | undefined): DnsTarget[] => [
367
+ { domain, spfHosts: command.spfHosts, returnPathHosts: command.returnPathHosts, replyTo, inbound: command.inbound },
368
+ ];
369
+ if (command.domain !== undefined) return single(command.domain, command.replyTo);
370
+ if (config === undefined) return "pass --domain <domain>, or run it with the app's config to check the sender's domain";
371
+ const mailing = getMailingOptions(config);
372
+ const domains = [...new Set([mailing.from, ...Object.values(mailing.senders ?? {})].map((sender) => getSenderDomain(sender).toLowerCase()))];
373
+ const replyTo = command.replyTo ?? mailing.replyTo;
374
+ const [first = ""] = domains;
375
+ if (domains.length === 1) return single(first, replyTo);
376
+
377
+ const spfHosts = assignHostsToDomains("--spf-host", command.spfHosts ?? [], domains);
378
+ if (typeof spfHosts === "string") return spfHosts;
379
+ const returnPathHosts = assignHostsToDomains("--return-path", command.returnPathHosts, domains);
380
+ if (typeof returnPathHosts === "string") return returnPathHosts;
381
+ return domains.map((domain, index) => ({
382
+ domain,
383
+ spfHosts: spfHosts.get(domain),
384
+ returnPathHosts: returnPathHosts.get(domain) ?? [],
385
+ replyTo: index === 0 ? replyTo : undefined,
386
+ inbound: index === 0 ? command.inbound : undefined,
387
+ }));
388
+ }
389
+
390
+ /** The hosts of `option` by the domain each sits under, or the error for one under none of `domains`. */
391
+ function assignHostsToDomains(option: string, hosts: readonly string[], domains: readonly string[]): Map<string, string[]> | string {
392
+ const byDomain = new Map<string, string[]>();
393
+ for (const host of hosts) {
394
+ const domain = findHostDomain(host, domains);
395
+ if (domain === null) return `${option} ${host} sits under none of the sender domains (${domains.join(", ")})`;
396
+ byDomain.set(domain, [...(byDomain.get(domain) ?? []), host]);
397
+ }
398
+ return byDomain;
399
+ }
400
+
401
+ /** The longest of `domains` that `host` is or sits under, or `null`. */
402
+ function findHostDomain(host: string, domains: readonly string[]): string | null {
403
+ const name = host.trim().toLowerCase().replace(/\.$/, "");
404
+ const matches = domains.filter((domain) => name === domain || name.endsWith(`.${domain}`));
405
+ return matches.reduce<string | null>((longest, domain) => (longest === null || domain.length > longest.length ? domain : longest), null);
406
+ }
407
+
408
+ /** Checks one domain and prints its report; returns how many checks failed. */
409
+ async function checkDnsTarget(command: DnsCommand, target: DnsTarget, options: RunMailCliOptions, output: CliOutput): Promise<number> {
410
+ const { domain, replyTo } = target;
411
+ const returnPath: ReturnPathHost[] = [...(command.resendReturnPath ? resendReturnPath(domain) : []), ...target.returnPathHosts.map((host) => ({ host }))];
349
412
  const report = await checkSenderDns(domain, {
350
413
  ...(command.dkimSelectors === undefined ? {} : { dkimSelectors: command.dkimSelectors }),
351
- ...(command.spfHosts === undefined ? {} : { spfHosts: command.spfHosts }),
414
+ ...(target.spfHosts === undefined ? {} : { spfHosts: target.spfHosts }),
352
415
  ...(command.expectDmarc === undefined ? {} : { expectDmarc: command.expectDmarc }),
353
416
  ...(replyTo === undefined ? {} : { replyTo }),
354
417
  returnPath,
355
- ...(command.inbound === undefined ? {} : { inbound: command.inbound }),
418
+ ...(target.inbound === undefined ? {} : { inbound: target.inbound }),
356
419
  ...(options.resolveTxt === undefined ? {} : { resolveTxt: options.resolveTxt }),
357
420
  ...(options.resolveMx === undefined ? {} : { resolveMx: options.resolveMx }),
358
421
  ...(options.resolveCname === undefined ? {} : { resolveCname: options.resolveCname }),
@@ -376,15 +439,16 @@ async function runDns(command: Extract<Command, { kind: "dns" }>, options: RunMa
376
439
  const failedReply = countFailed(reply);
377
440
  const failedPaths = countFailed(paths);
378
441
  const failedInbound = countFailed(inbound);
379
- if (failedAuthentication + failedReply + failedPaths + failedInbound === 0) {
442
+ const failed = failedAuthentication + failedReply + failedPaths + failedInbound;
443
+ if (failed === 0) {
380
444
  output.log("receivers can authenticate mail from this domain");
381
- return EXIT_OK;
445
+ return 0;
382
446
  }
383
447
  if (failedAuthentication > 0) output.log(`${String(failedAuthentication)} check(s) failed: list mail from this domain will land in spam or be refused`);
384
448
  if (failedReply > 0) output.log("the reply-to domain does not accept mail: replies will bounce");
385
449
  if (failedPaths > 0) output.log(`${String(failedPaths)} return-path host(s) failed: the provider cannot use them for bounces`);
386
450
  if (failedInbound > 0) output.log("the inbound service does not receive this domain's mail: replies will not reach it");
387
- return EXIT_FAILED;
451
+ return failed;
388
452
  }
389
453
 
390
454
  async function runCampaign(command: Extract<Command, { kind: "campaign" }>, options: RunMailCliOptions, output: CliOutput): Promise<number> {
@@ -636,17 +700,7 @@ const PREVIEW_ADDRESS = "recipient@example.com";
636
700
 
637
701
  /** The mail as the provider would get it, one output line per line, the address masked and signatures redacted. */
638
702
  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);
703
+ for (const line of formatMailPreview(message)) output.log(line);
650
704
  }
651
705
 
652
706
  /** 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
@@ -130,6 +130,11 @@ export interface OutgoingMail {
130
130
  * operator). Replaces `mailing({ replyTo })` for this mail; without it the configured one is used.
131
131
  */
132
132
  readonly replyTo?: string;
133
+ /**
134
+ * The name of a sender of `mailing({ senders })`, e.g. `alerts`; without it the mail goes out from `from`. Never an
135
+ * address, so a request can never choose the sender. An unknown name is `mailing.invalid_input` (`fields=sender`).
136
+ */
137
+ readonly sender?: string;
133
138
  /**
134
139
  * `transactional` (the default) for mail the recipient needs whatever they unsubscribed from:
135
140
  * password resets, receipts, account notices. Any other kebab-case name (e.g. `newsletter`) is
@@ -138,6 +143,31 @@ export interface OutgoingMail {
138
143
  * list kind.
139
144
  */
140
145
  readonly kind?: string;
146
+ /**
147
+ * Files sent with the mail, e.g. an invoice PDF or a report. Checked before anything leaves the process: at most
148
+ * `mailing({ attachments: { maxCount } })` files and `maxTotalBytes` bytes in all, a plain filename and a content
149
+ * type from the allow-list. The provider must declare `supportsAttachments`. Never logged: a failure logs the count,
150
+ * the content types and the sizes only.
151
+ */
152
+ readonly attachments?: readonly MailAttachment[];
153
+ }
154
+
155
+ /** One file attached to a mail. */
156
+ export interface MailAttachment {
157
+ /** The name the recipient sees, e.g. `invoice-2026-10.pdf`: 1 to 255 characters, no path separators or control characters. */
158
+ readonly filename: string;
159
+ /** The MIME type, e.g. `application/pdf`; it must be on the module's allow-list (`attachments.contentTypes`). */
160
+ readonly contentType: string;
161
+ /** The file's bytes, at least one. A Node `Buffer` is a `Uint8Array` too. */
162
+ readonly content: Uint8Array;
163
+ }
164
+
165
+ /** What an attachment looks like in a log, a dry run or an app's own records: everything but the bytes. */
166
+ export interface AttachmentSummary {
167
+ readonly filename: string;
168
+ readonly contentType: string;
169
+ /** The content's length in bytes. */
170
+ readonly size: number;
141
171
  }
142
172
 
143
173
  export interface SendMailOptions {
@@ -164,7 +194,10 @@ export type SendMailFailure = Err<MailingErrorCode> & { readonly httpStatus?: nu
164
194
 
165
195
  export type SendMailResult = Ok<SentMail> | SendMailFailure;
166
196
 
167
- /** What a provider receives: a validated mail with the sender from the configuration and the mail's or the configured reply-to. */
197
+ /**
198
+ * What a provider receives: a validated mail with the sender from the configuration (the mail's named sender, else
199
+ * `from`) and the mail's or the configured reply-to.
200
+ */
168
201
  export interface ProviderMessage {
169
202
  readonly from: string;
170
203
  readonly to: string;
@@ -174,6 +207,8 @@ export interface ProviderMessage {
174
207
  readonly html: string | null;
175
208
  readonly headers: Readonly<Record<string, string>>;
176
209
  readonly idempotencyKey: string | null;
210
+ /** The mail's attachments, only when it has any; given only to a provider that declares `supportsAttachments`. */
211
+ readonly attachments?: readonly MailAttachment[];
177
212
  }
178
213
 
179
214
  /**
@@ -194,5 +229,11 @@ export type ProviderFailureStatus = "rejected" | "unavailable" | "refused" | "qu
194
229
  export interface MailProvider {
195
230
  /** A short lowercase name for logs and `SentMail.provider`. */
196
231
  readonly name: string;
232
+ /**
233
+ * True when `send` delivers `ProviderMessage.attachments`. Without it, a mail with attachments is refused as
234
+ * `mailing.invalid_input` before the provider is called, so an adapter written before attachments existed never
235
+ * drops a file silently.
236
+ */
237
+ readonly supportsAttachments?: boolean;
197
238
  send(message: ProviderMessage, context: { readonly signal: AbortSignal }): Promise<ProviderOutcome>;
198
239
  }
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.5",
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. */