@dbx-tools/email 0.3.37 → 0.3.40
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/package.json +8 -4
- package/test/brand.test.ts +0 -46
- package/test/config.test.ts +0 -163
- package/test/executor.test.ts +0 -162
- package/test/sender.test.ts +0 -153
- package/test/transport.test.ts +0 -105
- package/test/tsconfig.json +0 -14
- package/tsconfig.json +0 -41
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"repository": {
|
|
4
4
|
"type": "git",
|
|
5
5
|
"url": "git+https://github.com/reggie-db/dbx-tools.git",
|
|
6
|
-
"directory": "
|
|
6
|
+
"directory": "packages/node/email"
|
|
7
7
|
},
|
|
8
8
|
"devDependencies": {
|
|
9
9
|
"@types/express": "^5.0.5",
|
|
@@ -19,21 +19,25 @@
|
|
|
19
19
|
"juice": "^12.1.1",
|
|
20
20
|
"marked": "^18.0.5",
|
|
21
21
|
"nodemailer": "^7.0.13",
|
|
22
|
-
"@dbx-tools/shared-core": "0.3.
|
|
23
|
-
"@dbx-tools/shared-email": "0.3.
|
|
22
|
+
"@dbx-tools/shared-core": "0.3.40",
|
|
23
|
+
"@dbx-tools/shared-email": "0.3.40"
|
|
24
24
|
},
|
|
25
25
|
"main": "index.ts",
|
|
26
26
|
"license": "UNLICENSED",
|
|
27
27
|
"publishConfig": {
|
|
28
28
|
"access": "public"
|
|
29
29
|
},
|
|
30
|
-
"version": "0.3.
|
|
30
|
+
"version": "0.3.40",
|
|
31
31
|
"types": "index.ts",
|
|
32
32
|
"type": "module",
|
|
33
33
|
"exports": {
|
|
34
34
|
".": "./index.ts",
|
|
35
35
|
"./package.json": "./package.json"
|
|
36
36
|
},
|
|
37
|
+
"files": [
|
|
38
|
+
"index.ts",
|
|
39
|
+
"src"
|
|
40
|
+
],
|
|
37
41
|
"dbxToolsConfig": {
|
|
38
42
|
"tags": [
|
|
39
43
|
"node"
|
package/test/brand.test.ts
DELETED
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { describe, it } from "node:test";
|
|
3
|
-
import { brand } from "@dbx-tools/shared-core";
|
|
4
|
-
import { defaultEmailBrand, emailBrandFromContext } from "../src/brand";
|
|
5
|
-
import { renderEmailHtml } from "../src/email-html";
|
|
6
|
-
|
|
7
|
-
describe("email brand", () => {
|
|
8
|
-
it("derives accent, font, and name from a brand context", () => {
|
|
9
|
-
const b = emailBrandFromContext(brand.defaultBrandContext);
|
|
10
|
-
assert.equal(b.accent, brand.defaultBrandContext.colors.primary);
|
|
11
|
-
assert.equal(b.fontFamily, brand.defaultBrandContext.typography.sans);
|
|
12
|
-
assert.equal(b.name, brand.defaultBrandContext.name);
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
it("drops a logo that is not a fetchable URL (package-export path)", () => {
|
|
16
|
-
// The default brand's logo is an `@dbx-tools/ui-branding/...svg` export
|
|
17
|
-
// path, which can't load in a mail client, so no logoUrl is emitted.
|
|
18
|
-
assert.equal(defaultEmailBrand.logoUrl, undefined);
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
it("keeps an http(s) or data URL logo", () => {
|
|
22
|
-
const ctx = brand.parseBrandContext({
|
|
23
|
-
assets: { logo: { light: "https://ex.com/l.svg", dark: "https://ex.com/d.svg" } },
|
|
24
|
-
});
|
|
25
|
-
// Dark logo wins - the header band is dark.
|
|
26
|
-
assert.equal(emailBrandFromContext(ctx).logoUrl, "https://ex.com/d.svg");
|
|
27
|
-
});
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
describe("renderEmailHtml branding", () => {
|
|
31
|
-
it("inlines the brand accent and font, not the default blue", () => {
|
|
32
|
-
const html = renderEmailHtml({ subject: "S", body: "b", brand: defaultEmailBrand });
|
|
33
|
-
assert.ok(html.includes(defaultEmailBrand.accent));
|
|
34
|
-
assert.ok(html.includes("Inter"));
|
|
35
|
-
assert.ok(!html.includes("#0b6bcb"));
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it("renders an <img> when the brand supplies a URL logo", () => {
|
|
39
|
-
const b = { ...defaultEmailBrand, logoUrl: "https://ex.com/logo.svg" };
|
|
40
|
-
assert.ok(renderEmailHtml({ subject: "S", body: "b", brand: b }).includes("<img"));
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it("falls back to the neutral default palette with no brand", () => {
|
|
44
|
-
assert.ok(renderEmailHtml({ subject: "S", body: "b" }).includes("#0b6bcb"));
|
|
45
|
-
});
|
|
46
|
-
});
|
package/test/config.test.ts
DELETED
|
@@ -1,163 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { resolve } from "node:path";
|
|
3
|
-
import { beforeEach, describe, it } from "node:test";
|
|
4
|
-
import {
|
|
5
|
-
DEFAULT_SMTP_PORT,
|
|
6
|
-
IMPLICIT_TLS_SMTP_PORT,
|
|
7
|
-
resolveEmailConfig,
|
|
8
|
-
type ResolvedSmtpConfig,
|
|
9
|
-
type SenderPolicy,
|
|
10
|
-
} from "../src/config";
|
|
11
|
-
|
|
12
|
-
/** Every env var {@link resolveEmailConfig} reads, cleared between cases. */
|
|
13
|
-
const ENV_KEYS = [
|
|
14
|
-
"SMTP_HOST",
|
|
15
|
-
"SMTP_PORT",
|
|
16
|
-
"SMTP_SECURE",
|
|
17
|
-
"SMTP_USER",
|
|
18
|
-
"SMTP_PASSWORD",
|
|
19
|
-
"EMAIL_DOMAIN",
|
|
20
|
-
"EMAIL_FROM",
|
|
21
|
-
"EMAIL_ALLOWED_SENDERS",
|
|
22
|
-
"EMAIL_SENDER_POLICY",
|
|
23
|
-
"EMAIL_OUTBOX_MODE",
|
|
24
|
-
"EMAIL_OUTBOX_DIR",
|
|
25
|
-
] as const;
|
|
26
|
-
|
|
27
|
-
const SMTP_CREDENTIALS = {
|
|
28
|
-
host: "smtp.example.com",
|
|
29
|
-
user: "apikey",
|
|
30
|
-
password: "secret",
|
|
31
|
-
} as const;
|
|
32
|
-
|
|
33
|
-
beforeEach(() => {
|
|
34
|
-
for (const key of ENV_KEYS) delete process.env[key];
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
describe("resolveEmailConfig modes", () => {
|
|
38
|
-
it("refuses to resolve with neither SMTP credentials nor an outbox opt-in", () => {
|
|
39
|
-
assert.throws(() => resolveEmailConfig(), /SMTP connection not configured/);
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it("names the missing env vars for a partial SMTP configuration", () => {
|
|
43
|
-
assert.throws(
|
|
44
|
-
() => resolveEmailConfig({ smtp: { host: "smtp.example.com" } }),
|
|
45
|
-
/Missing required environment variables: SMTP_USER, SMTP_PASSWORD/,
|
|
46
|
-
);
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it("refuses SMTP with no sender source to derive a From from", () => {
|
|
50
|
-
assert.throws(() => resolveEmailConfig({ smtp: SMTP_CREDENTIALS }), /Email sender source/);
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it("resolves SMTP mode with the default port and STARTTLS", () => {
|
|
54
|
-
const config = resolveEmailConfig({
|
|
55
|
-
smtp: SMTP_CREDENTIALS,
|
|
56
|
-
domain: "mail.example.com",
|
|
57
|
-
}) as ResolvedSmtpConfig;
|
|
58
|
-
assert.equal(config.mode, "smtp");
|
|
59
|
-
assert.equal(config.port, DEFAULT_SMTP_PORT);
|
|
60
|
-
assert.equal(config.secure, false);
|
|
61
|
-
assert.deepEqual(config.auth, { user: "apikey", pass: "secret" });
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it("turns on TLS-on-connect for the implicit-TLS port", () => {
|
|
65
|
-
const config = resolveEmailConfig({
|
|
66
|
-
smtp: { ...SMTP_CREDENTIALS, port: IMPLICIT_TLS_SMTP_PORT },
|
|
67
|
-
domain: "mail.example.com",
|
|
68
|
-
}) as ResolvedSmtpConfig;
|
|
69
|
-
assert.equal(config.secure, true);
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it("prefers explicit config over the matching env var", () => {
|
|
73
|
-
process.env.SMTP_HOST = "env.example.com";
|
|
74
|
-
process.env.SMTP_PORT = "2525";
|
|
75
|
-
process.env.SMTP_USER = "envuser";
|
|
76
|
-
process.env.SMTP_PASSWORD = "envpass";
|
|
77
|
-
process.env.EMAIL_DOMAIN = "env.example.com";
|
|
78
|
-
const config = resolveEmailConfig({
|
|
79
|
-
smtp: SMTP_CREDENTIALS,
|
|
80
|
-
domain: "mail.example.com",
|
|
81
|
-
}) as ResolvedSmtpConfig;
|
|
82
|
-
assert.equal(config.host, "smtp.example.com");
|
|
83
|
-
assert.equal(config.domain, "mail.example.com");
|
|
84
|
-
// The port has no explicit value, so the env var still wins over the default.
|
|
85
|
-
assert.equal(config.port, 2525);
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
it("resolves outbox mode to an absolute directory when opted in", () => {
|
|
89
|
-
process.env.EMAIL_OUTBOX_MODE = "1";
|
|
90
|
-
const config = resolveEmailConfig({ outDir: "tmp/email-outbox" });
|
|
91
|
-
assert.equal(config.mode, "file");
|
|
92
|
-
assert.equal(config.mode === "file" && config.outDir, resolve("tmp/email-outbox"));
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
it("keeps a sender source optional in outbox mode", () => {
|
|
96
|
-
process.env.EMAIL_OUTBOX_MODE = "1";
|
|
97
|
-
const config = resolveEmailConfig();
|
|
98
|
-
assert.equal(config.mode, "file");
|
|
99
|
-
assert.equal(config.domain, undefined);
|
|
100
|
-
assert.equal(config.from, undefined);
|
|
101
|
-
});
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
describe("resolveEmailConfig sender policy", () => {
|
|
105
|
-
it("defaults to allowlist and narrows an empty list to the configured domain", () => {
|
|
106
|
-
const config = resolveEmailConfig({ smtp: SMTP_CREDENTIALS, domain: "mail.example.com" });
|
|
107
|
-
assert.equal(config.senderPolicy, "allowlist");
|
|
108
|
-
assert.deepEqual(config.allowedSenders, ["*@mail.example.com"]);
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
it("narrows an empty list to a fixed From when that is the sender source", () => {
|
|
112
|
-
const config = resolveEmailConfig({ smtp: SMTP_CREDENTIALS, from: "Alerts@Example.com" });
|
|
113
|
-
assert.deepEqual(config.allowedSenders, ["alerts@example.com"]);
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
it("includes both patterns when both sender sources are configured", () => {
|
|
117
|
-
const config = resolveEmailConfig({
|
|
118
|
-
smtp: SMTP_CREDENTIALS,
|
|
119
|
-
from: "alerts@example.com",
|
|
120
|
-
domain: "mail.example.com",
|
|
121
|
-
});
|
|
122
|
-
assert.deepEqual(config.allowedSenders, ["alerts@example.com", "*@mail.example.com"]);
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
it("leaves the list empty under the named unrestricted policy", () => {
|
|
126
|
-
const config = resolveEmailConfig({
|
|
127
|
-
smtp: SMTP_CREDENTIALS,
|
|
128
|
-
domain: "mail.example.com",
|
|
129
|
-
senderPolicy: "unrestricted",
|
|
130
|
-
});
|
|
131
|
-
assert.equal(config.senderPolicy, "unrestricted");
|
|
132
|
-
assert.deepEqual(config.allowedSenders, []);
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
it("reads the policy from EMAIL_SENDER_POLICY", () => {
|
|
136
|
-
process.env.EMAIL_SENDER_POLICY = "unrestricted";
|
|
137
|
-
const config = resolveEmailConfig({ smtp: SMTP_CREDENTIALS, domain: "mail.example.com" });
|
|
138
|
-
assert.equal(config.senderPolicy, "unrestricted");
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
it("rejects an unrecognized policy", () => {
|
|
142
|
-
const senderPolicy = "open" as SenderPolicy;
|
|
143
|
-
assert.throws(
|
|
144
|
-
() => resolveEmailConfig({ smtp: SMTP_CREDENTIALS, domain: "d.com", senderPolicy }),
|
|
145
|
-
/Invalid value for senderPolicy/,
|
|
146
|
-
);
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
it("keeps an explicit allow-list instead of the implied one", () => {
|
|
150
|
-
const config = resolveEmailConfig({
|
|
151
|
-
smtp: SMTP_CREDENTIALS,
|
|
152
|
-
domain: "mail.example.com",
|
|
153
|
-
allowedSenders: "Alerts@Example.com, *@other.example.com",
|
|
154
|
-
});
|
|
155
|
-
assert.deepEqual(config.allowedSenders, ["alerts@example.com", "*@other.example.com"]);
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
it("reads an explicit allow-list from EMAIL_ALLOWED_SENDERS", () => {
|
|
159
|
-
process.env.EMAIL_OUTBOX_MODE = "1";
|
|
160
|
-
process.env.EMAIL_ALLOWED_SENDERS = "a@x.com b@x.com";
|
|
161
|
-
assert.deepEqual(resolveEmailConfig().allowedSenders, ["a@x.com", "b@x.com"]);
|
|
162
|
-
});
|
|
163
|
-
});
|
package/test/executor.test.ts
DELETED
|
@@ -1,162 +0,0 @@
|
|
|
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
|
-
});
|
package/test/sender.test.ts
DELETED
|
@@ -1,153 +0,0 @@
|
|
|
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
|
-
});
|
package/test/transport.test.ts
DELETED
|
@@ -1,105 +0,0 @@
|
|
|
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
|
-
});
|
package/test/tsconfig.json
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
// ~~ Generated by projen. To modify, edit .projenrc.js and run "pnpm exec projen".
|
|
2
|
-
{
|
|
3
|
-
"extends": "../tsconfig.json",
|
|
4
|
-
"compilerOptions": {
|
|
5
|
-
"noEmit": true,
|
|
6
|
-
"rootDir": ".."
|
|
7
|
-
},
|
|
8
|
-
"include": [
|
|
9
|
-
"**/*.ts"
|
|
10
|
-
],
|
|
11
|
-
"exclude": [
|
|
12
|
-
"node_modules"
|
|
13
|
-
]
|
|
14
|
-
}
|
package/tsconfig.json
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
// ~~ Generated by projen. To modify, edit .projenrc.js and run "pnpm exec projen".
|
|
2
|
-
{
|
|
3
|
-
"compilerOptions": {
|
|
4
|
-
"rootDir": "src",
|
|
5
|
-
"outDir": "lib",
|
|
6
|
-
"alwaysStrict": true,
|
|
7
|
-
"declaration": true,
|
|
8
|
-
"esModuleInterop": true,
|
|
9
|
-
"experimentalDecorators": true,
|
|
10
|
-
"inlineSourceMap": true,
|
|
11
|
-
"inlineSources": true,
|
|
12
|
-
"lib": [
|
|
13
|
-
"ES2022"
|
|
14
|
-
],
|
|
15
|
-
"module": "ESNext",
|
|
16
|
-
"noEmitOnError": false,
|
|
17
|
-
"noFallthroughCasesInSwitch": true,
|
|
18
|
-
"noImplicitAny": true,
|
|
19
|
-
"noImplicitReturns": true,
|
|
20
|
-
"noImplicitThis": true,
|
|
21
|
-
"noUnusedLocals": true,
|
|
22
|
-
"noUnusedParameters": true,
|
|
23
|
-
"resolveJsonModule": true,
|
|
24
|
-
"strict": true,
|
|
25
|
-
"strictNullChecks": true,
|
|
26
|
-
"strictPropertyInitialization": true,
|
|
27
|
-
"stripInternal": true,
|
|
28
|
-
"target": "ES2022",
|
|
29
|
-
"types": [
|
|
30
|
-
"node"
|
|
31
|
-
],
|
|
32
|
-
"moduleResolution": "bundler",
|
|
33
|
-
"skipLibCheck": true
|
|
34
|
-
},
|
|
35
|
-
"include": [
|
|
36
|
-
"src/**/*.ts"
|
|
37
|
-
],
|
|
38
|
-
"exclude": [
|
|
39
|
-
"node_modules"
|
|
40
|
-
]
|
|
41
|
-
}
|