@dbx-tools/email 0.3.29 → 0.3.31

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/README.md CHANGED
@@ -1,24 +1,31 @@
1
1
  # @dbx-tools/email
2
2
 
3
- Server-side email runtime, Mastra tool, and AppKit plugin.
3
+ Server-side email runtime, agent tools, and AppKit plugin.
4
4
 
5
5
  Import this package when an AppKit or Mastra backend needs model-drafted
6
6
  outbound email with human approval, sender derivation, SMTP dispatch, and a
7
- local outbox mode for development. Browser-safe message/result schemas live in
7
+ local outbox mode for development. AppKit ships no first-party email surface,
8
+ so this is additive rather than an alternative to a native plugin. Browser-safe
9
+ message/result schemas live in
8
10
  [`@dbx-tools/shared-email`](../../shared/email).
9
11
 
10
- Key features:
12
+ **Key features:**
11
13
 
12
- - AppKit plugin registration for email runtime setup and sender-option routes.
13
- - A Mastra `send_email` tool that suspends for human approval before delivery.
14
+ - AppKit plugin registration that resolves config, verifies SMTP at boot, and
15
+ mounts a sender-options route.
16
+ - Two agent surfaces over one runtime: a Mastra `send_email` tool that suspends
17
+ for human approval, and an AppKit `email.send` tool annotated as a write so a
18
+ host's approval gate fires.
14
19
  - SMTP delivery for production and HTML outbox delivery for local development
15
20
  and tests.
16
21
  - Sender derivation from the current Databricks user, a fixed `EMAIL_FROM`, or a
17
22
  configured domain.
18
- - Sender allow-list enforcement with exact addresses, domains, domain
19
- wildcards, and a final `*` escape hatch.
23
+ - Deny-by-default sender policy with exact addresses, domains, domain wildcards,
24
+ and a named `unrestricted` escape hatch.
20
25
  - Markdown-to-HTML rendering with a small email layout, inline styles, metadata,
21
26
  and attachment summaries.
27
+ - Named caps on body length and attachment size, and an `AbortSignal` threaded
28
+ through every send.
22
29
 
23
30
  ## Register The AppKit Plugin
24
31
 
@@ -36,23 +43,65 @@ await createApp({
36
43
  plugins: [
37
44
  server(),
38
45
  lakebase(),
39
- emailPlugin.email(),
46
+ emailPlugin.email({
47
+ smtp: { host: "smtp.example.com", user: "apikey", password: process.env.SMTP_KEY },
48
+ domain: "mail.example.com",
49
+ }),
40
50
  mastraPlugin.mastra({ agents: support, storage: true }),
41
51
  ],
42
52
  });
