@dbx-tools/email 0.3.29 → 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/README.md +185 -50
- package/index.ts +4 -2
- package/package.json +3 -3
- package/src/config.ts +140 -30
- package/src/defaults.ts +95 -0
- package/src/email-html.ts +10 -4
- package/src/outbox.ts +7 -1
- package/src/plugin.ts +243 -51
- package/src/sender.ts +30 -17
- package/src/tool.ts +28 -16
- package/src/transport.ts +313 -33
- package/test/config.test.ts +163 -0
- package/test/executor.test.ts +162 -0
- package/test/sender.test.ts +153 -0
- package/test/transport.test.ts +105 -0
package/README.md
CHANGED
|
@@ -1,24 +1,31 @@
|
|
|
1
1
|
# @dbx-tools/email
|
|
2
2
|
|
|
3
|
-
Server-side email runtime,
|
|
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.
|
|
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
|
|
13
|
-
|
|
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
|
-
-
|
|
19
|
-
|
|
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.
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
-
|
|
92
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
210
|
+
| Method | Path | Response |
|
|
211
|
+
| ------ | -------------------- | ---------------------------------------------------------- |
|
|
212
|
+
| `GET` | `/api/email/senders` | `email.emailSendersSchema` from `@dbx-tools/shared-email`. |
|
|
107
213
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
|
121
|
-
|
|
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,
|
|
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`
|
|
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:
|
|
250
|
+
body: "## Status\nResolved.",
|
|
140
251
|
});
|
|
252
|
+
|
|
253
|
+
const fragment = markdown.markdownToHtml("## Status\nResolved.");
|
|
141
254
|
```
|
|
142
255
|
|
|
143
|
-
`
|
|
144
|
-
|
|
145
|
-
|
|
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
|
|
194
|
-
|
|
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,
|
|
199
|
-
|
|
200
|
-
- `
|
|
201
|
-
`
|
|
202
|
-
- `
|
|
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.
|
|
23
|
-
"@dbx-tools/shared-email": "0.3.
|
|
22
|
+
"@dbx-tools/shared-core": "0.3.30",
|
|
23
|
+
"@dbx-tools/shared-email": "0.3.30"
|
|
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.30",
|
|
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
|
-
*
|
|
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
|
|
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
|
|
58
|
+
/** SMTP server port (`SMTP_PORT`). Defaults to {@link DEFAULT_SMTP_PORT}. */
|
|
37
59
|
port?: number;
|
|
38
|
-
/**
|
|
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
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
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
|
-
*
|
|
95
|
-
*
|
|
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: {
|
|
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:
|
|
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.
|
|
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
|
|
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 ===
|
|
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
|
|
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
|
|
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
|
|
227
|
-
"
|
|
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) :
|
|
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
|
|
244
|
-
|
|
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
|
|