@dbx-tools/email 0.3.28 → 0.3.30

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.
@@ -0,0 +1,162 @@
1
+ import assert from "node:assert/strict";
2
+ import { AsyncLocalStorage } from "node:async_hooks";
3
+ import { mkdtempSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { afterEach, describe, it } from "node:test";
7
+ import type { EmailMessage } from "@dbx-tools/shared-email";
8
+ import { EMAIL_SEND_SETTINGS, type EmailExecutionSettings } from "../src/defaults";
9
+ import {
10
+ getEmailRuntime,
11
+ resetEmailRuntime,
12
+ sendEmail,
13
+ setEmailExecutor,
14
+ type EmailExecutor,
15
+ } from "../src/transport";
16
+
17
+ // The runtime is a process-wide singleton built from the environment on first
18
+ // use, so the outbox mode has to be in place before any test sends.
19
+ process.env.EMAIL_OUTBOX_MODE = "1";
20
+ process.env.EMAIL_OUTBOX_DIR = mkdtempSync(join(tmpdir(), "email-executor-"));
21
+ process.env.EMAIL_ALLOWED_SENDERS = "*@example.com";
22
+
23
+ const FROM = "alerts@example.com";
24
+
25
+ function message(overrides: Partial<EmailMessage> = {}): EmailMessage {
26
+ return { to: ["alice@example.com"], subject: "Subject", body: "Body", ...overrides };
27
+ }
28
+
29
+ /** One recorded call to a spying executor. */
30
+ interface Recorded {
31
+ settings: EmailExecutionSettings;
32
+ }
33
+
34
+ /**
35
+ * An executor that records what it was handed and runs the call, standing in
36
+ * for the plugin's `execute()`. `supplied` is the signal it hands the call,
37
+ * mirroring the one the timeout interceptor provides.
38
+ */
39
+ function spyExecutor(calls: Recorded[], supplied?: AbortSignal): EmailExecutor {
40
+ return async (fn, settings) => {
41
+ calls.push({ settings });
42
+ try {
43
+ return { ok: true, data: await fn(supplied) };
44
+ } catch (err) {
45
+ return { ok: false, status: 500, message: err instanceof Error ? err.message : "failed" };
46
+ }
47
+ };
48
+ }
49
+
50
+ // Dropping the runtime also drops the installed executor, so each case starts
51
+ // on the unregistered fallback.
52
+ afterEach(() => resetEmailRuntime());
53
+
54
+ describe("send executor registration", () => {
55
+ it("sends without a registered plugin, through the direct fallback", async () => {
56
+ const result = await sendEmail(message(), FROM);
57
+ assert.equal(result.sent, true);
58
+ assert.ok(result.messageId?.endsWith(".html"));
59
+ });
60
+
61
+ it("installs the supplied executor on the shared runtime", () => {
62
+ const replacement: EmailExecutor = async (fn) => ({ ok: true, data: await fn() });
63
+ const fallback = getEmailRuntime().execute;
64
+ setEmailExecutor(replacement);
65
+ assert.notEqual(fallback, replacement);
66
+ assert.equal(getEmailRuntime().execute, replacement);
67
+ });
68
+
69
+ it("routes a send through the installed executor", async () => {
70
+ const calls: Recorded[] = [];
71
+ setEmailExecutor(spyExecutor(calls));
72
+ const result = await sendEmail(message(), FROM);
73
+ assert.equal(result.sent, true);
74
+ assert.equal(calls.length, 1);
75
+ });
76
+
77
+ it("hands the executor the write settings, so cache and retry stay off", async () => {
78
+ const calls: Recorded[] = [];
79
+ setEmailExecutor(spyExecutor(calls));
80
+ await sendEmail(message(), FROM);
81
+ assert.equal(calls[0]!.settings, EMAIL_SEND_SETTINGS);
82
+ assert.equal(calls[0]!.settings.default.cache?.enabled, false);
83
+ assert.equal(calls[0]!.settings.default.retry?.enabled, false);
84
+ });
85
+
86
+ it("replaces the previous executor so a re-registered plugin is not stale", async () => {
87
+ const first: Recorded[] = [];
88
+ const second: Recorded[] = [];
89
+ setEmailExecutor(spyExecutor(first));
90
+ setEmailExecutor(spyExecutor(second));
91
+ await sendEmail(message(), FROM);
92
+ assert.equal(first.length, 0);
93
+ assert.equal(second.length, 1);
94
+ });
95
+
96
+ it("stops using an executor once the runtime is dropped", async () => {
97
+ const calls: Recorded[] = [];
98
+ setEmailExecutor(spyExecutor(calls));
99
+ resetEmailRuntime();
100
+ await sendEmail(message(), FROM);
101
+ assert.equal(calls.length, 0);
102
+ });
103
+ });
104
+
105
+ describe("send executor failure handling", () => {
106
+ it("raises a stable error that does not leak the upstream message", async () => {
107
+ setEmailExecutor(async () => ({ ok: false, status: 502, message: "relay said 5.7.1 nope" }));
108
+ await assert.rejects(
109
+ () => sendEmail(message(), FROM),
110
+ (err: Error) => {
111
+ assert.match(err.message, /email: send failed/);
112
+ assert.doesNotMatch(err.message, /5\.7\.1/);
113
+ return true;
114
+ },
115
+ );
116
+ });
117
+
118
+ it("keeps validation ahead of the chain, so the executor never runs", async () => {
119
+ const calls: Recorded[] = [];
120
+ setEmailExecutor(spyExecutor(calls));
121
+ await assert.rejects(() => sendEmail(message({ to: [] }), FROM), /Missing required field: to/);
122
+ await assert.rejects(() => sendEmail(message(), "evil@attacker.com"), /Invalid value for from/);
123
+ assert.equal(calls.length, 0);
124
+ });
125
+ });
126
+
127
+ describe("send executor user scoping", () => {
128
+ it("reads the caller scope in force at send time, not at registration time", async () => {
129
+ // Stands in for AppKit's executionContextStorage: `asUser(req)` wraps the
130
+ // dispatch in `runInUserContext`, an AsyncLocalStorage.run, so an executor
131
+ // registered once at setup still resolves the per-call identity.
132
+ const storage = new AsyncLocalStorage<string>();
133
+ const seen: (string | undefined)[] = [];
134
+ setEmailExecutor(async (fn) => {
135
+ seen.push(storage.getStore());
136
+ return { ok: true, data: await fn() };
137
+ });
138
+ await storage.run("alice@databricks.com", () => sendEmail(message(), FROM));
139
+ await sendEmail(message(), FROM);
140
+ assert.deepEqual(seen, ["alice@databricks.com", undefined]);
141
+ });
142
+ });
143
+
144
+ describe("send executor cancellation", () => {
145
+ it("honors the signal the executor supplies", async () => {
146
+ const calls: Recorded[] = [];
147
+ setEmailExecutor(spyExecutor(calls, AbortSignal.abort()));
148
+ await assert.rejects(() => sendEmail(message(), FROM), /email: send failed/);
149
+ assert.equal(calls.length, 1);
150
+ });
151
+
152
+ it("honors the caller's own signal on the direct fallback", async () => {
153
+ await assert.rejects(() => sendEmail(message(), FROM, AbortSignal.abort()), /cancel/i);
154
+ });
155
+
156
+ it("honors the caller's signal when the executor supplies a live one", async () => {
157
+ const calls: Recorded[] = [];
158
+ setEmailExecutor(spyExecutor(calls, new AbortController().signal));
159
+ await assert.rejects(() => sendEmail(message(), FROM, AbortSignal.abort()), /cancel/i);
160
+ assert.equal(calls.length, 1);
161
+ });
162
+ });
@@ -0,0 +1,153 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import type { ResolvedEmailConfig } from "../src/config";
4
+ import {
5
+ assertSenderAllowed,
6
+ deriveSenderAddress,
7
+ isSenderAllowed,
8
+ listSenderOptions,
9
+ parseAllowedSenders,
10
+ resolveSenderAddress,
11
+ } from "../src/sender";
12
+
13
+ /** A resolved outbox config with only the sender fields under test set. */
14
+ function outboxConfig(sender: Partial<ResolvedEmailConfig> = {}): ResolvedEmailConfig {
15
+ return {
16
+ mode: "file",
17
+ outDir: "/tmp/email-outbox",
18
+ allowedSenders: [],
19
+ senderPolicy: "allowlist",
20
+ ...sender,
21
+ } as ResolvedEmailConfig;
22
+ }
23
+
24
+ describe("sender allow-list parsing", () => {
25
+ it("accepts an array or a comma / whitespace separated string", () => {
26
+ assert.deepEqual(parseAllowedSenders("a@x.com, b@x.com"), ["a@x.com", "b@x.com"]);
27
+ assert.deepEqual(parseAllowedSenders("a@x.com b@x.com"), ["a@x.com", "b@x.com"]);
28
+ assert.deepEqual(parseAllowedSenders(["a@x.com", "b@x.com"]), ["a@x.com", "b@x.com"]);
29
+ });
30
+
31
+ it("lower-cases, trims, and de-dupes", () => {
32
+ assert.deepEqual(parseAllowedSenders([" A@X.com ", "a@x.com"]), ["a@x.com"]);
33
+ });
34
+
35
+ it("yields nothing for an absent or empty value", () => {
36
+ assert.deepEqual(parseAllowedSenders(undefined), []);
37
+ assert.deepEqual(parseAllowedSenders(" , "), []);
38
+ });
39
+ });
40
+
41
+ describe("sender allow-list matching", () => {
42
+ it("matches an exact address case-insensitively", () => {
43
+ assert.equal(isSenderAllowed("Alerts@Example.com", ["alerts@example.com"]), true);
44
+ assert.equal(isSenderAllowed("other@example.com", ["alerts@example.com"]), false);
45
+ });
46
+
47
+ it("matches any local part on a wildcard or bare domain", () => {
48
+ assert.equal(isSenderAllowed("alice@mail.example.com", ["*@mail.example.com"]), true);
49
+ assert.equal(isSenderAllowed("bob@mail.example.com", ["mail.example.com"]), true);
50
+ assert.equal(isSenderAllowed("alice@other.example.com", ["*@mail.example.com"]), false);
51
+ });
52
+
53
+ it("requires a local part for a domain pattern", () => {
54
+ assert.equal(isSenderAllowed("@mail.example.com", ["*@mail.example.com"]), false);
55
+ });
56
+
57
+ it("treats a lone star as any address", () => {
58
+ assert.equal(isSenderAllowed("anyone@anywhere.com", ["*"]), true);
59
+ });
60
+
61
+ it("permits everything when the effective list is empty", () => {
62
+ assert.equal(isSenderAllowed("anyone@anywhere.com", []), true);
63
+ });
64
+
65
+ it("assertSenderAllowed rejects a denied address and passes a permitted one", () => {
66
+ assert.throws(
67
+ () => assertSenderAllowed("evil@attacker.com", ["*@mail.example.com"]),
68
+ /Invalid value for from/,
69
+ );
70
+ assert.doesNotThrow(() => assertSenderAllowed("alice@mail.example.com", ["mail.example.com"]));
71
+ });
72
+
73
+ it("keeps the allow-list patterns out of the thrown message", () => {
74
+ assert.throws(
75
+ () => assertSenderAllowed("evil@attacker.com", ["*@mail.example.com"]),
76
+ (err: Error) => !err.message.includes("mail.example.com"),
77
+ );
78
+ });
79
+ });
80
+
81
+ describe("sender derivation", () => {
82
+ it("re-homes the user's local part on the sending domain", () => {
83
+ assert.equal(
84
+ deriveSenderAddress("alice@databricks.com", "mail.example.com"),
85
+ "alice@mail.example.com",
86
+ );
87
+ });
88
+
89
+ it("refuses to derive without an on-behalf-of user", () => {
90
+ assert.throws(() => deriveSenderAddress(undefined, "mail.example.com"), /user email/);
91
+ assert.throws(() => deriveSenderAddress(" ", "mail.example.com"), /user email/);
92
+ });
93
+
94
+ it("prefers an explicit From over the per-user derivation", () => {
95
+ const config = outboxConfig({ from: "alerts@example.com", domain: "mail.example.com" });
96
+ assert.equal(resolveSenderAddress(config, "alice@databricks.com"), "alerts@example.com");
97
+ });
98
+
99
+ it("derives from the domain when no explicit From is set", () => {
100
+ const config = outboxConfig({ domain: "mail.example.com" });
101
+ assert.equal(resolveSenderAddress(config, "alice@databricks.com"), "alice@mail.example.com");
102
+ });
103
+
104
+ it("falls back to the user's own address when neither is configured", () => {
105
+ assert.equal(
106
+ resolveSenderAddress(outboxConfig(), "alice@databricks.com"),
107
+ "alice@databricks.com",
108
+ );
109
+ });
110
+
111
+ it("refuses when no source and no user yield an address", () => {
112
+ assert.throws(() => resolveSenderAddress(outboxConfig(), undefined), /Email sender address/);
113
+ });
114
+ });
115
+
116
+ describe("sender options for a picker", () => {
117
+ it("offers the default sender first", () => {
118
+ const config = outboxConfig({
119
+ domain: "mail.example.com",
120
+ allowedSenders: ["alerts@example.com", "*@mail.example.com"],
121
+ });
122
+ assert.deepEqual(listSenderOptions(config, "alice@databricks.com"), [
123
+ "alice@mail.example.com",
124
+ "alerts@example.com",
125
+ ]);
126
+ });
127
+
128
+ it("expands a domain pattern against the user's local part", () => {
129
+ const config = outboxConfig({ allowedSenders: ["*@mail.example.com", "other.example.com"] });
130
+ assert.deepEqual(listSenderOptions(config, "alice@databricks.com"), [
131
+ "alice@mail.example.com",
132
+ "alice@other.example.com",
133
+ ]);
134
+ });
135
+
136
+ it("drops domain patterns when there is no user local part to expand with", () => {
137
+ const config = outboxConfig({ allowedSenders: ["*@mail.example.com"] });
138
+ assert.deepEqual(listSenderOptions(config, undefined), []);
139
+ });
140
+
141
+ it("cannot enumerate a lone star, so it offers only the default", () => {
142
+ const config = outboxConfig({ from: "alerts@example.com", allowedSenders: ["*"] });
143
+ assert.deepEqual(listSenderOptions(config, "alice@databricks.com"), ["alerts@example.com"]);
144
+ });
145
+
146
+ it("omits a default the allow-list does not permit", () => {
147
+ const config = outboxConfig({
148
+ from: "alerts@example.com",
149
+ allowedSenders: ["*@mail.example.com"],
150
+ });
151
+ assert.deepEqual(listSenderOptions(config, "alice@databricks.com"), ["alice@mail.example.com"]);
152
+ });
153
+ });
@@ -0,0 +1,105 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtempSync } from "node:fs";
3
+ import { readFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { after, describe, it } from "node:test";
7
+ import type { EmailAttachment, EmailMessage } from "@dbx-tools/shared-email";
8
+ import {
9
+ MAX_ATTACHMENT_BYTES,
10
+ MAX_ATTACHMENT_COUNT,
11
+ MAX_ATTACHMENTS_TOTAL_BYTES,
12
+ MAX_BODY_CHARS,
13
+ } from "../src/defaults";
14
+ import { resetEmailRuntime, sendEmail } from "../src/transport";
15
+
16
+ // The runtime is a process-wide singleton built from the environment on first
17
+ // use, so the outbox mode has to be in place before any test sends.
18
+ const OUTBOX_DIR = mkdtempSync(join(tmpdir(), "email-outbox-"));
19
+ process.env.EMAIL_OUTBOX_MODE = "1";
20
+ process.env.EMAIL_OUTBOX_DIR = OUTBOX_DIR;
21
+ process.env.EMAIL_ALLOWED_SENDERS = "*@example.com";
22
+
23
+ const FROM = "alerts@example.com";
24
+
25
+ function message(overrides: Partial<EmailMessage> = {}): EmailMessage {
26
+ return { to: ["alice@example.com"], subject: "Subject", body: "Body", ...overrides };
27
+ }
28
+
29
+ /** An attachment whose inline content decodes to exactly `bytes` bytes. */
30
+ function attachment(filename: string, bytes: number): EmailAttachment {
31
+ return { filename, content: "x".repeat(bytes) };
32
+ }
33
+
34
+ after(() => resetEmailRuntime());
35
+
36
+ describe("send validation", () => {
37
+ it("rejects a message with no recipient", async () => {
38
+ await assert.rejects(() => sendEmail(message({ to: [] }), FROM), /Missing required field: to/);
39
+ });
40
+
41
+ it("rejects a body over the character cap", async () => {
42
+ await assert.rejects(
43
+ () => sendEmail(message({ body: "x".repeat(MAX_BODY_CHARS + 1) }), FROM),
44
+ /Invalid value for body/,
45
+ );
46
+ });
47
+
48
+ it("rejects a single attachment over the per-file byte cap", async () => {
49
+ const attachments = [attachment("big.bin", MAX_ATTACHMENT_BYTES + 1)];
50
+ await assert.rejects(
51
+ () => sendEmail(message({ attachments }), FROM),
52
+ /Invalid value for attachments\[\]\.content/,
53
+ );
54
+ });
55
+
56
+ it("rejects attachments that together exceed the total byte cap", async () => {
57
+ const each = Math.ceil(MAX_ATTACHMENTS_TOTAL_BYTES / 3);
58
+ const attachments = ["a", "b", "c"].map((name) => attachment(`${name}.bin`, each));
59
+ await assert.rejects(
60
+ () => sendEmail(message({ attachments }), FROM),
61
+ /at most \d+ bytes across all files/,
62
+ );
63
+ });
64
+
65
+ it("rejects more attachments than the count cap", async () => {
66
+ const attachments = Array.from({ length: MAX_ATTACHMENT_COUNT + 1 }, (_, index) =>
67
+ attachment(`f${index}.txt`, 1),
68
+ );
69
+ await assert.rejects(() => sendEmail(message({ attachments }), FROM), /at most \d+ files/);
70
+ });
71
+
72
+ it("refuses a sender the effective allow-list does not permit", async () => {
73
+ await assert.rejects(() => sendEmail(message(), "evil@attacker.com"), /Invalid value for from/);
74
+ });
75
+
76
+ it("rejects a send that was already aborted", async () => {
77
+ await assert.rejects(() => sendEmail(message(), FROM, AbortSignal.abort()));
78
+ });
79
+ });
80
+
81
+ describe("outbox send", () => {
82
+ it("writes a rendered HTML preview under the sender folder", async () => {
83
+ const result = await sendEmail(
84
+ message({ cc: ["team@example.com"], body: "## Status\nResolved." }),
85
+ FROM,
86
+ );
87
+ assert.equal(result.sent, true);
88
+ assert.equal(result.from, FROM);
89
+ assert.equal(result.recipient, "alice@example.com");
90
+ assert.ok(result.messageId?.startsWith(join(OUTBOX_DIR, FROM)));
91
+ const html = await readFile(result.messageId!, "utf8");
92
+ assert.match(html, /Status<\/h2>/);
93
+ assert.match(html, /team@example\.com/);
94
+ });
95
+
96
+ it("measures a base64 attachment by its decoded size, not its text length", async () => {
97
+ // Four characters carry three decoded bytes, so the text is longer than
98
+ // what the cap counts.
99
+ const attachments = [
100
+ { filename: "a.bin", content: Buffer.alloc(1024, 7).toString("base64"), encoding: "base64" },
101
+ ];
102
+ const result = await sendEmail(message({ attachments }), FROM);
103
+ assert.equal(result.sent, true);
104
+ });
105
+ });