@dbx-tools/email 0.1.9

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 ADDED
@@ -0,0 +1,174 @@
1
+ # @dbx-tools/node-email
2
+
3
+ Server-side email runtime, Mastra tool, and AppKit plugin.
4
+
5
+ Import this package when an AppKit or Mastra backend needs model-drafted
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
8
+ [`@dbx-tools/shared-email`](../../shared/email).
9
+
10
+ Key features:
11
+
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
+ - SMTP delivery for production and HTML outbox delivery for local development
15
+ and tests.
16
+ - Sender derivation from the current Databricks user, a fixed `EMAIL_FROM`, or a
17
+ configured domain.
18
+ - Sender allow-list enforcement with exact addresses, domains, domain
19
+ wildcards, and a final `*` escape hatch.
20
+ - Markdown-to-HTML rendering with a small email layout, inline styles, metadata,
21
+ and attachment summaries.
22
+
23
+ ## Register The AppKit Plugin
24
+
25
+ ```ts
26
+ import { createApp, lakebase, server } from "@databricks/appkit";
27
+ import { plugin as emailPlugin, tool as emailTool } from "@dbx-tools/node-email";
28
+ import { agents, plugin as mastraPlugin } from "@dbx-tools/node-appkit-mastra";
29
+
30
+ const support = agents.createAgent({
31
+ instructions: "Draft emails, but wait for approval before sending.",
32
+ tools: () => ({ send_email: emailTool.emailTool() }),
33
+ });
34
+
35
+ await createApp({
36
+ plugins: [
37
+ server(),
38
+ lakebase(),
39
+ emailPlugin.email(),
40
+ mastraPlugin.mastra({ agents: support, storage: true }),
41
+ ],
42
+ });
43
+ ```
44
+
45
+ `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.
50
+
51
+ The plugin does not decide how approval is presented. It emits a Mastra tool
52
+ suspension and expects the host UI to resume that tool call with an approval or
53
+ denial result. [`@dbx-tools/ui-email`](../../ui/email) provides the matching
54
+ approval card and compose components.
55
+
56
+ ## Send Without An Agent
57
+
58
+ ```ts
59
+ import { transport } from "@dbx-tools/node-email";
60
+
61
+ const result = await transport.sendEmail(
62
+ {
63
+ to: ["alice@example.com"],
64
+ cc: ["team@example.com"],
65
+ subject: "Daily report",
66
+ body: "# Report\nEverything completed.",
67
+ attachments: [{ filename: "report.csv", content: "a,b\n1,2\n" }],
68
+ },
69
+ "reports@example.com",
70
+ );
71
+ ```
72
+
73
+ 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.
75
+
76
+ ## Resolve SMTP Or Outbox Mode
77
+
78
+ ```ts
79
+ import { config, transport } from "@dbx-tools/node-email";
80
+
81
+ const resolved = config.resolveEmailConfig({
82
+ smtp: { host: "smtp.example.com", user: "apikey", password: secret },
83
+ domain: "mail.example.com",
84
+ });
85
+
86
+ const runtime = transport.getEmailRuntime(resolved);
87
+ ```
88
+
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`.
95
+
96
+ SMTP mode requires host, user, password, and a sender source. Outbox mode writes
97
+ HTML files to disk when SMTP credentials are absent and `EMAIL_OUTBOX_MODE=1`.
98
+
99
+ Use SMTP mode for deployed apps. Use outbox mode for local demos, automated
100
+ tests, and development loops where sending real mail would be risky.
101
+
102
+ ## AppKit Routes
103
+
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:
107
+
108
+ - the concrete sender addresses the current user may choose;
109
+ - the default sender address;
110
+ - whether the list was restricted by policy.
111
+
112
+ Use this route to populate a `From` dropdown in a compose UI. If no dropdown is
113
+ shown, the server can still derive the sender from the active user and config.
114
+
115
+ ## Derive And Restrict Senders
116
+
117
+ ```ts
118
+ import { sender } from "@dbx-tools/node-email";
119
+
120
+ const from = sender.resolveSenderAddress({
121
+ userEmail: "alice@databricks.com",
122
+ domain: "mail.example.com",
123
+ });
124
+
125
+ sender.assertSenderAllowed(from, ["*@mail.example.com", "alerts@example.com"]);
126
+ ```
127
+
128
+ 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.
131
+
132
+ ## Render Markdown Email
133
+
134
+ ```ts
135
+ import { emailHtml, markdown } from "@dbx-tools/node-email";
136
+
137
+ const html = emailHtml.renderEmailHtml({
138
+ subject: "Incident update",
139
+ body: markdown.markdownToHtml("## Status\nResolved."),
140
+ });
141
+ ```
142
+
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.
146
+
147
+ ## Use The Outbox In Tests
148
+
149
+ ```ts
150
+ import { outbox } from "@dbx-tools/node-email";
151
+
152
+ await outbox.writeOutboxEmail({
153
+ dir: "tmp/email-outbox",
154
+ message,
155
+ from: "bot@example.com",
156
+ });
157
+ ```
158
+
159
+ Outbox files are HTML previews with metadata in the header. Attachments are
160
+ listed in the preview, but attachment bytes are not copied to disk.
161
+
162
+ ## Modules
163
+
164
+ - `plugin` - `EmailPlugin`, `email()` AppKit plugin factory, and sender route.
165
+ - `tool` - approval-gated `emailTool()` Mastra tool.
166
+ - `transport` - shared runtime, `getEmailRuntime()`, `resetEmailRuntime()`, and
167
+ `sendEmail()`.
168
+ - `config` - SMTP/outbox config types, JSON schema, and `resolveEmailConfig()`.
169
+ - `sender` - sender derivation, allow-list parsing, and sender-option listing.
170
+ - `markdown` / `emailHtml` - Markdown normalization/rendering and HTML layout.
171
+ - `outbox` - local HTML file writer for development and tests.
172
+
173
+ Pair this package with [`@dbx-tools/shared-email`](../../shared/email) when a UI
174
+ or tool schema needs to validate the same email payload.
package/index.ts ADDED
@@ -0,0 +1,16 @@
1
+ // GENERATED by projen watch - DO NOT EDIT.
2
+ // Regenerated from the exporting modules in ./src.
3
+ // Hand edits are overwritten on the next watch; this file is read-only.
4
+
5
+ export * as config from "./src/config";
6
+ export * as emailHtml from "./src/email-html";
7
+ export * as markdown from "./src/markdown";
8
+ export * as outbox from "./src/outbox";
9
+ export * as plugin from "./src/plugin";
10
+ export * as sender from "./src/sender";
11
+ export * as tool from "./src/tool";
12
+ export * as transport from "./src/transport";
13
+ export type { SmtpConfig, EmailPluginConfig, ResolvedSmtpConfig, ResolvedFileConfig, ResolvedEmailConfig } from "./src/config";
14
+ export type { EmailHtmlOptions } from "./src/email-html";
15
+ export type { EmailToolOptions } from "./src/tool";
16
+ export type { EmailRuntime } from "./src/transport";
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@dbx-tools/email",
3
+ "repository": {
4
+ "type": "git",
5
+ "url": "git+https://github.com/reggie-db/dbx-tools.git",
6
+ "directory": "workspaces/node/email"
7
+ },
8
+ "devDependencies": {
9
+ "@types/express": "^5.0.5",
10
+ "@types/json-schema": "^7",
11
+ "@types/node": "^24.6.0",
12
+ "@types/nodemailer": "^7",
13
+ "tsx": "^4.23.0",
14
+ "typescript": "^5.9.3"
15
+ },
16
+ "dependencies": {
17
+ "@databricks/appkit": "^0.43.0",
18
+ "@mastra/core": "^1.47.0",
19
+ "juice": "^12.1.1",
20
+ "marked": "^18.0.5",
21
+ "nodemailer": "^7.0.13",
22
+ "@dbx-tools/shared-core": "0.1.9",
23
+ "@dbx-tools/shared-email": "0.1.9"
24
+ },
25
+ "main": "index.ts",
26
+ "license": "UNLICENSED",
27
+ "version": "0.1.9",
28
+ "types": "index.ts",
29
+ "type": "module",
30
+ "exports": {
31
+ ".": "./index.ts",
32
+ "./package.json": "./package.json"
33
+ },
34
+ "dbxToolsConfig": {
35
+ "tags": [
36
+ "node"
37
+ ]
38
+ },
39
+ "//": "~~ Generated by projen. To modify, edit .projenrc.js and run \"pnpm exec projen\".",
40
+ "scripts": {
41
+ "build": "projen build",
42
+ "compile": "projen compile",
43
+ "default": "projen default",
44
+ "package": "projen package",
45
+ "post-compile": "projen post-compile",
46
+ "pre-compile": "projen pre-compile",
47
+ "test": "projen test",
48
+ "watch": "projen watch",
49
+ "projen": "projen"
50
+ }
51
+ }
package/src/config.ts ADDED
@@ -0,0 +1,241 @@
1
+ /**
2
+ * SMTP configuration for the email plugin: the typed
3
+ * {@link EmailPluginConfig} (the plugin's slice of AppKit config), the
4
+ * JSON Schema the manifest publishes for it, and {@link resolveEmailConfig}
5
+ * which layers that config over environment defaults into the concrete
6
+ * {@link ResolvedEmailConfig} the runtime needs.
7
+ *
8
+ * Two modes fall out of the resolution. When SMTP credentials (host +
9
+ * user + password) are all present it resolves to `mode: "smtp"` and
10
+ * mail is sent for real. When they are absent and `EMAIL_OUTBOX_MODE`
11
+ * is explicitly enabled it resolves to `mode: "file"` (an "outbox")
12
+ * and each message is written to disk as HTML instead of sent. Any
13
+ * partial SMTP configuration or a send attempt with no credentials and
14
+ * no outbox opt-in throws.
15
+ *
16
+ * Precedence per field: explicit plugin config wins, then the matching
17
+ * environment variable. Env names are unprefixed because the app talks
18
+ * to a single SMTP server (e.g. SMTP2GO): `SMTP_HOST`, `SMTP_PORT`,
19
+ * `SMTP_SECURE`, `SMTP_USER`, `SMTP_PASSWORD`, plus `EMAIL_DOMAIN` for
20
+ * the derived sender's domain, `EMAIL_FROM` for an explicit override,
21
+ * and `EMAIL_OUTBOX_DIR` for the outbox directory.
22
+ */
23
+ import type { BasePluginConfig } from "@databricks/appkit";
24
+ import type { JSONSchema7 } from "json-schema";
25
+ import { resolve } from "node:path";
26
+ import { parseAllowedSenders } from "./sender";
27
+
28
+ /** SMTP connection + credentials. All fields fall back to env when unset. */
29
+ export interface SmtpConfig {
30
+ /** SMTP server hostname (`SMTP_HOST`). */
31
+ host?: string;
32
+ /** SMTP server port (`SMTP_PORT`). Defaults to 587. */
33
+ port?: number;
34
+ /** Use a TLS-on-connect socket (`SMTP_SECURE`). Defaults to `port === 465`. */
35
+ secure?: boolean;
36
+ /** SMTP auth username (`SMTP_USER`). */
37
+ user?: string;
38
+ /** SMTP auth password / API key (`SMTP_PASSWORD`). */
39
+ password?: string;
40
+ }
41
+
42
+ /** AppKit config accepted by the email plugin. */
43
+ export interface EmailPluginConfig extends BasePluginConfig {
44
+ /** SMTP connection + credentials. Omit to run in file/outbox mode. */
45
+ smtp?: SmtpConfig;
46
+ /**
47
+ * Domain used to build the sender address from the on-behalf-of user
48
+ * (`<local-part>@<domain>`). Falls back to `EMAIL_DOMAIN`. Required in
49
+ * SMTP mode unless {@link from} is set; optional in file mode (the
50
+ * outbox falls back to the user's own email address).
51
+ */
52
+ domain?: string;
53
+ /**
54
+ * Explicit `From` address. When set, the sender is used verbatim and
55
+ * the per-user derivation is skipped. Falls back to `EMAIL_FROM`.
56
+ */
57
+ from?: string;
58
+ /**
59
+ * Directory for the file/outbox fallback. Falls back to
60
+ * `EMAIL_OUTBOX_DIR`, then `<cwd>/tmp`. Only used when SMTP
61
+ * credentials are absent.
62
+ */
63
+ outDir?: string;
64
+ /**
65
+ * Optional allow-list restricting the sender (`From`) address. Each
66
+ * entry is an exact address (`user@domain.com`), a domain wildcard
67
+ * (`*@domain.com` or the bare `domain.com`, matching any local part on
68
+ * that domain), or `*` (any). A resolved / chosen sender that matches
69
+ * no entry is rejected at send time. Accepts a `string[]` or a single
70
+ * comma- / whitespace-separated string; falls back to
71
+ * `EMAIL_ALLOWED_SENDERS`. Omit (or leave empty) for no restriction.
72
+ */
73
+ allowedSenders?: string | string[];
74
+ }
75
+
76
+ /** Sender source shared by both resolved modes. */
77
+ interface ResolvedSender {
78
+ /** Sender domain; present whenever {@link from} is absent. */
79
+ domain?: string;
80
+ /** Explicit sender override; present skips per-user derivation. */
81
+ from?: string;
82
+ /**
83
+ * Normalized sender allow-list (see {@link EmailPluginConfig.allowedSenders}).
84
+ * Empty means no restriction.
85
+ */
86
+ allowedSenders: string[];
87
+ }
88
+
89
+ /** Resolved config for real SMTP delivery. */
90
+ export interface ResolvedSmtpConfig extends ResolvedSender {
91
+ mode: "smtp";
92
+ host: string;
93
+ port: number;
94
+ secure: boolean;
95
+ auth: { user: string; pass: string };
96
+ }
97
+
98
+ /** Resolved config for the file/outbox fallback (no SMTP credentials). */
99
+ export interface ResolvedFileConfig extends ResolvedSender {
100
+ mode: "file";
101
+ /** Absolute directory messages are written under. */
102
+ outDir: string;
103
+ }
104
+
105
+ /** Concrete, validated config the runtime dispatches through. */
106
+ export type ResolvedEmailConfig = ResolvedSmtpConfig | ResolvedFileConfig;
107
+
108
+ /** JSON Schema published on the manifest's `config.schema`. */
109
+ export const EMAIL_CONFIG_SCHEMA: JSONSchema7 = {
110
+ type: "object",
111
+ properties: {
112
+ smtp: {
113
+ type: "object",
114
+ description: "SMTP connection and credentials (env fallbacks: SMTP_*).",
115
+ properties: {
116
+ host: { type: "string", description: "SMTP server hostname." },
117
+ port: { type: "number", description: "SMTP server port (default 587)." },
118
+ secure: {
119
+ type: "boolean",
120
+ description: "TLS-on-connect socket (default: port === 465).",
121
+ },
122
+ user: { type: "string", description: "SMTP auth username." },
123
+ password: { type: "string", description: "SMTP auth password / API key." },
124
+ },
125
+ },
126
+ domain: {
127
+ type: "string",
128
+ description:
129
+ "Domain for the derived sender address (<user-local-part>@<domain>). Falls back to EMAIL_DOMAIN.",
130
+ },
131
+ from: {
132
+ type: "string",
133
+ description: "Explicit From address; skips per-user derivation. Falls back to EMAIL_FROM.",
134
+ },
135
+ outDir: {
136
+ type: "string",
137
+ description:
138
+ "Directory for the file/outbox fallback when SMTP is unconfigured. Falls back to EMAIL_OUTBOX_DIR, then <cwd>/tmp.",
139
+ },
140
+ allowedSenders: {
141
+ type: "array",
142
+ items: { type: "string" },
143
+ description:
144
+ '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.',
145
+ },
146
+ },
147
+ };
148
+
149
+ /** Parse the `SMTP_SECURE` env / config flag, defaulting to `port === 465`. */
150
+ function resolveSecure(flag: boolean | undefined, port: number): boolean {
151
+ if (typeof flag === "boolean") return flag;
152
+ const env = process.env["SMTP_SECURE"];
153
+ if (env !== undefined) return /^(1|true|yes)$/i.test(env.trim());
154
+ return port === 465;
155
+ }
156
+
157
+ /** Whether `EMAIL_OUTBOX_MODE` explicitly opts into the file/outbox fallback. */
158
+ function isOutboxModeEnabled(): boolean {
159
+ const env = process.env["EMAIL_OUTBOX_MODE"];
160
+ return env !== undefined && /^(1|true|yes)$/i.test(env.trim());
161
+ }
162
+
163
+ const SMTP_REQUIRED_FIELDS = ["SMTP_HOST", "SMTP_USER", "SMTP_PASSWORD"] as const;
164
+
165
+ /** List env keys for SMTP fields that are unset in the resolved credential set. */
166
+ function missingSmtpFields(
167
+ host: string | undefined,
168
+ user: string | undefined,
169
+ pass: string | undefined,
170
+ ): string[] {
171
+ const values = [host, user, pass] as const;
172
+ return SMTP_REQUIRED_FIELDS.filter((_, index) => !values[index]);
173
+ }
174
+
175
+ /**
176
+ * Resolve plugin config over environment defaults.
177
+ *
178
+ * When SMTP host + user + password are all present, returns `mode:
179
+ * "smtp"` for real delivery (and throws if no sender source - domain or
180
+ * from - is configured, since SMTP can't derive one from nothing).
181
+ * When all three are absent and `EMAIL_OUTBOX_MODE` is enabled, returns
182
+ * `mode: "file"` so the runtime writes messages to the outbox directory
183
+ * for local testing; in that mode a sender source is optional (the
184
+ * outbox falls back to the OBO user's own address). Partial SMTP
185
+ * configuration or a send with no credentials and no outbox opt-in
186
+ * throws.
187
+ */
188
+ export function resolveEmailConfig(config: EmailPluginConfig = {}): ResolvedEmailConfig {
189
+ const smtp = config.smtp ?? {};
190
+ const host = smtp.host ?? process.env["SMTP_HOST"];
191
+ const user = smtp.user ?? process.env["SMTP_USER"];
192
+ const pass = smtp.password ?? process.env["SMTP_PASSWORD"];
193
+ const domain = config.domain ?? process.env["EMAIL_DOMAIN"];
194
+ const from = config.from ?? process.env["EMAIL_FROM"];
195
+ const allowedSenders = parseAllowedSenders(
196
+ config.allowedSenders ?? process.env["EMAIL_ALLOWED_SENDERS"],
197
+ );
198
+ const sender: ResolvedSender = {
199
+ ...(domain ? { domain } : {}),
200
+ ...(from ? { from } : {}),
201
+ allowedSenders,
202
+ };
203
+
204
+ const hasAllSmtp = Boolean(host && user && pass);
205
+ const hasAnySmtp = Boolean(host || user || pass);
206
+
207
+ if (hasAnySmtp && !hasAllSmtp) {
208
+ throw new Error(
209
+ `email: incomplete SMTP configuration - set ${missingSmtpFields(host, user, pass).join(", ")}`,
210
+ );
211
+ }
212
+
213
+ if (hasAllSmtp) {
214
+ if (!domain && !from) {
215
+ throw new Error(
216
+ "email: SMTP is configured but no sender source - set EMAIL_DOMAIN (to derive <user>@<domain>) or EMAIL_FROM (a fixed address)",
217
+ );
218
+ }
219
+ const portRaw = smtp.port ?? Number(process.env["SMTP_PORT"]);
220
+ const port = Number.isFinite(portRaw) && portRaw ? Number(portRaw) : 587;
221
+ return {
222
+ mode: "smtp",
223
+ host: host!,
224
+ port,
225
+ secure: resolveSecure(smtp.secure, port),
226
+ auth: { user: user!, pass: pass! },
227
+ ...sender,
228
+ };
229
+ }
230
+
231
+ if (!isOutboxModeEnabled()) {
232
+ throw new Error(
233
+ `email: SMTP is not configured - set ${SMTP_REQUIRED_FIELDS.join(", ")} (or EMAIL_OUTBOX_MODE=1 for local outbox testing)`,
234
+ );
235
+ }
236
+
237
+ const outDir = resolve(
238
+ config.outDir ?? process.env["EMAIL_OUTBOX_DIR"] ?? resolve(process.cwd(), "tmp"),
239
+ );
240
+ return { mode: "file", outDir, ...sender };
241
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Email HTML assembly: render a markdown body into a branded, responsive
3
+ * email layout and inline the stylesheet with `juice`.
4
+ *
5
+ * The layout is the classic email-safe pattern (a centered 600px
6
+ * table-based container with a header band, content card, and optional
7
+ * footer) - the same shape MJML emits, hand-built here because MJML's
8
+ * toolchain pulls fast-moving browser-data deps (caniuse-lite,
9
+ * baseline-browser-mapping) that are awkward to install behind a
10
+ * locked-down registry. Inlining matters because real clients (Gmail,
11
+ * Outlook) strip `<style>` blocks and ignore class selectors; the same
12
+ * renderer feeds both the local outbox preview and the SMTP HTML part,
13
+ * so a browser and an inbox show the same thing.
14
+ */
15
+
16
+ import { string } from "@dbx-tools/shared-core";
17
+ import juice from "juice";
18
+ import { markdownToHtml } from "./markdown";
19
+
20
+ /** Accent color for the header band and links. */
21
+ const ACCENT = "#0b6bcb";
22
+
23
+ /** Escape HTML-significant characters (re-exported from `@dbx-tools/shared`). */
24
+ export const escapeHtml = string.escapeHtml;
25
+
26
+ /**
27
+ * Content stylesheet inlined onto the markdown body (juice maps these
28
+ * onto elements). Outer layout styling is written inline directly so it
29
+ * survives even if inlining is skipped; the `@media` rule is preserved
30
+ * by juice for clients that honor it.
31
+ */
32
+ const CONTENT_CSS = `
33
+ .email-body { font-size: 15px; line-height: 1.55; color: #1a1a1a; }
34
+ .email-body p { margin: 0 0 1rem; }
35
+ .email-body a { color: ${ACCENT}; }
36
+ .email-body h1, .email-body h2, .email-body h3 { margin: 1.4rem 0 0.6rem; line-height: 1.25; }
37
+ .email-body ul, .email-body ol { margin: 0 0 1rem; padding-left: 1.4rem; }
38
+ .email-body table { border-collapse: collapse; margin: 1rem 0; width: 100%; font-size: 14px; }
39
+ .email-body th, .email-body td { border: 1px solid #d0d7de; padding: 6px 10px; text-align: left; }
40
+ .email-body th { background: #f6f8fa; font-weight: 600; }
41
+ .email-body code { background: #f1f3f5; padding: 2px 5px; border-radius: 4px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.9em; }
42
+ .email-body pre { background: #f1f3f5; padding: 12px; border-radius: 6px; overflow-x: auto; }
43
+ .email-body pre code { background: none; padding: 0; }
44
+ .email-body blockquote { margin: 1rem 0; padding: 0 1rem; color: #57606a; border-left: 3px solid #d0d7de; }
45
+ .email-body img { max-width: 100%; height: auto; }
46
+ .meta { border-collapse: collapse; font-size: 13px; margin-bottom: 4px; }
47
+ .meta th { text-align: left; padding: 2px 12px 2px 0; vertical-align: top; color: #6b7280; font-weight: 600; white-space: nowrap; }
48
+ .meta td { padding: 2px 0; color: #374151; }
49
+ @media only screen and (max-width: 620px) {
50
+ .container { width: 100% !important; }
51
+ .gutter { padding-left: 20px !important; padding-right: 20px !important; }
52
+ }`;
53
+
54
+ /** Options for {@link renderEmailHtml}. */
55
+ export interface EmailHtmlOptions {
56
+ /** Markdown body. Rendered to HTML, then wrapped in the layout. */
57
+ body: string;
58
+ /** Header-band title and document `<title>`. Defaults to "Message". */
59
+ subject?: string;
60
+ /**
61
+ * Optional `[label, value]` envelope rows shown above the body (used
62
+ * by the outbox preview; omitted for SMTP sends, where the mail client
63
+ * shows the envelope itself).
64
+ */
65
+ headers?: ReadonlyArray<readonly [string, string]>;
66
+ /** Optional small-print footer line. Omitted when unset. */
67
+ footer?: string;
68
+ }
69
+
70
+ /** Render the optional envelope-header table block. */
71
+ function metaBlock(headers: EmailHtmlOptions["headers"]): string {
72
+ if (!headers || headers.length === 0) return "";
73
+ const rows = headers
74
+ .map(([label, value]) => `<tr><th>${escapeHtml(label)}</th><td>${escapeHtml(value)}</td></tr>`)
75
+ .join("");
76
+ return `<table role="presentation" class="meta"><tbody>${rows}</tbody></table>`;
77
+ }
78
+
79
+ /** Render the optional footer row. */
80
+ function footerRow(footer: string | undefined): string {
81
+ if (!footer) return "";
82
+ return `
83
+ <tr>
84
+ <td class="gutter" style="padding: 16px 32px; border-top: 1px solid #eaecef; color: #9aa0a6; font-size: 12px; line-height: 1.5;">
85
+ ${escapeHtml(footer)}
86
+ </td>
87
+ </tr>`;
88
+ }
89
+
90
+ /**
91
+ * Render `body` (markdown) into a complete, style-inlined email document
92
+ * using the branded responsive layout.
93
+ */
94
+ export function renderEmailHtml(opts: EmailHtmlOptions): string {
95
+ const title = opts.subject?.trim() || "Message";
96
+ const doc = `<!doctype html>
97
+ <html lang="en">
98
+ <head>
99
+ <meta charset="utf-8" />
100
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
101
+ <meta name="color-scheme" content="light only" />
102
+ <title>${escapeHtml(title)}</title>
103
+ <style>${CONTENT_CSS}
104
+ </style>
105
+ </head>
106
+ <body style="margin: 0; padding: 0; background-color: #f4f5f7; -webkit-text-size-adjust: 100%;">
107
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color: #f4f5f7;">
108
+ <tr>
109
+ <td align="center" style="padding: 24px 12px;">
110
+ <table role="presentation" class="container" width="600" cellpadding="0" cellspacing="0" style="width: 600px; max-width: 100%; background-color: #ffffff; border-radius: 10px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.08); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;">
111
+ <tr>
112
+ <td class="gutter" style="padding: 20px 32px; background-color: ${ACCENT};">
113
+ <span style="color: #ffffff; font-size: 18px; font-weight: 700; line-height: 1.3;">${escapeHtml(title)}</span>
114
+ </td>
115
+ </tr>
116
+ <tr>
117
+ <td class="gutter" style="padding: 24px 32px 8px;">
118
+ ${metaBlock(opts.headers)}
119
+ <div class="email-body">${markdownToHtml(opts.body)}</div>
120
+ </td>
121
+ </tr>${footerRow(opts.footer)}
122
+ </table>
123
+ </td>
124
+ </tr>
125
+ </table>
126
+ </body>
127
+ </html>
128
+ `;
129
+ return juice(doc);
130
+ }
package/src/juice.d.ts ADDED
@@ -0,0 +1,17 @@
1
+ // Minimal ambient types for `juice` (no `@types/juice` is published).
2
+ // We only use the default export: inline a stylesheet found in the
3
+ // document's <style> tags into element `style` attributes.
4
+ declare module "juice" {
5
+ interface JuiceOptions {
6
+ applyStyleTags?: boolean;
7
+ removeStyleTags?: boolean;
8
+ preserveImportant?: boolean;
9
+ preserveMediaQueries?: boolean;
10
+ preserveFontFaces?: boolean;
11
+ inlinePseudoElements?: boolean;
12
+ xmlMode?: boolean;
13
+ [option: string]: unknown;
14
+ }
15
+ function juice(html: string, options?: JuiceOptions): string;
16
+ export = juice;
17
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Server-side markdown -> HTML rendering for email bodies. The model
3
+ * drafts bodies in markdown; this turns them into real HTML (GFM tables,
4
+ * lists, code, links). {@link normalizeMarkdown} first repairs the two
5
+ * structures LLMs most often emit as plain text instead of markdown -
6
+ * `=====` divider rules and pipe tables missing their `| --- |`
7
+ * separator row - so they render as a `<hr>` / `<table>` rather than
8
+ * literal text. The prompt steers the model away from ASCII art; this is
9
+ * the belt-and-suspenders fallback.
10
+ */
11
+
12
+ import { marked } from "marked";
13
+
14
+ /** A line of only `=` or `_` (length >= 3): an ASCII divider rule. */
15
+ function isAsciiRule(line: string): boolean {
16
+ return /^[ \t]*[=_]{3,}[ \t]*$/.test(line);
17
+ }
18
+
19
+ /** A line that participates in a markdown pipe table (has a `|`). */
20
+ function isPipeRow(line: string): boolean {
21
+ return line.includes("|") && line.trim().length > 0;
22
+ }
23
+
24
+ /** A markdown table separator row, e.g. `| --- | :--: |`. */
25
+ function isSeparatorRow(line: string): boolean {
26
+ return /^[ \t]*\|?[ \t]*:?-{2,}:?[ \t]*(\|[ \t]*:?-{2,}:?[ \t]*)+\|?[ \t]*$/.test(line);
27
+ }
28
+
29
+ /** Column count of a pipe row (outer pipes optional). */
30
+ function pipeColumns(line: string): number {
31
+ return line.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").length;
32
+ }
33
+
34
+ /** A GFM separator row with `columns` cells. */
35
+ function separatorRow(columns: number): string {
36
+ return `| ${Array.from({ length: columns }, () => "---").join(" | ")} |`;
37
+ }
38
+
39
+ /**
40
+ * Repair common LLM "looks-like-markdown-but-isn't" output: convert
41
+ * standalone `=====` / `_____` rules to a `---` thematic break (leaving
42
+ * genuine setext-heading underlines intact), and insert a `| --- |`
43
+ * separator after the first row of a pipe block that lacks one so GFM
44
+ * renders it as a table.
45
+ */
46
+ export function normalizeMarkdown(src: string): string {
47
+ const lines = src.split("\n");
48
+ const out: string[] = [];
49
+ for (let i = 0; i < lines.length; i++) {
50
+ const line = lines[i]!;
51
+ const prev = i > 0 ? lines[i - 1]! : "";
52
+ const next = i + 1 < lines.length ? lines[i + 1]! : "";
53
+
54
+ if (isAsciiRule(line)) {
55
+ // A run of `=` directly under a non-blank text line is a setext H1
56
+ // underline - keep it. Anything else is a decorative divider.
57
+ const isSetextUnderline =
58
+ /^[ \t]*={3,}[ \t]*$/.test(line) && prev.trim() !== "" && !isPipeRow(prev);
59
+ out.push(isSetextUnderline ? line : "---");
60
+ continue;
61
+ }
62
+
63
+ // First row of a pipe block (prev is not itself a pipe row) followed
64
+ // by another aligned pipe row, with no separator and no `###` bar-
65
+ // chart fill: treat as a table header and inject the separator.
66
+ const startsPipeBlock = isPipeRow(line) && !isPipeRow(prev);
67
+ if (
68
+ startsPipeBlock &&
69
+ isPipeRow(next) &&
70
+ !isSeparatorRow(next) &&
71
+ !/#{3,}/.test(line) &&
72
+ pipeColumns(line) >= 2 &&
73
+ pipeColumns(line) === pipeColumns(next)
74
+ ) {
75
+ out.push(line);
76
+ out.push(separatorRow(pipeColumns(line)));
77
+ continue;
78
+ }
79
+
80
+ out.push(line);
81
+ }
82
+ return out.join("\n");
83
+ }
84
+
85
+ /** Render a markdown body to an HTML fragment (GFM tables enabled). */
86
+ export function markdownToHtml(body: string): string {
87
+ return marked.parse(normalizeMarkdown(body), {
88
+ async: false,
89
+ gfm: true,
90
+ breaks: true,
91
+ });
92
+ }
package/src/outbox.ts ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Filesystem outbox for the no-SMTP fallback: {@link writeOutboxEmail}
3
+ * persists one drafted message as a standalone HTML file under
4
+ * `<dir>/<from>/<timestamp>-<subject-slug>.html` instead of sending it.
5
+ * This lets the approval flow and agents be exercised end-to-end with
6
+ * zero email configuration - open the file in a browser to see exactly
7
+ * what would have gone out (the HTML matches the SMTP send, headers
8
+ * table aside). The body is rendered + style-inlined by
9
+ * {@link renderEmailHtml}. Attachments are listed by filename in the
10
+ * header table but not written to disk - the outbox previews the
11
+ * envelope, it doesn't reproduce the wire payload.
12
+ */
13
+
14
+ import type { EmailMessage } from "@dbx-tools/shared-email";
15
+ import { mkdir, writeFile } from "node:fs/promises";
16
+ import { join, resolve } from "node:path";
17
+ import { renderEmailHtml } from "./email-html";
18
+
19
+ /** Filesystem-safe slug of the subject for the file name. */
20
+ function subjectSlug(subject: string): string {
21
+ const slug = subject
22
+ .trim()
23
+ .toLowerCase()
24
+ .replace(/[^a-z0-9]+/g, "-")
25
+ .replace(/^-+|-+$/g, "");
26
+ return (slug || "email").slice(0, 48);
27
+ }
28
+
29
+ /** The envelope rows shown above the body in the preview file. */
30
+ function headerRows(message: EmailMessage, from: string): Array<readonly [string, string]> {
31
+ const rows: Array<readonly [string, string | undefined]> = [
32
+ ["From", from],
33
+ ["To", message.to.join(", ")],
34
+ ["Cc", message.cc?.join(", ")],
35
+ ["Bcc", message.bcc?.join(", ")],
36
+ ["Subject", message.subject],
37
+ ["Attachments", message.attachments?.map((att) => att.filename).join(", ")],
38
+ ["Date", new Date().toISOString()],
39
+ ];
40
+ return rows.filter((row): row is [string, string] => Boolean(row[1]));
41
+ }
42
+
43
+ /**
44
+ * Write one message as HTML under `<dir>/<from>/` and return the
45
+ * absolute path. The per-sender folder mirrors the SMTP `From`, so test
46
+ * output groups by who the message would have been sent as.
47
+ */
48
+ export async function writeOutboxEmail(
49
+ message: EmailMessage,
50
+ from: string,
51
+ dir: string,
52
+ ): Promise<string> {
53
+ const folder = resolve(dir, from);
54
+ await mkdir(folder, { recursive: true });
55
+ const path = join(folder, `${Date.now()}-${subjectSlug(message.subject)}.html`);
56
+ const html = renderEmailHtml({
57
+ subject: message.subject,
58
+ headers: headerRows(message, from),
59
+ body: message.body,
60
+ footer: "Local outbox preview - written to disk, not sent (no SMTP credentials configured).",
61
+ });
62
+ await writeFile(path, html, "utf8");
63
+ return path;
64
+ }
package/src/plugin.ts ADDED
@@ -0,0 +1,161 @@
1
+ /**
2
+ * AppKit plugin (registered name: `email`) that owns the SMTP runtime
3
+ * for outbound mail. Registering it validates the SMTP configuration
4
+ * and verifies connectivity at startup, so a bad host / credential
5
+ * surfaces in the boot logs instead of on the first approved send. The
6
+ * actual send happens through the approval-gated {@link emailTool}
7
+ * spread into an agent; this plugin primes the shared transport the
8
+ * tool reuses and exposes a direct {@link sendEmail} for non-agent
9
+ * callers.
10
+ *
11
+ * Configuration is the manifest-published {@link EmailPluginConfig}
12
+ * (SMTP host/port/credentials, sender domain or explicit `from`, and an
13
+ * optional `allowedSenders` restriction), with unprefixed `SMTP_*` /
14
+ * `EMAIL_*` environment fallbacks.
15
+ *
16
+ * The plugin mounts one route under its base path (`/api/email`):
17
+ * `GET /senders` returns the permitted `From` options for the calling
18
+ * user, so a compose UI can offer them in a dropdown.
19
+ */
20
+
21
+ import {
22
+ getExecutionContext,
23
+ Plugin,
24
+ toPlugin,
25
+ type IAppRouter,
26
+ type PluginManifest,
27
+ } from "@databricks/appkit";
28
+ import type { EmailMessage, EmailResult, EmailSenders } from "@dbx-tools/shared-email";
29
+ import { error, log } from "@dbx-tools/shared-core";
30
+ import type express from "express";
31
+ import { EMAIL_CONFIG_SCHEMA, type EmailPluginConfig } from "./config";
32
+ import { isSenderAllowed, listSenderOptions, resolveSenderAddress } from "./sender";
33
+ import { getEmailRuntime, sendEmail } from "./transport";
34
+
35
+ /** Mount-relative route (under `/api/email`) for the sender-options lookup. */
36
+ const SENDERS_ROUTE = "/senders";
37
+
38
+ /**
39
+ * AppKit plugin that configures and verifies the SMTP transport used by
40
+ * the `send_email` tool.
41
+ */
42
+ export class EmailPlugin extends Plugin<EmailPluginConfig> {
43
+ static manifest = {
44
+ name: "email",
45
+ displayName: "Email",
46
+ description:
47
+ "Sends approval-gated email over SMTP, with the sender derived from " +
48
+ "the on-behalf-of user's address on a configured domain.",
49
+ stability: "beta",
50
+ resources: {
51
+ required: [],
52
+ optional: [],
53
+ },
54
+ config: { schema: EMAIL_CONFIG_SCHEMA },
55
+ } satisfies PluginManifest<"email">;
56
+
57
+ private logger = log.logger(this);
58
+
59
+ /**
60
+ * Prime the shared runtime from this plugin's config (over env). In
61
+ * SMTP mode, verify connectivity - a failed verify is logged as a
62
+ * warning rather than thrown, so the app still boots and the first
63
+ * send surfaces the real error via the approval flow. With no SMTP
64
+ * credentials the runtime is in file/outbox mode (only when
65
+ * `EMAIL_OUTBOX_MODE` is set), logged here so it's obvious mail is
66
+ * being written to disk rather than sent.
67
+ */
68
+ override async setup(): Promise<void> {
69
+ const { transporter, config } = getEmailRuntime(this.config);
70
+ if (config.mode === "file") {
71
+ this.logger.warn("outbox:enabled", {
72
+ dir: config.outDir,
73
+ reason: "no SMTP credentials configured; emails are written to disk instead of sent",
74
+ });
75
+ return;
76
+ }
77
+ try {
78
+ await transporter?.verify();
79
+ this.logger.info("smtp:ready", {
80
+ host: config.host,
81
+ port: config.port,
82
+ secure: config.secure,
83
+ });
84
+ } catch (err) {
85
+ this.logger.warn("smtp:unverified", { error: error.errorMessage(err) });
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Expose the sender-options lookup so UI compose views can populate a
91
+ * `From` dropdown from the configured allow-list. Mounted under the
92
+ * plugin base path, i.e. `GET /api/email/senders`. Runs in the OBO
93
+ * user scope so domain wildcards resolve against the caller's own
94
+ * local part.
95
+ */
96
+ override injectRoutes(router: IAppRouter): void {
97
+ router.get(SENDERS_ROUTE, (req, res, next) => {
98
+ this.userScopedSelf(req)
99
+ .listSenders()
100
+ .then((senders) => res.json(senders))
101
+ .catch(next);
102
+ });
103
+ }
104
+
105
+ override exports() {
106
+ return {
107
+ /**
108
+ * Send a message immediately from `from` through the shared
109
+ * transport, bypassing the approval flow. For agent-driven sends
110
+ * use {@link emailTool} instead.
111
+ */
112
+ sendEmail: (message: EmailMessage, from: string): Promise<EmailResult> =>
113
+ sendEmail(message, from),
114
+ /**
115
+ * Sender options for the current user (the `GET /senders` payload).
116
+ * AppKit wraps this with `asUser(req)` for OBO scoping.
117
+ */
118
+ listSenders: (): Promise<EmailSenders> => this.listSenders(),
119
+ };
120
+ }
121
+
122
+ /**
123
+ * Compute the `From` options offered to the current user: the concrete
124
+ * addresses the configured allow-list permits (domain wildcards
125
+ * expanded against the OBO user's local part), the default among them,
126
+ * and whether the list is an enforced restriction. See
127
+ * {@link listSenderOptions}.
128
+ */
129
+ private async listSenders(): Promise<EmailSenders> {
130
+ const { config } = getEmailRuntime();
131
+ const ctx = getExecutionContext();
132
+ const userEmail = "isUserContext" in ctx ? ctx.userEmail : undefined;
133
+ const senders = listSenderOptions(config, userEmail);
134
+ // Prefer the address a send would actually default to; fall back to
135
+ // the first offered option when that can't be resolved / permitted.
136
+ let defaultSender = senders[0];
137
+ try {
138
+ const resolved = resolveSenderAddress(config, userEmail).toLowerCase();
139
+ if (isSenderAllowed(resolved, config.allowedSenders)) defaultSender = resolved;
140
+ } catch {
141
+ // Keep the first offered option (or none) as the default.
142
+ }
143
+ return {
144
+ senders,
145
+ ...(defaultSender ? { defaultSender } : {}),
146
+ restricted: config.allowedSenders.length > 0,
147
+ };
148
+ }
149
+
150
+ /**
151
+ * Return `this.asUser(req)` when the request carries an OBO token,
152
+ * otherwise `this`. Avoids the noisy AppKit "asUser without token"
153
+ * warning on every request in local dev; behavior is unchanged in
154
+ * production where a missing token means a real OBO call.
155
+ */
156
+ private userScopedSelf(req: express.Request): this {
157
+ return req.header("x-forwarded-access-token") ? (this.asUser(req) as this) : this;
158
+ }
159
+ }
160
+
161
+ export const email = toPlugin(EmailPlugin);
package/src/sender.ts ADDED
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Sender-address policy: turn the on-behalf-of user's email into an
3
+ * outbound `From`, and (optionally) restrict which addresses may send.
4
+ *
5
+ * The default `From` re-homes the local part (everything before `@`) of
6
+ * the OBO email on the configured sending domain, so `alice@databricks.com`
7
+ * through a domain of `mail.example.com` goes out as
8
+ * `alice@mail.example.com`. An explicit `from` short-circuits that; the
9
+ * file/outbox fallback (no domain) keeps the user's address verbatim so
10
+ * test artifacts land under a recognizable folder.
11
+ *
12
+ * When an allow-list is configured (see {@link parseAllowedSenders}) the
13
+ * resolved `From` is constrained to it: a pattern is either an exact
14
+ * address (`user@domain.com`), a domain wildcard (`*@domain.com` or the
15
+ * bare `domain.com`, matching any local part on that domain), or `*`
16
+ * (any). {@link listSenderOptions} expands the allow-list into the
17
+ * concrete addresses a UI dropdown can offer for the current user.
18
+ */
19
+
20
+ import { net } from "@dbx-tools/shared-core";
21
+
22
+ import type { ResolvedEmailConfig } from "./config";
23
+
24
+ /**
25
+ * Re-home the OBO user's local part on `domain`. Throws when no usable
26
+ * local part is available (e.g. a service-context call with no user).
27
+ */
28
+ export function deriveSenderAddress(userEmail: string | undefined, domain: string): string {
29
+ const local = userEmail?.split("@")[0]?.trim();
30
+ if (!local) {
31
+ throw new Error(
32
+ "email: cannot derive sender address - no on-behalf-of user email is available; set `from` / EMAIL_FROM to send from a fixed address",
33
+ );
34
+ }
35
+ return `${local}@${domain}`;
36
+ }
37
+
38
+ /**
39
+ * Normalize a sender allow-list from config (a `string[]`) or an env var
40
+ * (a CSV / whitespace-separated string). Delegates to the shared
41
+ * {@link net.parseEmails} so allow-list patterns are read exactly
42
+ * like recipient lists elsewhere: entries are trimmed, lower-cased (so
43
+ * matching in {@link isSenderAllowed} is case-insensitive), and
44
+ * de-duplicated; empties are dropped. An empty result means "no
45
+ * restriction".
46
+ */
47
+ export function parseAllowedSenders(raw: string | string[] | undefined): string[] {
48
+ return net.parseEmails(raw, { lowercase: true });
49
+ }
50
+
51
+ /** The `@domain` suffix a wildcard / bare-domain pattern matches, else null. */
52
+ function patternDomainSuffix(pattern: string): string | null {
53
+ if (pattern.startsWith("*@")) return `@${pattern.slice(2)}`;
54
+ if (!pattern.includes("@")) return `@${pattern}`;
55
+ return null;
56
+ }
57
+
58
+ /** Whether `address` (already lower-cased) satisfies a single pattern. */
59
+ function matchesPattern(address: string, pattern: string): boolean {
60
+ if (pattern === "*") return true;
61
+ const suffix = patternDomainSuffix(pattern);
62
+ if (suffix) return address.length > suffix.length && address.endsWith(suffix);
63
+ return address === pattern;
64
+ }
65
+
66
+ /**
67
+ * Whether `from` is permitted by the allow-list. An empty (or absent)
68
+ * allow-list permits everything.
69
+ */
70
+ export function isSenderAllowed(from: string, patterns: string[]): boolean {
71
+ if (patterns.length === 0) return true;
72
+ const address = from.trim().toLowerCase();
73
+ return patterns.some((pattern) => matchesPattern(address, pattern));
74
+ }
75
+
76
+ /**
77
+ * Throw when `from` is not permitted by the allow-list. No-op when the
78
+ * allow-list is empty. The single enforcement point for the restriction
79
+ * (called from {@link sendEmail}), so every send path is covered whether
80
+ * the address was derived server-side or chosen in a UI.
81
+ */
82
+ export function assertSenderAllowed(from: string, patterns: string[]): void {
83
+ if (!isSenderAllowed(from, patterns)) {
84
+ throw new Error(
85
+ `email: sender "${from}" is not permitted by the configured allow-list (${patterns.join(", ")})`,
86
+ );
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Resolve the `From` address for a send from the resolved config and the
92
+ * current OBO user: explicit `from` wins, then `<local>@<domain>`, then
93
+ * (file/outbox mode only) the user's email verbatim. Throws when none of
94
+ * those yield an address.
95
+ */
96
+ export function resolveSenderAddress(
97
+ config: ResolvedEmailConfig,
98
+ userEmail: string | undefined,
99
+ ): string {
100
+ if (config.from) return config.from;
101
+ if (config.domain) return deriveSenderAddress(userEmail, config.domain);
102
+ const email = userEmail?.trim();
103
+ if (!email) {
104
+ throw new Error(
105
+ "email: no sender address available - set `from` / EMAIL_FROM, `domain` / EMAIL_DOMAIN, or run on behalf of a user",
106
+ );
107
+ }
108
+ return email;
109
+ }
110
+
111
+ /**
112
+ * Expand the resolved config's allow-list into the concrete `From`
113
+ * addresses offered to the current user - the data a UI sender dropdown
114
+ * renders. Exact-address patterns pass through; domain wildcards
115
+ * (`*@domain.com` / bare `domain.com`) are concretized as
116
+ * `<user-local>@<domain>` and dropped when no OBO user local part is
117
+ * available. When no allow-list is configured, the single default sender
118
+ * ({@link resolveSenderAddress}) is returned when it can be resolved,
119
+ * else an empty list. The default resolved sender, when permitted, is
120
+ * ordered first.
121
+ */
122
+ export function listSenderOptions(
123
+ config: ResolvedEmailConfig,
124
+ userEmail: string | undefined,
125
+ ): string[] {
126
+ const patterns = config.allowedSenders ?? [];
127
+ const local = userEmail?.split("@")[0]?.trim().toLowerCase();
128
+ const options: string[] = [];
129
+ const add = (address: string | undefined): void => {
130
+ if (address && !options.includes(address)) options.push(address);
131
+ };
132
+
133
+ // Surface the address a send would use by default first, when it can
134
+ // be resolved and the allow-list (if any) permits it.
135
+ try {
136
+ const fallback = resolveSenderAddress(config, userEmail);
137
+ if (isSenderAllowed(fallback, patterns)) add(fallback.toLowerCase());
138
+ } catch {
139
+ // No default resolvable (e.g. file mode with no user / domain / from).
140
+ }
141
+
142
+ for (const pattern of patterns) {
143
+ if (pattern === "*") continue; // "any" can't be enumerated as a choice
144
+ if (pattern.startsWith("*@") || !pattern.includes("@")) {
145
+ const domain = pattern.startsWith("*@") ? pattern.slice(2) : pattern;
146
+ if (local) add(`${local}@${domain}`);
147
+ } else {
148
+ add(pattern); // exact address
149
+ }
150
+ }
151
+ return options;
152
+ }
package/src/tool.ts ADDED
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The `send_email` Mastra tool: approval-gated so a model can draft a
3
+ * message freely but nothing leaves the building until a human clicks
4
+ * Approve in the chat UI. On approval the sender is resolved (explicit
5
+ * `from` config, else derived from the on-behalf-of user's email) and
6
+ * the message is dispatched through the shared SMTP transport.
7
+ *
8
+ * The sender derivation runs inside the AppKit user scope, so
9
+ * `getExecutionContext()` returns the OBO user whose local-part seeds
10
+ * the address (see {@link deriveSenderAddress}).
11
+ */
12
+
13
+ import { getExecutionContext } from "@databricks/appkit";
14
+ import { email, type EmailMessage } from "@dbx-tools/shared-email";
15
+ import { log, string } from "@dbx-tools/shared-core";
16
+ import { createTool } from "@mastra/core/tools";
17
+ import { resolveSenderAddress } from "./sender";
18
+ import { getEmailRuntime, sendEmail } from "./transport";
19
+
20
+ const logger = log.logger("email/tool/send-email");
21
+
22
+ /** Options accepted by {@link emailTool}. */
23
+ export interface EmailToolOptions {
24
+ /**
25
+ * Override the tool id. Defaults to `"send_email"`; the chat UI's
26
+ * approval gate keys off this id, so keep it unless you also teach
27
+ * the client about the new name.
28
+ */
29
+ id?: string;
30
+ }
31
+
32
+ /**
33
+ * Build the approval-gated `send_email` tool. Spread it into the agents
34
+ * that should be able to draft mail; it is intentionally not installed
35
+ * everywhere.
36
+ *
37
+ * @example
38
+ * ```ts
39
+ * import { emailTool } from "@dbx-tools/appkit-email";
40
+ * import { createAgent } from "@dbx-tools/appkit-mastra";
41
+ *
42
+ * const support = createAgent({
43
+ * instructions: "...",
44
+ * tools: () => ({ send_email: emailTool() }),
45
+ * });
46
+ * ```
47
+ */
48
+ export function emailTool(opts: EmailToolOptions = {}) {
49
+ return createTool({
50
+ id: opts.id ?? "send_email",
51
+ description: string.toDescription(`
52
+ Send an email on the user's behalf. Pass one or more recipient
53
+ addresses (with optional cc / bcc and file attachments), a subject,
54
+ and a body; the user is prompted to approve the send before it goes
55
+ out (this tool is approval-gated). Use it only when the user
56
+ explicitly asks to send / forward / share something via email -
57
+ never autonomously. Keep subjects short and bodies self-contained:
58
+ the recipient has none of the chat context. Write the body in
59
+ GitHub-Flavored Markdown - headings, lists, and real Markdown
60
+ tables - not ASCII art (no "=====" dividers or space/pipe-drawn
61
+ tables); it is rendered to HTML before sending.
62
+ `),
63
+ inputSchema: email.emailMessageSchema,
64
+ outputSchema: email.emailResultSchema,
65
+ requireApproval: true,
66
+ execute: async (input) => {
67
+ const message = input as EmailMessage;
68
+ const { config } = getEmailRuntime();
69
+ const ctx = getExecutionContext();
70
+ const userEmail = "isUserContext" in ctx ? ctx.userEmail : undefined;
71
+ const from = resolveSenderAddress(config, userEmail);
72
+ const result = await sendEmail(message, from);
73
+ logger.info("sent", {
74
+ to: result.recipient,
75
+ from: result.from,
76
+ ...(result.messageId ? { messageId: result.messageId } : {}),
77
+ });
78
+ return result;
79
+ },
80
+ });
81
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * The email runtime: a lazily-built, process-wide dispatcher plus its
3
+ * resolved config, and {@link sendEmail} which sends one
4
+ * {@link EmailMessage} through it. In SMTP mode the runtime holds a
5
+ * memoized nodemailer transport (shared by the plugin's setup and the
6
+ * agent tool, so they reuse one connection pool); in file/outbox mode it
7
+ * holds no transport and {@link sendEmail} writes HTML to disk instead.
8
+ * The first caller (normally the plugin at setup) primes it with the
9
+ * plugin's config; later callers reuse it.
10
+ */
11
+
12
+ import type { EmailAttachment, EmailMessage, EmailResult } from "@dbx-tools/shared-email";
13
+ import nodemailer, { type SendMailOptions, type Transporter } from "nodemailer";
14
+ import { resolveEmailConfig, type EmailPluginConfig, type ResolvedEmailConfig } from "./config";
15
+ import { renderEmailHtml } from "./email-html";
16
+ import { writeOutboxEmail } from "./outbox";
17
+ import { assertSenderAllowed } from "./sender";
18
+
19
+ /** The shared dispatcher and the config it was built from. */
20
+ export interface EmailRuntime {
21
+ /** Present only in SMTP mode. */
22
+ transporter?: Transporter;
23
+ config: ResolvedEmailConfig;
24
+ }
25
+
26
+ let runtime: EmailRuntime | undefined;
27
+
28
+ /**
29
+ * Return the shared runtime, building it on first use from the supplied
30
+ * config layered over environment defaults. Overrides are only read when
31
+ * the runtime is first created, so prime it from the plugin's config at
32
+ * setup; subsequent calls (e.g. the tool's `execute`) pass nothing and
33
+ * get the same instance.
34
+ */
35
+ export function getEmailRuntime(overrides?: EmailPluginConfig): EmailRuntime {
36
+ if (!runtime) {
37
+ const config = resolveEmailConfig(overrides);
38
+ runtime = {
39
+ config,
40
+ ...(config.mode === "smtp"
41
+ ? {
42
+ transporter: nodemailer.createTransport({
43
+ host: config.host,
44
+ port: config.port,
45
+ secure: config.secure,
46
+ auth: config.auth,
47
+ }),
48
+ }
49
+ : {}),
50
+ };
51
+ }
52
+ return runtime;
53
+ }
54
+
55
+ /** Drop the memoized runtime so the next {@link getEmailRuntime} rebuilds it. */
56
+ export function resetEmailRuntime(): void {
57
+ runtime?.transporter?.close();
58
+ runtime = undefined;
59
+ }
60
+
61
+ /** The comma-joined recipient string echoed back in {@link EmailResult}. */
62
+ function recipientEcho(to: string[]): string {
63
+ return to.join(", ");
64
+ }
65
+
66
+ /**
67
+ * Map the wire-format {@link EmailAttachment}s onto nodemailer's
68
+ * attachment shape, dropping unset optional keys so nodemailer applies
69
+ * its own defaults (utf-8 encoding, filename-inferred content type). The
70
+ * wire fields are a deliberate subset of nodemailer's, so this is a
71
+ * straight structural pass-through.
72
+ */
73
+ function toMailAttachments(
74
+ attachments: EmailAttachment[] | undefined,
75
+ ): SendMailOptions["attachments"] {
76
+ if (!attachments || attachments.length === 0) return undefined;
77
+ return attachments.map((att) => ({
78
+ filename: att.filename,
79
+ ...(att.content !== undefined ? { content: att.content } : {}),
80
+ ...(att.encoding !== undefined ? { encoding: att.encoding } : {}),
81
+ ...(att.path !== undefined ? { path: att.path } : {}),
82
+ ...(att.contentType !== undefined ? { contentType: att.contentType } : {}),
83
+ }));
84
+ }
85
+
86
+ /**
87
+ * Send (SMTP mode) or persist (file/outbox mode) one message from the
88
+ * resolved `from` address. `to` (and optional `cc` / `bcc`) each accept
89
+ * one or more addresses, and `attachments` are forwarded as files. The
90
+ * body is markdown: SMTP sends it as both a plain-text part (the raw
91
+ * source) and an HTML part (rendered), and the outbox embeds the
92
+ * rendered HTML in a document. In file mode the returned `messageId` is
93
+ * the path written. Throws when `to` carries no recipient, or when `from`
94
+ * is not permitted by the configured sender allow-list.
95
+ */
96
+ export async function sendEmail(message: EmailMessage, from: string): Promise<EmailResult> {
97
+ if (message.to.length === 0) {
98
+ throw new Error("email: `to` must include at least one recipient");
99
+ }
100
+ const { config, transporter } = getEmailRuntime();
101
+ assertSenderAllowed(from, config.allowedSenders);
102
+ const recipient = recipientEcho(message.to);
103
+
104
+ if (config.mode === "file") {
105
+ const path = await writeOutboxEmail(message, from, config.outDir);
106
+ return { sent: true, recipient, from, messageId: path };
107
+ }
108
+
109
+ if (!transporter) throw new Error("email: SMTP transport unavailable");
110
+ const attachments = toMailAttachments(message.attachments);
111
+ const info = await transporter.sendMail({
112
+ from,
113
+ to: message.to,
114
+ subject: message.subject,
115
+ text: message.body,
116
+ html: renderEmailHtml({ subject: message.subject, body: message.body }),
117
+ ...(message.cc && message.cc.length > 0 ? { cc: message.cc } : {}),
118
+ ...(message.bcc && message.bcc.length > 0 ? { bcc: message.bcc } : {}),
119
+ ...(attachments ? { attachments } : {}),
120
+ });
121
+ return {
122
+ sent: true,
123
+ recipient,
124
+ from,
125
+ ...(info.messageId ? { messageId: info.messageId } : {}),
126
+ };
127
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,41 @@
1
+ // ~~ Generated by projen. To modify, edit .projenrc.js and run "pnpm exec projen".
2
+ {
3
+ "compilerOptions": {
4
+ "rootDir": "src",
5
+ "outDir": "lib",
6
+ "alwaysStrict": true,
7
+ "declaration": true,
8
+ "esModuleInterop": true,
9
+ "experimentalDecorators": true,
10
+ "inlineSourceMap": true,
11
+ "inlineSources": true,
12
+ "lib": [
13
+ "ES2022"
14
+ ],
15
+ "module": "ESNext",
16
+ "noEmitOnError": false,
17
+ "noFallthroughCasesInSwitch": true,
18
+ "noImplicitAny": true,
19
+ "noImplicitReturns": true,
20
+ "noImplicitThis": true,
21
+ "noUnusedLocals": true,
22
+ "noUnusedParameters": true,
23
+ "resolveJsonModule": true,
24
+ "strict": true,
25
+ "strictNullChecks": true,
26
+ "strictPropertyInitialization": true,
27
+ "stripInternal": true,
28
+ "target": "ES2022",
29
+ "types": [
30
+ "node"
31
+ ],
32
+ "moduleResolution": "bundler",
33
+ "skipLibCheck": true
34
+ },
35
+ "include": [
36
+ "src/**/*.ts"
37
+ ],
38
+ "exclude": [
39
+ "node_modules"
40
+ ]
41
+ }