@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.
package/src/tool.ts CHANGED
@@ -9,18 +9,41 @@
9
9
  * `getExecutionContext()` returns the OBO user whose local-part seeds
10
10
  * the address (see {@link deriveSenderAddress}).
11
11
  *
12
+ * The dispatch itself goes through the executor the plugin installs on the
13
+ * shared runtime, so a send from this tool picks up the same retry / timeout /
14
+ * telemetry chain as one from the AppKit tool. In a Mastra app with no AppKit
15
+ * plugin registered the send still runs, just without interceptors.
16
+ *
12
17
  * @module
13
18
  */
14
19
 
15
20
  import { getExecutionContext } from "@databricks/appkit";
16
21
  import { log, string } from "@dbx-tools/shared-core";
17
- import { email, type EmailMessage } from "@dbx-tools/shared-email";
22
+ import { email } from "@dbx-tools/shared-email";
18
23
  import { createTool } from "@mastra/core/tools";
19
24
  import { resolveSenderAddress } from "./sender";
20
25
  import { getEmailRuntime, sendEmail } from "./transport";
21
26
 
22
27
  const logger = log.logger("email/tool/send-email");
23
28
 
29
+ /**
30
+ * The model-facing description of the send capability, shared by the Mastra
31
+ * {@link emailTool} and the AppKit `email.send` tool so both agents get the
32
+ * same guidance about approval, scope, and body formatting.
33
+ */
34
+ export const SEND_EMAIL_DESCRIPTION = string.toDescription(`
35
+ Send an email on the user's behalf. Pass one or more recipient
36
+ addresses (with optional cc / bcc and file attachments), a subject,
37
+ and a body; the user is prompted to approve the send before it goes
38
+ out (this tool is approval-gated). Use it only when the user
39
+ explicitly asks to send / forward / share something via email -
40
+ never autonomously. Keep subjects short and bodies self-contained:
41
+ the recipient has none of the chat context. Write the body in
42
+ GitHub-Flavored Markdown - headings, lists, and real Markdown
43
+ tables - not ASCII art (no "=====" dividers or space/pipe-drawn
44
+ tables); it is rendered to HTML before sending.
45
+ `);
46
+
24
47
  /** Options accepted by {@link emailTool}. */
