@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.
- package/CHANGELOG.md +43 -0
- package/README.md +67 -9
- package/dist/attachments.d.ts +32 -0
- package/dist/attachments.d.ts.map +1 -0
- package/dist/attachments.js +68 -0
- package/dist/attachments.js.map +1 -0
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +76 -32
- package/dist/cli/run.js.map +1 -1
- package/dist/contract.d.ts +40 -1
- package/dist/contract.d.ts.map +1 -1
- package/dist/index.d.ts +8 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/jobs/delivery-job.d.ts +8 -3
- package/dist/jobs/delivery-job.d.ts.map +1 -1
- package/dist/jobs/delivery-job.js +18 -2
- package/dist/jobs/delivery-job.js.map +1 -1
- package/dist/options.d.ts +8 -0
- package/dist/options.d.ts.map +1 -1
- package/dist/options.js +28 -0
- package/dist/options.js.map +1 -1
- package/dist/providers/resend.d.ts.map +1 -1
- package/dist/providers/resend.js +10 -0
- package/dist/providers/resend.js.map +1 -1
- package/dist/server/deliveries.d.ts.map +1 -1
- package/dist/server/deliveries.js +3 -2
- package/dist/server/deliveries.js.map +1 -1
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/index.js.map +1 -1
- package/dist/server/operator-output.d.ts +6 -0
- package/dist/server/operator-output.d.ts.map +1 -1
- package/dist/server/operator-output.js +23 -0
- package/dist/server/operator-output.js.map +1 -1
- package/dist/server/send-mail.d.ts +10 -3
- package/dist/server/send-mail.d.ts.map +1 -1
- package/dist/server/send-mail.js +64 -18
- package/dist/server/send-mail.js.map +1 -1
- package/dist/server/validate-mail.d.ts +10 -3
- package/dist/server/validate-mail.d.ts.map +1 -1
- package/dist/server/validate-mail.js +55 -12
- package/dist/server/validate-mail.js.map +1 -1
- package/dist/testing/fake-provider.d.ts.map +1 -1
- package/dist/testing/fake-provider.js +18 -3
- package/dist/testing/fake-provider.js.map +1 -1
- package/dist/testing/outbox.d.ts.map +1 -1
- package/dist/testing/outbox.js +10 -1
- package/dist/testing/outbox.js.map +1 -1
- package/module.json +1 -1
- package/package.json +1 -1
- package/src/attachments.ts +90 -0
- package/src/cli/run.ts +86 -32
- package/src/contract.ts +42 -1
- package/src/index.ts +12 -1
- package/src/jobs/delivery-job.ts +24 -4
- package/src/options.ts +31 -0
- package/src/providers/resend.ts +10 -0
- package/src/server/deliveries.ts +3 -2
- package/src/server/index.ts +1 -1
- package/src/server/operator-output.ts +24 -0
- package/src/server/send-mail.ts +63 -19
- package/src/server/validate-mail.ts +65 -13
- package/src/testing/fake-provider.ts +18 -3
- 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":"AAAA,iGAAiG;AACjG,uFAAuF;AACvF,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,UAAU,EAAE,gBAAgB,EAAE,eAAe,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAC;AAClK,OAAO,EAAE,
|
|
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,
|
|
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 = {
|
|
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,
|
|
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;
|
|
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"}
|
package/dist/testing/outbox.js
CHANGED
|
@@ -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;
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@softure-ai/mailing",
|
|
3
|
-
"version": "0.2.
|
|
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 {
|
|
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
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
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
|
-
...(
|
|
414
|
+
...(target.spfHosts === undefined ? {} : { spfHosts: target.spfHosts }),
|
|
352
415
|
...(command.expectDmarc === undefined ? {} : { expectDmarc: command.expectDmarc }),
|
|
353
416
|
...(replyTo === undefined ? {} : { replyTo }),
|
|
354
417
|
returnPath,
|
|
355
|
-
...(
|
|
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
|
-
|
|
442
|
+
const failed = failedAuthentication + failedReply + failedPaths + failedInbound;
|
|
443
|
+
if (failed === 0) {
|
|
380
444
|
output.log("receivers can authenticate mail from this domain");
|
|
381
|
-
return
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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.
|
|
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,
|
package/src/jobs/delivery-job.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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,
|
|
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.
|
|
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. */
|