43
53
  ```
44
54
 
45
55
  `plugin.email()` validates config, primes the shared runtime, verifies SMTP when
46
- SMTP mode is active, and mounts a sender-options route for UIs. `tool.emailTool()`
47
- creates an approval-gated Mastra `send_email` tool. Approval requires Mastra
48
- storage, so register `lakebase()` or configure storage explicitly in the Mastra
49
- plugin.
56
+ SMTP mode is active, and mounts a sender-options route for UIs. A failed verify
57
+ fails setup, so a bad host or credential shows up in the boot logs rather than on
58
+ the first approved send; outbox mode skips the check and logs loudly instead.
59
+
60
+ `tool.emailTool()` creates an approval-gated Mastra `send_email` tool. Approval
61
+ requires Mastra storage, so register `lakebase()` or configure storage explicitly
62
+ in the Mastra plugin.
50
63
 
51
64
  The plugin does not decide how approval is presented. It emits a Mastra tool
52
65
  suspension and expects the host UI to resume that tool call with an approval or
53
66
  denial result. [`@dbx-tools/ui-email`](../../ui/email) provides the matching
54
67
  approval card and compose components.
55
68
 
69
+ ## Configuration
70
+
71
+ | Option | Type | Default | Description |
72
+ | ---------------- | ------------------------------- | ----------------------------------------- | -------------------------------------------------------------------------------- |
73
+ | `smtp.host` | `string` | `SMTP_HOST` | SMTP server hostname. Omit the whole `smtp` block to run in outbox mode. |
74
+ | `smtp.port` | `number` | `SMTP_PORT`, then `587` | SMTP server port. |
75
+ | `smtp.secure` | `boolean` | `SMTP_SECURE`, then `port === 465` | TLS-on-connect socket rather than STARTTLS. |
76
+ | `smtp.user` | `string` | `SMTP_USER` | SMTP auth username. |
77
+ | `smtp.password` | `string` | `SMTP_PASSWORD` | SMTP auth password or API key. |
78
+ | `domain` | `string` | `EMAIL_DOMAIN` | Domain the sender is derived on, as `<user-local-part>@<domain>`. |
79
+ | `from` | `string` | `EMAIL_FROM` | Fixed `From` address. Skips per-user derivation. |
80
+ | `senderPolicy` | `"allowlist" \| "unrestricted"` | `EMAIL_SENDER_POLICY`, then `"allowlist"` | How the sender is restricted when `allowedSenders` is empty. |
81
+ | `allowedSenders` | `string \| string[]` | `EMAIL_ALLOWED_SENDERS` | Permitted `From` patterns: exact addresses, `*@domain`, a bare `domain`, or `*`. |
82
+ | `outDir` | `string` | `EMAIL_OUTBOX_DIR`, then `<cwd>/tmp` | Directory the outbox writes HTML previews to. |
83
+ | `brand` | `EmailBrand` | none | Accent color, font, display name, and header logo inlined into every message. |
84
+
85
+ Precedence per field is explicit config, then the environment variable, then the
86
+ built-in default.
87
+
88
+ | Environment variable | Purpose |
89
+ | ----------------------- | ------------------------------------------------------------- |
90
+ | `SMTP_HOST` | SMTP server hostname. |
91
+ | `SMTP_PORT` | SMTP server port. |
92
+ | `SMTP_SECURE` | Force or disable a TLS-on-connect socket. |
93
+ | `SMTP_USER` | SMTP auth username. |
94
+ | `SMTP_PASSWORD` | SMTP auth password or API key. |
95
+ | `EMAIL_DOMAIN` | Domain for the derived sender address. |
96
+ | `EMAIL_FROM` | Fixed `From` address. |
97
+ | `EMAIL_SENDER_POLICY` | `allowlist` (default) or `unrestricted`. |
98
+ | `EMAIL_ALLOWED_SENDERS` | Comma- or whitespace-separated `From` allow-list. |
99
+ | `EMAIL_OUTBOX_MODE` | Opt in to writing messages to disk when SMTP is unconfigured. |
100
+ | `EMAIL_OUTBOX_DIR` | Directory for outbox previews. |
101
+
102
+ The `SMTP_*` names are unprefixed because SMTP is a third-party service, not a
103
+ Databricks resource.
104
+
56
105
  ## Send Without An Agent
57
106
 
58
107
  ```ts