25
48
  export interface EmailToolOptions {
26
49
  /**
@@ -50,28 +73,17 @@ export interface EmailToolOptions {
50
73
  export function emailTool(opts: EmailToolOptions = {}) {
51
74
  return createTool({
52
75
  id: opts.id ?? "send_email",
53
- description: string.toDescription(`
54
- Send an email on the user's behalf. Pass one or more recipient
55
- addresses (with optional cc / bcc and file attachments), a subject,
56
- and a body; the user is prompted to approve the send before it goes
57
- out (this tool is approval-gated). Use it only when the user
58
- explicitly asks to send / forward / share something via email -
59
- never autonomously. Keep subjects short and bodies self-contained:
60
- the recipient has none of the chat context. Write the body in
61
- GitHub-Flavored Markdown - headings, lists, and real Markdown
62
- tables - not ASCII art (no "=====" dividers or space/pipe-drawn
63
- tables); it is rendered to HTML before sending.
64
- `),
76
+ description: SEND_EMAIL_DESCRIPTION,
65
77
  inputSchema: email.emailMessageSchema,
66
78
  outputSchema: email.emailResultSchema,
67
79
  requireApproval: true,
68
- execute: async (input) => {
69
- const message = input as EmailMessage;
80
+ execute: async (input, context) => {
81
+ const message = email.emailMessageSchema.parse(input);
70
82
  const { config } = getEmailRuntime();
71
83
  const ctx = getExecutionContext();
72
84
  const userEmail = "isUserContext" in ctx ? ctx.userEmail : undefined;
73
85
  const from = resolveSenderAddress(config, userEmail);
74
- const result = await sendEmail(message, from);
86
+ const result = await sendEmail(message, from, context?.abortSignal);
75
87
  logger.info("sent", {
76
88
  to: result.recipient,
77
89
  from: result.from,
package/src/transport.ts CHANGED
@@ -8,37 +8,117 @@
8
8
  * The first caller (normally the plugin at setup) primes it with the
9
9
  * plugin's config; later callers reuse it.
10
10
  *
11
+ * The runtime also carries the {@link EmailExecutor} every outbound send runs
12
+ * through. The plugin installs its own `execute()` there at setup, which is
13
+ * how the Mastra tool - a plain function with no plugin instance in scope -
14
+ * still gets AppKit's retry / timeout / telemetry chain. Without a registered
15
+ * plugin (a direct call from a script or a test) the send still runs, just
16
+ * without interceptors.
17
+ *
18
+ * Every entry point takes an optional {@link AbortSignal} so the plugin's
19
+ * `execute()` timeout and a client disconnect both stop the caller waiting
20
+ * on SMTP.
21
+ *
11
22
  * @module
12
23
  */
13
24
 
25
+ import {
26
+ AppKitError,
27
+ ConfigurationError,
28
+ ExecutionError,
29
+ ValidationError,
30
+ type ExecutionResult,
31
+ } from "@databricks/appkit";
32
+ import { async, error, log } from "@dbx-tools/shared-core";
14
33
  import type { EmailAttachment, EmailMessage, EmailResult } from "@dbx-tools/shared-email";
15
34
  import nodemailer, { type SendMailOptions, type Transporter } from "nodemailer";
16
35
  import { resolveEmailConfig, type EmailPluginConfig, type ResolvedEmailConfig } from "./config";
36
+ import {
37
+ EMAIL_SEND_SETTINGS,
38
+ MAX_ATTACHMENT_BYTES,
39
+ MAX_ATTACHMENT_COUNT,
40
+ MAX_ATTACHMENTS_TOTAL_BYTES,
41
+ MAX_BODY_CHARS,
42
+ type EmailExecutionSettings,
43
+ } from "./defaults";
17
44
  import { renderEmailHtml } from "./email-html";
18
45
  import { writeOutboxEmail } from "./outbox";
19
46
  import { assertSenderAllowed } from "./sender";
20
47
 
21
- /** The shared dispatcher and the config it was built from. */
48
+ const logger = log.logger("email/transport");
49
+
50
+ /**
51
+ * Runs one outbound send through AppKit's interceptor chain. Matches
52
+ * `Plugin.execute()`, which never throws: a failure comes back as
53
+ * `{ ok: false }`.
54
+ */
55
+ export type EmailExecutor = <T>(
56
+ fn: (signal?: AbortSignal) => Promise<T>,
57
+ settings: EmailExecutionSettings,
58
+ ) => Promise<ExecutionResult<T>>;
59
+
60
+ /** The shared dispatcher, its resolved config, and the send executor. */
22
61
  export interface EmailRuntime {
23
62
  /** Present only in SMTP mode. */
24
63
  transporter?: Transporter;
25
64
  config: ResolvedEmailConfig;
65
+ execute: EmailExecutor;
66
+ }
67
+
68
+ /**
69
+ * Combine two optional cancellation sources into one signal. Returns the
70
+ * single signal when only one is present so the common path allocates
71
+ * nothing.
72
+ */
73
+ function mergeSignals(a?: AbortSignal, b?: AbortSignal): AbortSignal | undefined {
74
+ if (!a) return b;
75
+ if (!b) return a;
76
+ const controller = new AbortController();
77
+ async.tieAbortSignal(controller, a);
78
+ async.tieAbortSignal(controller, b);
79
+ return controller.signal;
26
80
  }
27
81
 
82
+ /**
83
+ * Executor used until (or unless) the plugin installs its own: run the send
84
+ * directly, mapping a throw onto the same {@link ExecutionResult} shape so
85
+ * call sites branch on `ok` either way.
86
+ */
87
+ const directExecute: EmailExecutor = async (fn) => {
88
+ try {
89
+ return { ok: true, data: await fn() };
90
+ } catch (err) {
91
+ return {
92
+ ok: false,
93
+ status: err instanceof AppKitError ? err.statusCode : 500,
94
+ message: error.errorMessage(err),
95
+ };
96
+ }
97
+ };
98
+
28
99
  let runtime: EmailRuntime | undefined;
29
100
 
30
101
  /**
31
- * Return the shared runtime, building it on first use from the supplied
32
- * config layered over environment defaults. Overrides are only read when
33
- * the runtime is first created, so prime it from the plugin's config at
34
- * setup; subsequent calls (e.g. the tool's `execute`) pass nothing and
35
- * get the same instance.
102
+ * Return the shared runtime, building it on first use by resolving
103
+ * `overrides` over the environment (`SMTP_HOST`, `SMTP_PORT`,
104
+ * `SMTP_SECURE`, `SMTP_USER`, `SMTP_PASSWORD`, `EMAIL_DOMAIN`,
105
+ * `EMAIL_FROM`, `EMAIL_ALLOWED_SENDERS`, `EMAIL_SENDER_POLICY`,
106
+ * `EMAIL_OUTBOX_MODE`, `EMAIL_OUTBOX_DIR`) through
107
+ * {@link resolveEmailConfig}. With SMTP credentials present it holds a
108
+ * nodemailer transport and its connection pool; otherwise it is in
109
+ * file/outbox mode and holds none.
110
+ *
111
+ * `overrides` is read only on the call that builds the runtime, so prime it
112
+ * from the plugin's config at setup; later callers (the tool's `execute`,
113
+ * the sender-options route) pass nothing and get the same instance. Throws
114
+ * whatever {@link resolveEmailConfig} throws for an unusable configuration.
36
115
  */
37
116
  export function getEmailRuntime(overrides?: EmailPluginConfig): EmailRuntime {
38
117
  if (!runtime) {
39
118
  const config = resolveEmailConfig(overrides);
40
119
  runtime = {
41
120
  config,
121
+ execute: directExecute,
42
122
  ...(config.mode === "smtp"
43
123
  ? {
44
124
  transporter: nodemailer.createTransport({
@@ -54,17 +134,180 @@ export function getEmailRuntime(overrides?: EmailPluginConfig): EmailRuntime {
54
134
  return runtime;
55
135
  }
56
136
 
57
- /** Drop the memoized runtime so the next {@link getEmailRuntime} rebuilds it. */
137
+ /**
138
+ * Install the executor outbound sends run through. The plugin calls this at
139
+ * setup with its own `execute()`; a second call replaces the previous one, so
140
+ * a re-registered plugin does not leave the tools bound to a dead instance.
141
+ */
142
+ export function setEmailExecutor(execute: EmailExecutor): void {
143
+ getEmailRuntime().execute = execute;
144
+ }
145
+
146
+ /**
147
+ * Drop the memoized runtime, closing the SMTP connection pool, so the next
148
+ * {@link getEmailRuntime} rebuilds it from fresh config and stops calling
149
+ * through a torn-down plugin's `execute()`. Idempotent, and the plugin's
150
+ * `shutdown()` hook.
151
+ */
58
152
  export function resetEmailRuntime(): void {
59
153
  runtime?.transporter?.close();
60
154
  runtime = undefined;
61
155
  }
62
156
 
157
+ /**
158
+ * Run one non-idempotent write through the shared executor and unwrap it.
159
+ *
160
+ * `execute()` never throws, so a failed send arrives as `{ ok: false }` with a
161
+ * status the interceptors already sanitized; it is logged here and re-raised
162
+ * as a stable {@link ExecutionError} so an upstream message never becomes the
163
+ * caller's error text. `signal` is the caller's own cancellation (an agent
164
+ * run, a request teardown); it is merged with the signal the timeout
165
+ * interceptor supplies so either one unwinds the I/O.
166
+ */
167
+ export async function executeWrite<T>(
168
+ operation: string,
169
+ settings: EmailExecutionSettings,
170
+ fn: (signal?: AbortSignal) => Promise<T>,
171
+ signal?: AbortSignal,
172
+ ): Promise<T> {
173
+ const { execute } = getEmailRuntime();
174
+ const result = await execute(
175
+ (executeSignal) => fn(mergeSignals(executeSignal, signal)),
176
+ settings,
177
+ );
178
+ if (result.ok) return result.data;
179
+ // A caller that cancelled is not a failure worth reporting as one.
180
+ if (signal?.aborted) throw ExecutionError.canceled();
181
+ logger.warn("execution-failed", {
182
+ operation,
183
+ status: result.status,
184
+ error: result.message,
185
+ });
186
+ throw new ExecutionError(`email: ${operation} failed`, {
187
+ context: { operation, status: result.status },
188
+ });
189
+ }
190
+
191
+ /**
192
+ * Open and tear down one SMTP connection to prove the host, port, and
193
+ * credentials work. Called at plugin setup so a bad relay shows up in the
194
+ * boot logs rather than on the first approved send.
195
+ */
196
+ export async function verifyEmailTransport(
197
+ transporter: Transporter | undefined,
198
+ signal?: AbortSignal,
199
+ ): Promise<void> {
200
+ if (!transporter) {
201
+ throw ConfigurationError.invalidConnection(
202
+ "SMTP",
203
+ "The runtime resolved to outbox mode, so there is no transport to verify.",
204
+ );
205
+ }
206
+ await abortable(transporter.verify(), signal);
207
+ }
208
+
209
+ /**
210
+ * Stop awaiting `promise` as soon as `signal` aborts. nodemailer exposes no
211
+ * cancellation hook, so the SMTP conversation itself finishes on its own
212
+ * connection; what unwinds is the caller and everything downstream of it.
213
+ */
214
+ function abortable<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
215
+ if (!signal) return promise;
216
+ signal.throwIfAborted();
217
+ // The listener is detached once the race settles so a long-lived run signal
218
+ // does not accumulate one per send.
219
+ const listener = new AbortController();
220
+ const aborted = new Promise<never>((_, reject) => {
221
+ signal.addEventListener("abort", () => reject(signal.reason), {
222
+ once: true,
223
+ signal: listener.signal,
224
+ });
225
+ });
226
+ return Promise.race([promise, aborted]).finally(() => listener.abort());
227
+ }
228
+
63
229
  /** The comma-joined recipient string echoed back in {@link EmailResult}. */
64
230
  function recipientEcho(to: string[]): string {
65
231
  return to.join(", ");
66
232
  }
67
233
 
234
+ /**
235
+ * Node buffer encodings a wire attachment may name for its inline `content`.
236
+ * Anything else is measured as UTF-8, which over-counts rather than letting
237
+ * an unrecognized encoding slip past the size cap.
238
+ */
239
+ const CONTENT_ENCODINGS: ReadonlySet<string> = new Set([
240
+ "ascii",
241
+ "base64",
242
+ "base64url",
243
+ "binary",
244
+ "hex",
245
+ "latin1",
246
+ "ucs2",
247
+ "ucs-2",
248
+ "utf8",
249
+ "utf-8",
250
+ "utf16le",
251
+ "utf-16le",
252
+ ]);
253
+
254
+ /**
255
+ * Decoded byte size of one attachment's inline content. A `path` attachment
256
+ * contributes nothing: nodemailer streams it, so its bytes never sit in this
257
+ * process and cannot be measured here.
258
+ */
259
+ function attachmentBytes(attachment: EmailAttachment): number {
260
+ const { content, encoding } = attachment;
261
+ if (content === undefined) return 0;
262
+ const declared = encoding?.toLowerCase();
263
+ const resolved =
264
+ declared && CONTENT_ENCODINGS.has(declared) ? (declared as BufferEncoding) : "utf8";
265
+ return Buffer.byteLength(content, resolved);
266
+ }
267
+
268
+ /**
269
+ * Reject a message whose body or attachments exceed the plugin's caps. The
270
+ * body and attachment list arrive from a model, so they are unbounded until
271
+ * something bounds them; most SMTP relays also reject an oversized message
272
+ * only after the whole payload has been uploaded.
273
+ */
274
+ function assertMessageWithinCaps(message: EmailMessage): void {
275
+ if (message.body.length > MAX_BODY_CHARS) {
276
+ throw ValidationError.invalidValue(
277
+ "body",
278
+ message.body.length,
279
+ `at most ${MAX_BODY_CHARS} characters`,
280
+ );
281
+ }
282
+ const attachments = message.attachments ?? [];
283
+ if (attachments.length > MAX_ATTACHMENT_COUNT) {
284
+ throw ValidationError.invalidValue(
285
+ "attachments",
286
+ attachments.length,
287
+ `at most ${MAX_ATTACHMENT_COUNT} files`,
288
+ );
289
+ }
290
+ let total = 0;
291
+ for (const attachment of attachments) {
292
+ const bytes = attachmentBytes(attachment);
293
+ if (bytes > MAX_ATTACHMENT_BYTES) {
294
+ throw ValidationError.invalidValue(
295
+ "attachments[].content",
296
+ bytes,
297
+ `at most ${MAX_ATTACHMENT_BYTES} bytes per file`,
298
+ );
299
+ }
300
+ total += bytes;
301
+ }
302
+ if (total > MAX_ATTACHMENTS_TOTAL_BYTES) {
303
+ throw ValidationError.invalidValue(
304
+ "attachments",
305
+ total,
306
+ `at most ${MAX_ATTACHMENTS_TOTAL_BYTES} bytes across all files`,
307
+ );
308
+ }
309
+ }
310
+
68
311
  /**
69
312
  * Map the wire-format {@link EmailAttachment}s onto nodemailer's
70
313
  * attachment shape, dropping unset optional keys so nodemailer applies
@@ -86,44 +329,48 @@ function toMailAttachments(
86
329
  }
87
330
 
88
331
  /**
89
- * Send (SMTP mode) or persist (file/outbox mode) one message from the
90
- * resolved `from` address. `to` (and optional `cc` / `bcc`) each accept
91
- * one or more addresses, and `attachments` are forwarded as files. The
92
- * body is markdown: SMTP sends it as both a plain-text part (the raw
93
- * source) and an HTML part (rendered), and the outbox embeds the
94
- * rendered HTML in a document. In file mode the returned `messageId` is
95
- * the path written. Throws when `to` carries no recipient, or when `from`
96
- * is not permitted by the configured sender allow-list.
332
+ * Hand one already-validated message to SMTP, or write it to the outbox when
333
+ * no credentials are configured. The half of a send that performs I/O, so it
334
+ * is what runs inside the interceptor chain.
97
335
  */
98
- export async function sendEmail(message: EmailMessage, from: string): Promise<EmailResult> {
99
- if (message.to.length === 0) {
100
- throw new Error("email: `to` must include at least one recipient");
101
- }
336
+ async function dispatch(
337
+ message: EmailMessage,
338
+ from: string,
339
+ signal?: AbortSignal,
340
+ ): Promise<EmailResult> {
102
341
  const { config, transporter } = getEmailRuntime();
103
- assertSenderAllowed(from, config.allowedSenders);
104
342
  const recipient = recipientEcho(message.to);
343
+ signal?.throwIfAborted();
105
344
 
106
345
  if (config.mode === "file") {
107
346
  const path = await writeOutboxEmail(message, from, config.outDir, config.brand);
108
347
  return { sent: true, recipient, from, messageId: path };
109
348
  }
110
349
 
111
- if (!transporter) throw new Error("email: SMTP transport unavailable");
350
+ if (!transporter) {
351
+ throw ConfigurationError.invalidConnection(
352
+ "SMTP",
353
+ "No transport was built for the resolved configuration.",
354
+ );
355
+ }
112
356
  const attachments = toMailAttachments(message.attachments);
113
- const info = await transporter.sendMail({
114
- from,
115
- to: message.to,
116
- subject: message.subject,
117
- text: message.body,
118
- html: renderEmailHtml({
357
+ const info = await abortable(
358
+ transporter.sendMail({
359
+ from,
360
+ to: message.to,
119
361
  subject: message.subject,
120
- body: message.body,
121
- ...(config.brand ? { brand: config.brand } : {}),
362
+ text: message.body,
363
+ html: renderEmailHtml({
364
+ subject: message.subject,
365
+ body: message.body,
366
+ ...(config.brand ? { brand: config.brand } : {}),
367
+ }),
368
+ ...(message.cc && message.cc.length > 0 ? { cc: message.cc } : {}),
369
+ ...(message.bcc && message.bcc.length > 0 ? { bcc: message.bcc } : {}),
370
+ ...(attachments ? { attachments } : {}),
122
371
  }),
123
- ...(message.cc && message.cc.length > 0 ? { cc: message.cc } : {}),
124
- ...(message.bcc && message.bcc.length > 0 ? { bcc: message.bcc } : {}),
125
- ...(attachments ? { attachments } : {}),
126
- });
372
+ signal,
373
+ );
127
374
  return {
128
375
  sent: true,
129
376
  recipient,
@@ -131,3 +378,36 @@ export async function sendEmail(message: EmailMessage, from: string): Promise<Em
131
378
  ...(info.messageId ? { messageId: info.messageId } : {}),
132
379
  };
133
380
  }
381
+
382
+ /**
383
+ * Send (SMTP mode) or persist (file/outbox mode) one message from the
384
+ * resolved `from` address. `to` (and optional `cc` / `bcc`) each accept
385
+ * one or more addresses, and `attachments` are forwarded as files. The
386
+ * body is markdown: SMTP sends it as both a plain-text part (the raw
387
+ * source) and an HTML part (rendered), and the outbox embeds the
388
+ * rendered HTML in a document. In file mode the returned `messageId` is
389
+ * the path written. Throws when `to` carries no recipient, when the body
390
+ * or attachments exceed the plugin's caps, or when `from` is not permitted
391
+ * by the effective sender allow-list.
392
+ *
393
+ * The recipient, cap, and sender checks run before the interceptor chain so
394
+ * their specific status and actionable message reach the caller instead of
395
+ * the chain's stable failure text. `signal` cancels the send.
396
+ */
397
+ export async function sendEmail(
398
+ message: EmailMessage,
399
+ from: string,
400
+ signal?: AbortSignal,
401
+ ): Promise<EmailResult> {
402
+ if (message.to.length === 0) {
403
+ throw ValidationError.missingField("to");
404
+ }
405
+ assertMessageWithinCaps(message);
406
+ assertSenderAllowed(from, getEmailRuntime().config.allowedSenders);
407
+ return executeWrite(
408
+ "send",
409
+ EMAIL_SEND_SETTINGS,
410
+ (executeSignal) => dispatch(message, from, executeSignal),
411
+ signal,
412
+ );
413
+ }
@@ -0,0 +1,163 @@
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
+ });