@@ -71,7 +120,35 @@ const result = await transport.sendEmail(
71
120
  ```
72
121
 
73
122
  Use direct sends for operational mail, tests, or admin flows where a model is not
74
- involved. The same resolved runtime is used by the AppKit plugin and tool.
123
+ involved. The same resolved runtime is used by the AppKit plugin and both agent
124
+ tools, so every path shares one connection pool, one sender policy, and one set
125
+ of caps. A third argument accepts an `AbortSignal` when the caller wants to stop
126
+ waiting on SMTP.
127
+
128
+ The plugin export is equivalent and resolves the sender for you when the caller
129
+ is a Databricks user:
130
+
131
+ ```ts
132
+ const appkit = await createApp({ plugins: [server(), emailPlugin.email()] });
133
+ await appkit.email.sendEmail(message, "reports@example.com");
134
+ ```
135
+
136
+ ## How Sends Reach AppKit's Interceptors
137
+
138
+ Every send runs through AppKit's interceptor chain (timeout, telemetry, and the
139
+ retry / cache posture in `defaults`), including sends from the Mastra tool, which
140
+ has no plugin instance in scope. The runtime carries an executor slot for this:
141
+ registering the plugin installs its own `Plugin.execute()` there at setup, and
142
+ `sendEmail()` routes through whatever is installed.
143
+
144
+ Nothing else has to be wired up. Two consequences are worth knowing:
145
+
146
+ - In a plain Mastra app with no AppKit plugin registered, the slot falls back to
147
+ running the send directly, so the tool still works without interceptors.
148
+ - Recipient, cap, and sender-policy checks run _before_ the chain, so a rejected
149
+ message keeps its specific status and actionable message. A failure inside the
150
+ chain is re-raised as a stable `ExecutionError`, so an SMTP relay's own wording
151
+ never becomes the caller's or the model's error text.
75
152
 
76
153
  ## Resolve SMTP Or Outbox Mode
77
154
 
@@ -83,15 +160,16 @@ const resolved = config.resolveEmailConfig({
83
160
  domain: "mail.example.com",
84
161
  });
85
162
 
86
- const runtime = transport.getEmailRuntime(resolved);
163
+ const runtime = transport.getEmailRuntime({
164
+ smtp: { host: "smtp.example.com", user: "apikey", password: secret },
165
+ domain: "mail.example.com",
166
+ });
87
167
  ```
88
168
 
89
- Resolution order is explicit config first, then env vars:
90
-
91
- - `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURE`, `SMTP_USER`, `SMTP_PASSWORD`;
92
- - `EMAIL_DOMAIN` or `EMAIL_FROM`;
93
- - `EMAIL_OUTBOX_MODE`, `EMAIL_OUTBOX_DIR`;
94
- - `EMAIL_ALLOWED_SENDERS`.
169
+ `resolveEmailConfig()` returns the validated `ResolvedEmailConfig` for
170
+ inspection. `getEmailRuntime()` takes the same plugin config, resolves it, and
171
+ memoizes the transport process-wide; the plugin primes it at setup, so later
172
+ callers pass nothing and get the same instance.
95
173
 
96
174
  SMTP mode requires host, user, password, and a sender source. Outbox mode writes
97
175
  HTML files to disk when SMTP credentials are absent and `EMAIL_OUTBOX_MODE=1`.
@@ -99,15 +177,44 @@ HTML files to disk when SMTP credentials are absent and `EMAIL_OUTBOX_MODE=1`.
99
177
  Use SMTP mode for deployed apps. Use outbox mode for local demos, automated
100
178
  tests, and development loops where sending real mail would be risky.
101
179
 
180
+ ## Agent Tools
181
+
182
+ Two tools expose the same send capability, one per agent runtime. Both are
183
+ gated: the Mastra tool suspends for approval, and the AppKit tool is annotated
184
+ `{ effect: "write", requiresUserContext: true }` so a host's approval gate fires.
185
+ The AppKit tool is deliberately not `autoInheritable`, so an agent reaches it
186
+ only by wiring it explicitly.
187
+
188
+ | Tool | Runtime | Wiring |
189
+ | ------------ | ------- | ------------------------------------------------------------------------ |
190
+ | `send_email` | Mastra | `tools: () => ({ send_email: emailTool.emailTool() })` |
191
+ | `email.send` | AppKit | `plugins.email.toolkit()` in code, or a `plugin:email` frontmatter entry |
192
+
193
+ ```ts
194
+ import { createApp, server } from "@databricks/appkit";
195
+ import { agents, createAgent } from "@databricks/appkit/beta";
196
+ import { plugin as emailPlugin } from "@dbx-tools/email";
197
+
198
+ const support = createAgent({
199
+ instructions: "Draft emails, but wait for approval before sending.",
200
+ tools: (plugins) => ({ ...plugins.email.toolkit() }),
201
+ });
202
+
203
+ await createApp({
204
+ plugins: [server(), emailPlugin.email(), agents({ agents: { support } })],
205
+ });
206
+ ```
207
+
102
208
  ## AppKit Routes
103
209
 
104
- The plugin exposes a sender-options route for browser clients. The response
105
- matches `email.emailSendersSchema` from
106
- [`@dbx-tools/shared-email`](../../shared/email) and includes:
210
+ | Method | Path | Response |
211
+ | ------ | -------------------- | ---------------------------------------------------------- |
212
+ | `GET` | `/api/email/senders` | `email.emailSendersSchema` from `@dbx-tools/shared-email`. |
107
213
 
108
- - the concrete sender addresses the current user may choose;
109
- - the default sender address;
110
- - whether the list was restricted by policy.
214
+ The response includes the concrete sender addresses the current user may choose,
215
+ the default sender address, and whether the list was restricted by policy. The
216
+ route runs in the on-behalf-of user scope so domain wildcards resolve against the
217
+ caller's own local part.
111
218
 
112
219
  Use this route to populate a `From` dropdown in a compose UI. If no dropdown is
113
220
  shown, the server can still derive the sender from the active user and config.
@@ -115,19 +222,23 @@ shown, the server can still derive the sender from the active user and config.
115
222
  ## Derive And Restrict Senders
116
223
 
117
224
  ```ts
118
- import { sender } from "@dbx-tools/email";
225
+ import { config, sender } from "@dbx-tools/email";
119
226
 
120
- const from = sender.resolveSenderAddress({
121
- userEmail: "alice@databricks.com",
122
- domain: "mail.example.com",
123
- });
227
+ const resolved = config.resolveEmailConfig({ domain: "mail.example.com" });
228
+ const from = sender.resolveSenderAddress(resolved, "alice@databricks.com");
124
229
 
125
- sender.assertSenderAllowed(from, ["*@mail.example.com", "alerts@example.com"]);
230
+ sender.assertSenderAllowed(from, resolved.allowedSenders);
126
231
  ```
127
232
 
128
233
  Sender helpers support exact addresses, domain wildcards, bare domains, and `*`.
129
- `sender.listSenderOptions()` produces the concrete `From` choices for the
130
- current user, which is what the AppKit plugin exposes to clients.
234
+ `sender.listSenderOptions(resolved, userEmail)` produces the concrete `From`
235
+ choices for the current user, which is what the AppKit plugin exposes to clients.
236
+
237
+ The default `senderPolicy: "allowlist"` is deny-by-default: with no explicit
238
+ `allowedSenders`, the effective allow-list is the configured sender source, so a
239
+ deployment that only sets `EMAIL_DOMAIN` rejects a `From` on any other domain.
240
+ Set `senderPolicy: "unrestricted"` to accept any `From` a caller supplies. The
241
+ effective policy is logged at boot.
131
242
 
132
243
  ## Render Markdown Email
133
244
 
@@ -136,13 +247,17 @@ import { emailHtml, markdown } from "@dbx-tools/email";
136
247
 
137
248
  const html = emailHtml.renderEmailHtml({
138
249
  subject: "Incident update",
139
- body: markdown.markdownToHtml("## Status\nResolved."),
250
+ body: "## Status\nResolved.",
140
251
  });
252
+
253
+ const fragment = markdown.markdownToHtml("## Status\nResolved.");
141
254
  ```
142
255
 
143
- `markdown.normalizeMarkdown()` trims common indentation and fenced-text noise.
144
- `markdown.markdownToHtml()` renders Markdown. `emailHtml.renderEmailHtml()` wraps
145
- the rendered body in the package layout and inlines CSS for mail clients.
256
+ `renderEmailHtml()` takes a Markdown `body`, renders it, wraps it in the package
257
+ layout, and inlines the CSS for mail clients. Pass Markdown, not HTML: the
258
+ renderer runs `markdownToHtml()` itself. `markdown.normalizeMarkdown()` trims
259
+ common indentation and repairs fenced-text noise, and
260
+ `markdown.markdownToHtml()` renders a Markdown fragment on its own.
146
261
 
147
262
  ## Brand The Email
148
263
 
@@ -183,26 +298,46 @@ font but no logo.
183
298
  ```ts
184
299
  import { outbox } from "@dbx-tools/email";
185
300
 
186
- await outbox.writeOutboxEmail({
187
- dir: "tmp/email-outbox",
188
- message,
189
- from: "bot@example.com",
190
- });
301
+ const path = await outbox.writeOutboxEmail(message, "bot@example.com", "tmp/email-outbox");
191
302
  ```
192
303
 
193
- Outbox files are HTML previews with metadata in the header. Attachments are
194
- listed in the preview, but attachment bytes are not copied to disk.
304
+ Outbox files are HTML previews with metadata in the header, written to
305
+ `<dir>/<from>/<timestamp>-<subject-slug>.html`. A fourth argument accepts an
306
+ `EmailBrand`. Attachments are listed in the preview, but attachment bytes are not
307
+ copied to disk.
308
+
309
+ ## Limits
310
+
311
+ | Constant | Value | Bounds |
312
+ | ----------------------------- | ------- | ---------------------------------------- |
313
+ | `MAX_BODY_CHARS` | 200,000 | Markdown body length. |
314
+ | `MAX_ATTACHMENT_BYTES` | 10 MiB | One attachment's decoded inline content. |
315
+ | `MAX_ATTACHMENTS_TOTAL_BYTES` | 20 MiB | Combined decoded attachment content. |
316
+ | `MAX_ATTACHMENT_COUNT` | 20 | Attachments on one message. |
317
+ | `SEND_TIMEOUT_MS` | 30,000 | One SMTP conversation. |
318
+ | `VERIFY_TIMEOUT_MS` | 15,000 | The setup-time SMTP handshake. |
319
+
320
+ An oversized payload is rejected with a `ValidationError` before anything is
321
+ handed to SMTP. The constants and the plugin's interceptor settings live in the
322
+ `defaults` module.
195
323
 
196
324
  ## Modules
197
325
 
198
- - `plugin` - `EmailPlugin`, `email()` AppKit plugin factory, and sender route.
199
- - `tool` - approval-gated `emailTool()` Mastra tool.
200
- - `transport` - shared runtime, `getEmailRuntime()`, `resetEmailRuntime()`, and
201
- `sendEmail()`.
202
- - `config` - SMTP/outbox config types, JSON schema, and `resolveEmailConfig()`.
326
+ - `plugin` - `EmailPlugin`, the `email()` AppKit plugin factory, the sender
327
+ route, and the `email.send` AppKit agent tool.
328
+ - `tool` - approval-gated `emailTool()` Mastra tool and the shared
329
+ `SEND_EMAIL_DESCRIPTION`.
330
+ - `transport` - shared runtime, `getEmailRuntime()`, `resetEmailRuntime()`,
331
+ `verifyEmailTransport()`, `sendEmail()`, and the executor slot
332
+ (`setEmailExecutor()`, `executeWrite()`) that puts every send on AppKit's
333
+ interceptor chain.
334
+ - `config` - SMTP/outbox config types, sender policy, JSON schema, and
335
+ `resolveEmailConfig()`.
336
+ - `defaults` - execution settings for the interceptor chain and the payload caps.
203
337
  - `sender` - sender derivation, allow-list parsing, and sender-option listing.
204
338
  - `markdown` / `emailHtml` - Markdown normalization/rendering and HTML layout.
205
339
  - `outbox` - local HTML file writer for development and tests.
340
+ - `brand` - `EmailBrand`, `emailBrandFromContext()`, and `defaultEmailBrand`.
206
341
 
207
342
  Pair this package with [`@dbx-tools/shared-email`](../../shared/email) when a UI
208
343
  or tool schema needs to validate the same email payload.
package/index.ts CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  export * as brand from "./src/brand";
6
6
  export * as config from "./src/config";
7
+ export * as defaults from "./src/defaults";
7
8
  export * as emailHtml from "./src/email-html";
8
9
  export * as markdown from "./src/markdown";
9
10
  export * as outbox from "./src/outbox";
@@ -12,7 +13,8 @@ export * as sender from "./src/sender";
12
13
  export * as tool from "./src/tool";
13
14
  export * as transport from "./src/transport";
14
15
  export type { EmailBrand } from "./src/brand";
15
- export type { SmtpConfig, EmailPluginConfig, ResolvedSmtpConfig, ResolvedFileConfig, ResolvedEmailConfig } from "./src/config";
16
+ export type { SenderPolicy, SmtpConfig, EmailPluginConfig, ResolvedSmtpConfig, ResolvedFileConfig, ResolvedEmailConfig } from "./src/config";
17
+ export type { EmailExecuteConfig, EmailExecutionSettings } from "./src/defaults";
16
18
  export type { EmailHtmlOptions } from "./src/email-html";
17
19
  export type { EmailToolOptions } from "./src/tool";
18
- export type { EmailRuntime } from "./src/transport";
20
+ export type { EmailExecutor, EmailRuntime } from "./src/transport";
package/package.json CHANGED
@@ -19,15 +19,15 @@
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.29",
23
- "@dbx-tools/shared-email": "0.3.29"
22
+ "@dbx-tools/shared-core": "0.3.31",
23
+ "@dbx-tools/shared-email": "0.3.31"
24
24
  },
25
25
  "main": "index.ts",
26
26
  "license": "UNLICENSED",
27
27
  "publishConfig": {
28
28
  "access": "public"
29
29
  },
30
- "version": "0.3.29",
30
+ "version": "0.3.31",
31
31
  "types": "index.ts",
32
32
  "type": "module",
33
33
  "exports": {
package/src/config.ts CHANGED
@@ -18,24 +18,49 @@
18
18
  * to a single SMTP server (e.g. SMTP2GO): `SMTP_HOST`, `SMTP_PORT`,
19
19
  * `SMTP_SECURE`, `SMTP_USER`, `SMTP_PASSWORD`, plus `EMAIL_DOMAIN` for
20
20
  * the derived sender's domain, `EMAIL_FROM` for an explicit override,
21
- * and `EMAIL_OUTBOX_DIR` for the outbox directory.
21
+ * `EMAIL_SENDER_POLICY` for the sender restriction mode, and
22
+ * `EMAIL_OUTBOX_DIR` for the outbox directory.
22
23
  *
23
24
  * @module
24
25
  */
25
26
  import { resolve } from "node:path";
26
- import type { BasePluginConfig } from "@databricks/appkit";
27
+ import { ConfigurationError, ValidationError, type BasePluginConfig } from "@databricks/appkit";
27
28
  import { object } from "@dbx-tools/shared-core";
28
29
  import type { JSONSchema7 } from "json-schema";
29
30
  import type { EmailBrand } from "./brand";
30
31
  import { parseAllowedSenders } from "./sender";
31
32
 
33
+ /** SMTP submission port used when none is configured. */
34
+ export const DEFAULT_SMTP_PORT = 587;
35
+
36
+ /** SMTP port that implies a TLS-on-connect socket rather than STARTTLS. */
37
+ export const IMPLICIT_TLS_SMTP_PORT = 465;
38
+
39
+ /**
40
+ * How the sender (`From`) address is restricted.
41
+ *
42
+ * `"allowlist"` is the default and the deny-by-default posture: a send is
43
+ * permitted only from an address the configuration names, either through
44
+ * {@link EmailPluginConfig.allowedSenders} or, when that is empty, through
45
+ * the configured sender source ({@link EmailPluginConfig.from} as an exact
46
+ * address, {@link EmailPluginConfig.domain} as a `*@domain` wildcard).
47
+ *
48
+ * `"unrestricted"` is the explicit opt-out: any `From` a caller supplies is
49
+ * accepted. Only reach for it when an upstream system already vets the
50
+ * sender.
51
+ */
52
+ export type SenderPolicy = "allowlist" | "unrestricted";
53
+
32
54
  /** SMTP connection + credentials. All fields fall back to env when unset. */
33
55
  export interface SmtpConfig {
34
56
  /** SMTP server hostname (`SMTP_HOST`). */
35
57
  host?: string;
36
- /** SMTP server port (`SMTP_PORT`). Defaults to 587. */
58
+ /** SMTP server port (`SMTP_PORT`). Defaults to {@link DEFAULT_SMTP_PORT}. */
37
59
  port?: number;
38
- /** Use a TLS-on-connect socket (`SMTP_SECURE`). Defaults to `port === 465`. */
60
+ /**
61
+ * Use a TLS-on-connect socket (`SMTP_SECURE`). Defaults to
62
+ * `port === ` {@link IMPLICIT_TLS_SMTP_PORT}.
63
+ */
39
64
  secure?: boolean;
40
65
  /** SMTP auth username (`SMTP_USER`). */
41
66
  user?: string;
@@ -66,15 +91,22 @@ export interface EmailPluginConfig extends BasePluginConfig {
66
91
  */
67
92
  outDir?: string;
68
93
  /**
69
- * Optional allow-list restricting the sender (`From`) address. Each
70
- * entry is an exact address (`user@domain.com`), a domain wildcard
71
- * (`*@domain.com` or the bare `domain.com`, matching any local part on
72
- * that domain), or `*` (any). A resolved / chosen sender that matches
73
- * no entry is rejected at send time. Accepts a `string[]` or a single
74
- * comma- / whitespace-separated string; falls back to
75
- * `EMAIL_ALLOWED_SENDERS`. Omit (or leave empty) for no restriction.
94
+ * Allow-list restricting the sender (`From`) address. Each entry is an
95
+ * exact address (`user@domain.com`), a domain wildcard (`*@domain.com`
96
+ * or the bare `domain.com`, matching any local part on that domain), or
97
+ * `*` (any). A resolved / chosen sender that matches no entry is
98
+ * rejected at send time. Accepts a `string[]` or a single comma- /
99
+ * whitespace-separated string; falls back to `EMAIL_ALLOWED_SENDERS`.
100
+ * When empty, the {@link senderPolicy} decides what is permitted.
76
101
  */
77
102
  allowedSenders?: string | string[];
103
+ /**
104
+ * How the sender address is restricted when {@link allowedSenders} is
105
+ * empty (`EMAIL_SENDER_POLICY`). Defaults to `"allowlist"`, which
106
+ * narrows sends to the configured sender source. See
107
+ * {@link SenderPolicy}.
108
+ */
109
+ senderPolicy?: SenderPolicy;
78
110
  /**
79
111
  * Optional brand styling (accent, font, header logo) applied to the
80
112
  * rendered HTML of every message. Omit for the neutral default layout.
@@ -91,10 +123,14 @@ interface ResolvedSender {
91
123
  /** Explicit sender override; present skips per-user derivation. */
92
124
  from?: string;
93
125
  /**
94
- * Normalized sender allow-list (see {@link EmailPluginConfig.allowedSenders}).
95
- * Empty means no restriction.
126
+ * Effective sender allow-list: the configured patterns when any were
127
+ * given, else the patterns implied by the sender source under an
128
+ * `"allowlist"` policy. Empty means unenforceable (an `"unrestricted"`
129
+ * policy, or an outbox with no sender source to narrow to).
96
130
  */
97
131
  allowedSenders: string[];
132
+ /** The restriction mode the allow-list was resolved under. */
133
+ senderPolicy: SenderPolicy;
98
134
  /** Brand styling applied to rendered HTML; absent for the default layout. */
99
135
  brand?: EmailBrand;
100
136
  }
@@ -126,14 +162,20 @@ export const EMAIL_CONFIG_SCHEMA: JSONSchema7 = {
126
162
  type: "object",
127
163
  description: "SMTP connection and credentials (env fallbacks: SMTP_*).",
128
164
  properties: {
129
- host: { type: "string", description: "SMTP server hostname." },
130
- port: { type: "number", description: "SMTP server port (default 587)." },
165
+ host: { type: "string", description: "SMTP server hostname (SMTP_HOST)." },
166
+ port: {
167
+ type: "number",
168
+ description: `SMTP server port (SMTP_PORT). Defaults to ${DEFAULT_SMTP_PORT}.`,
169
+ },
131
170
  secure: {
132
171
  type: "boolean",
133
- description: "TLS-on-connect socket (default: port === 465).",
172
+ description: `TLS-on-connect socket (SMTP_SECURE). Defaults to port === ${IMPLICIT_TLS_SMTP_PORT}.`,
173
+ },
174
+ user: { type: "string", description: "SMTP auth username (SMTP_USER)." },
175
+ password: {
176
+ type: "string",
177
+ description: "SMTP auth password / API key (SMTP_PASSWORD).",
134
178
  },
135
- user: { type: "string", description: "SMTP auth username." },
136
- password: { type: "string", description: "SMTP auth password / API key." },
137
179
  },
138
180
  },
139
181
  domain: {
@@ -154,15 +196,72 @@ export const EMAIL_CONFIG_SCHEMA: JSONSchema7 = {
154
196
  type: "array",
155
197
  items: { type: "string" },
156
198
  description:
157
- 'Allow-list of permitted sender (From) patterns: exact addresses ("user@domain.com"), domain wildcards ("*@domain.com"), or "*". Also accepts a comma/space-separated string. Falls back to EMAIL_ALLOWED_SENDERS. Empty = unrestricted.',
199
+ 'Allow-list of permitted sender (From) patterns: exact addresses ("user@domain.com"), domain wildcards ("*@domain.com"), or "*". Also accepts a comma/space-separated string. Falls back to EMAIL_ALLOWED_SENDERS. When empty, senderPolicy decides.',
200
+ },
201
+ senderPolicy: {
202
+ type: "string",
203
+ enum: ["allowlist", "unrestricted"],
204
+ description:
205
+ 'How the sender is restricted when allowedSenders is empty (EMAIL_SENDER_POLICY). "allowlist" (default) narrows sends to the configured sender source; "unrestricted" accepts any From.',
206
+ },
207
+ brand: {
208
+ type: "object",
209
+ description:
210
+ "Brand styling inlined into every rendered message. Omit for the neutral default layout.",
211
+ properties: {
212
+ accent: {
213
+ type: "string",
214
+ description: 'Header-band background and body link color (e.g. "#FF3621").',
215
+ },
216
+ onAccent: {
217
+ type: "string",
218
+ description: "Text and logo color rendered on the accent band. Defaults to white.",
219
+ },
220
+ fontFamily: {
221
+ type: "string",
222
+ description: 'Body font stack (e.g. "Inter, ui-sans-serif, system-ui, sans-serif").',
223
+ },
224
+ name: {
225
+ type: "string",
226
+ description: "Product / display name used as the header text and the logo alt text.",
227
+ },
228
+ logoUrl: {
229
+ type: "string",
230
+ description:
231
+ "Logo image for the header band. Only an http(s): or data: URL renders; other values are dropped because a mail client cannot load them.",
232
+ },
233
+ },
234
+ required: ["accent", "fontFamily"],
158
235
  },
159
236
  },
160
237
  };
161
238
 
162
- /** Parse the `SMTP_SECURE` env / config flag, defaulting to `port === 465`. */
239
+ /** Parse the `SMTP_SECURE` env / config flag, defaulting by port. */
163
240
  function resolveSecure(flag: boolean | undefined, port: number): boolean {
164
241
  if (typeof flag === "boolean") return flag;
165
- return object.toBoolean(process.env.SMTP_SECURE) ?? port === 465;
242
+ return object.toBoolean(process.env.SMTP_SECURE) ?? port === IMPLICIT_TLS_SMTP_PORT;
243
+ }
244
+
245
+ /** Parse the `EMAIL_SENDER_POLICY` env / config value, defaulting to deny-by-default. */
246
+ function resolveSenderPolicy(policy: SenderPolicy | undefined): SenderPolicy {
247
+ const raw = policy ?? process.env.EMAIL_SENDER_POLICY?.trim().toLowerCase();
248
+ if (raw === "unrestricted") return "unrestricted";
249
+ if (raw === undefined || raw === "" || raw === "allowlist") return "allowlist";
250
+ throw ValidationError.invalidValue("senderPolicy", raw, '"allowlist" or "unrestricted"');
251
+ }
252
+
253
+ /**
254
+ * Effective allow-list for an `"allowlist"` policy with no explicit patterns:
255
+ * the sender source itself, so a configured domain or fixed address is the
256
+ * boundary rather than "anything goes". Yields nothing when neither is set,
257
+ * which only happens in outbox mode (where the sender falls back to the
258
+ * on-behalf-of user's own address and cannot be enumerated up front).
259
+ */
260
+ function impliedSenderPatterns(domain: string | undefined, from: string | undefined): string[] {
261
+ const patterns: string[] = [];
262
+ if (from) patterns.push(from.trim().toLowerCase());
263
+ if (domain) patterns.push(`*@${domain.trim().toLowerCase()}`);
264
+ return patterns;
166
265
  }
167
266
 
168
267
  /** Whether `EMAIL_OUTBOX_MODE` explicitly opts into the file/outbox fallback. */
@@ -194,6 +293,11 @@ function missingSmtpFields(
194
293
  * outbox falls back to the OBO user's own address). Partial SMTP
195
294
  * configuration or a send with no credentials and no outbox opt-in
196
295
  * throws.
296
+ *
297
+ * The sender allow-list is resolved here too: under the default
298
+ * `"allowlist"` policy an empty list is filled in from the sender source,
299
+ * so a deployment that only sets `EMAIL_DOMAIN` still rejects a `From` on
300
+ * any other domain. See {@link SenderPolicy}.
197
301
  */
198
302
  export function resolveEmailConfig(config: EmailPluginConfig = {}): ResolvedEmailConfig {
199
303
  const smtp = config.smtp ?? {};
@@ -202,13 +306,19 @@ export function resolveEmailConfig(config: EmailPluginConfig = {}): ResolvedEmai
202
306
  const pass = smtp.password ?? process.env.SMTP_PASSWORD;
203
307
  const domain = config.domain ?? process.env.EMAIL_DOMAIN;
204
308
  const from = config.from ?? process.env.EMAIL_FROM;
205
- const allowedSenders = parseAllowedSenders(
309
+ const senderPolicy = resolveSenderPolicy(config.senderPolicy);
310
+ const configuredSenders = parseAllowedSenders(
206
311
  config.allowedSenders ?? process.env.EMAIL_ALLOWED_SENDERS,
207
312
  );
313
+ const allowedSenders =
314
+ configuredSenders.length > 0 || senderPolicy === "unrestricted"
315
+ ? configuredSenders
316
+ : impliedSenderPatterns(domain, from);
208
317
  const sender: ResolvedSender = {
209
318
  ...(domain ? { domain } : {}),
210
319
  ...(from ? { from } : {}),
211
320
  allowedSenders,
321
+ senderPolicy,
212
322
  ...(config.brand ? { brand: config.brand } : {}),
213
323
  };
214
324
 
@@ -216,19 +326,18 @@ export function resolveEmailConfig(config: EmailPluginConfig = {}): ResolvedEmai
216
326
  const hasAnySmtp = Boolean(host || user || pass);
217
327
 
218
328
  if (hasAnySmtp && !hasAllSmtp) {
219
- throw new Error(
220
- `email: incomplete SMTP configuration - set ${missingSmtpFields(host, user, pass).join(", ")}`,
221
- );
329
+ throw ValidationError.missingEnvVars(missingSmtpFields(host, user, pass));
222
330
  }
223
331
 
224
332
  if (hasAllSmtp) {
225
333
  if (!domain && !from) {
226
- throw new Error(
227
- "email: SMTP is configured but no sender source - set EMAIL_DOMAIN (to derive <user>@<domain>) or EMAIL_FROM (a fixed address)",
334
+ throw ConfigurationError.resourceNotFound(
335
+ "Email sender source",
336
+ "Set EMAIL_DOMAIN to derive <user-local-part>@<domain>, or EMAIL_FROM for a fixed address.",
228
337
  );
229
338
  }
230
339
  const portRaw = smtp.port ?? Number(process.env.SMTP_PORT);
231
- const port = Number.isFinite(portRaw) && portRaw ? Number(portRaw) : 587;
340
+ const port = Number.isFinite(portRaw) && portRaw ? Number(portRaw) : DEFAULT_SMTP_PORT;
232
341
  return {
233
342
  mode: "smtp",
234
343
  host: host!,
@@ -240,8 +349,9 @@ export function resolveEmailConfig(config: EmailPluginConfig = {}): ResolvedEmai
240
349
  }
241
350
 
242
351
  if (!isOutboxModeEnabled()) {
243
- throw new Error(
244
- `email: SMTP is not configured - set ${SMTP_REQUIRED_FIELDS.join(", ")} (or EMAIL_OUTBOX_MODE=1 for local outbox testing)`,
352
+ throw ConfigurationError.invalidConnection(
353
+ "SMTP",
354
+ `Set ${SMTP_REQUIRED_FIELDS.join(", ")}, or EMAIL_OUTBOX_MODE=1 to write messages to a local outbox instead.`,
245
355
  );
246
356
  }
247
357