@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/src/defaults.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interceptor defaults and hard payload caps for the email plugin.
|
|
3
|
+
*
|
|
4
|
+
* The execution settings are what the runtime's executor hands
|
|
5
|
+
* `Plugin.execute()`, kept here rather than at the call sites so the
|
|
6
|
+
* caching / retry / timeout posture of every outbound operation is
|
|
7
|
+
* reviewable in one place. The caps bound the one unbounded input the
|
|
8
|
+
* plugin accepts: a model-drafted message with attachments.
|
|
9
|
+
*
|
|
10
|
+
* @module
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The `PluginExecuteConfig` slice this package sets. Mirrored structurally
|
|
15
|
+
* because AppKit's `PluginExecuteConfig` lives behind a subpath its `exports`
|
|
16
|
+
* map does not publish, so the nominal type cannot be imported. Written as a
|
|
17
|
+
* type alias rather than an interface so it stays assignable to the nominal
|
|
18
|
+
* type's index signature.
|
|
19
|
+
*/
|
|
20
|
+
export type EmailExecuteConfig = {
|
|
21
|
+
cache?: { enabled?: boolean; ttl?: number; cacheKey?: (string | number | object)[] };
|
|
22
|
+
retry?: { enabled?: boolean; attempts?: number; initialDelay?: number; maxDelay?: number };
|
|
23
|
+
timeout?: number;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The `PluginExecutionSettings` shape accepted by AppKit's `Plugin.execute()`.
|
|
28
|
+
* Mirrored structurally for the same reason as {@link EmailExecuteConfig}.
|
|
29
|
+
*/
|
|
30
|
+
export type EmailExecutionSettings = {
|
|
31
|
+
default: EmailExecuteConfig;
|
|
32
|
+
user?: EmailExecuteConfig;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** Ceiling on how long a single SMTP conversation may take. */
|
|
36
|
+
export const SEND_TIMEOUT_MS = 30_000;
|
|
37
|
+
|
|
38
|
+
/** Ceiling on the SMTP handshake performed at plugin setup. */
|
|
39
|
+
export const VERIFY_TIMEOUT_MS = 15_000;
|
|
40
|
+
|
|
41
|
+
/** Attempts allowed for the setup-time SMTP handshake, including the first. */
|
|
42
|
+
export const VERIFY_ATTEMPTS = 3;
|
|
43
|
+
|
|
44
|
+
/** Largest single attachment accepted, in decoded bytes (10 MiB). */
|
|
45
|
+
export const MAX_ATTACHMENT_BYTES = 10_485_760;
|
|
46
|
+
|
|
47
|
+
/** Largest combined attachment payload accepted, in decoded bytes (20 MiB). */
|
|
48
|
+
export const MAX_ATTACHMENTS_TOTAL_BYTES = 20_971_520;
|
|
49
|
+
|
|
50
|
+
/** Largest number of attachments accepted on one message. */
|
|
51
|
+
export const MAX_ATTACHMENT_COUNT = 20;
|
|
52
|
+
|
|
53
|
+
/** Largest markdown body accepted, in characters. */
|
|
54
|
+
export const MAX_BODY_CHARS = 200_000;
|
|
55
|
+
|
|
56
|
+
/** Execution settings for a send (SMTP dispatch or an outbox write). */
|
|
57
|
+
export const EMAIL_SEND_SETTINGS: EmailExecutionSettings = {
|
|
58
|
+
default: {
|
|
59
|
+
// Cache disabled: a send is a side effect, not a value. Replaying a
|
|
60
|
+
// cached result would report success for a message never handed to SMTP.
|
|
61
|
+
cache: { enabled: false },
|
|
62
|
+
// Retry disabled: SMTP delivery is not idempotent. A `sendMail` that
|
|
63
|
+
// times out may already have queued the message, so a second attempt
|
|
64
|
+
// risks delivering the mail twice.
|
|
65
|
+
retry: { enabled: false },
|
|
66
|
+
timeout: SEND_TIMEOUT_MS,
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** Execution settings for the setup-time SMTP connectivity check. */
|
|
71
|
+
export const EMAIL_VERIFY_SETTINGS: EmailExecutionSettings = {
|
|
72
|
+
default: {
|
|
73
|
+
// Cache disabled: connectivity is a point-in-time fact about the server,
|
|
74
|
+
// and this runs once per boot, so there is nothing to reuse.
|
|
75
|
+
cache: { enabled: false },
|
|
76
|
+
// Retry enabled: the handshake has no side effect, and a cold SMTP relay
|
|
77
|
+
// or a slow DNS answer at boot is exactly the transient failure that
|
|
78
|
+
// should not take the app down.
|
|
79
|
+
retry: { enabled: true, attempts: VERIFY_ATTEMPTS },
|
|
80
|
+
timeout: VERIFY_TIMEOUT_MS,
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** Execution settings for the sender-options lookup. */
|
|
85
|
+
export const EMAIL_SENDERS_SETTINGS: EmailExecutionSettings = {
|
|
86
|
+
default: {
|
|
87
|
+
// Cache disabled: the options are computed from already-resolved config
|
|
88
|
+
// and the caller's own address, so a cache would add a cross-identity
|
|
89
|
+
// leak risk for no measurable saving.
|
|
90
|
+
cache: { enabled: false },
|
|
91
|
+
// Retry disabled: the lookup performs no I/O, so a failure is
|
|
92
|
+
// deterministic and a second attempt would fail identically.
|
|
93
|
+
retry: { enabled: false },
|
|
94
|
+
},
|
|
95
|
+
};
|
package/src/email-html.ts
CHANGED
|
@@ -25,6 +25,12 @@ import juice from "juice";
|
|
|
25
25
|
import type { EmailBrand } from "./brand";
|
|
26
26
|
import { markdownToHtml } from "./markdown";
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Rendered height of the header logo. Fixed rather than intrinsic because
|
|
30
|
+
* mail clients ignore CSS sizing on an image without an `height` attribute.
|
|
31
|
+
*/
|
|
32
|
+
const LOGO_HEIGHT_PX = 28;
|
|
33
|
+
|
|
28
34
|
/** Neutral fallback styling when no brand is supplied. */
|
|
29
35
|
const DEFAULT_BRAND: Required<Pick<EmailBrand, "accent" | "onAccent" | "fontFamily">> = {
|
|
30
36
|
accent: "#0b6bcb",
|
|
@@ -32,7 +38,7 @@ const DEFAULT_BRAND: Required<Pick<EmailBrand, "accent" | "onAccent" | "fontFami
|
|
|
32
38
|
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif",
|
|
33
39
|
};
|
|
34
40
|
|
|
35
|
-
/** Escape HTML-significant characters (re-exported from `@dbx-tools/shared`). */
|
|
41
|
+
/** Escape HTML-significant characters (re-exported from `@dbx-tools/shared-core`). */
|
|
36
42
|
export const escapeHtml = string.escapeHtml;
|
|
37
43
|
|
|
38
44
|
/**
|
|
@@ -110,12 +116,12 @@ function footerRow(footer: string | undefined): string {
|
|
|
110
116
|
/**
|
|
111
117
|
* Render the header-band content: the brand logo (when the brand supplies a
|
|
112
118
|
* renderable image) above the title, or just the title. The logo is capped
|
|
113
|
-
* at
|
|
114
|
-
* shows so the band is never empty.
|
|
119
|
+
* at {@link LOGO_HEIGHT_PX} and tinted implicitly by its own artwork; the
|
|
120
|
+
* title always shows so the band is never empty.
|
|
115
121
|
*/
|
|
116
122
|
function headerBand(title: string, brand: EmailBrand, onAccent: string): string {
|
|
117
123
|
const logo = brand.logoUrl
|
|
118
|
-
? `<img src="${escapeHtml(brand.logoUrl)}" alt="${escapeHtml(brand.name ?? title)}" height="
|
|
124
|
+
? `<img src="${escapeHtml(brand.logoUrl)}" alt="${escapeHtml(brand.name ?? title)}" height="${LOGO_HEIGHT_PX}" style="height: ${LOGO_HEIGHT_PX}px; width: auto; display: block; margin-bottom: 8px;" />`
|
|
119
125
|
: "";
|
|
120
126
|
return `${logo}<span style="color: ${onAccent}; font-size: 18px; font-weight: 700; line-height: 1.3;">${escapeHtml(title)}</span>`;
|
|
121
127
|
}
|
package/src/outbox.ts
CHANGED
|
@@ -20,9 +20,15 @@ import type { EmailMessage } from "@dbx-tools/shared-email";
|
|
|
20
20
|
import type { EmailBrand } from "./brand";
|
|
21
21
|
import { renderEmailHtml } from "./email-html";
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Longest subject slug kept in an outbox file name, leaving room for the
|
|
25
|
+
* timestamp prefix and extension inside common filesystem name limits.
|
|
26
|
+
*/
|
|
27
|
+
const SUBJECT_SLUG_MAX_LENGTH = 48;
|
|
28
|
+
|
|
23
29
|
/** Filesystem-safe slug of the subject for the file name. */
|
|
24
30
|
function subjectSlug(subject: string): string {
|
|
25
|
-
return string.toSlugWithOptions({ maxLength:
|
|
31
|
+
return string.toSlugWithOptions({ maxLength: SUBJECT_SLUG_MAX_LENGTH }, subject) || "email";
|
|
26
32
|
}
|
|
27
33
|
|
|
28
34
|
/** The envelope rows shown above the body in the preview file. */
|
package/src/plugin.ts
CHANGED
|
@@ -2,16 +2,19 @@
|
|
|
2
2
|
* AppKit plugin (registered name: `email`) that owns the SMTP runtime
|
|
3
3
|
* for outbound mail. Registering it validates the SMTP configuration
|
|
4
4
|
* and verifies connectivity at startup, so a bad host / credential
|
|
5
|
-
* surfaces in the boot logs instead of on the first approved send
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* surfaces in the boot logs instead of on the first approved send, and
|
|
6
|
+
* installs this plugin's `execute()` as the runtime's executor so every
|
|
7
|
+
* send picks up AppKit's retry / timeout / telemetry chain.
|
|
8
|
+
*
|
|
9
|
+
* The plugin is also a `ToolProvider`, so an AppKit agent can reach an
|
|
10
|
+
* `email.send` tool directly; the {@link emailTool} export is the same
|
|
11
|
+
* capability for a Mastra agent. Both share the transport primed here,
|
|
12
|
+
* and {@link sendEmail} is available to non-agent callers.
|
|
10
13
|
*
|
|
11
14
|
* Configuration is the manifest-published {@link EmailPluginConfig}
|
|
12
|
-
* (SMTP host/port/credentials, sender domain or explicit `from`,
|
|
13
|
-
* optional `allowedSenders` restriction), with
|
|
14
|
-
* `EMAIL_*` environment fallbacks.
|
|
15
|
+
* (SMTP host/port/credentials, sender domain or explicit `from`, the
|
|
16
|
+
* sender policy, and an optional `allowedSenders` restriction), with
|
|
17
|
+
* unprefixed `SMTP_*` / `EMAIL_*` environment fallbacks.
|
|
15
18
|
*
|
|
16
19
|
* The plugin mounts one route under its base path (`/api/email`):
|
|
17
20
|
* `GET /senders` returns the permitted `From` options for the calling
|
|
@@ -21,27 +24,75 @@
|
|
|
21
24
|
*/
|
|
22
25
|
|
|
23
26
|
import {
|
|
27
|
+
AuthenticationError,
|
|
28
|
+
ConfigurationError,
|
|
29
|
+
ConnectionError,
|
|
30
|
+
ExecutionError,
|
|
24
31
|
getExecutionContext,
|
|
25
32
|
Plugin,
|
|
26
33
|
toPlugin,
|
|
34
|
+
ValidationError,
|
|
35
|
+
type AppKitError,
|
|
36
|
+
type ExecutionResult,
|
|
27
37
|
type IAppRouter,
|
|
28
38
|
type PluginManifest,
|
|
29
39
|
} from "@databricks/appkit";
|
|
30
|
-
import {
|
|
31
|
-
|
|
32
|
-
|
|
40
|
+
import {
|
|
41
|
+
defineTool,
|
|
42
|
+
executeFromRegistry,
|
|
43
|
+
toolsFromRegistry,
|
|
44
|
+
type AgentToolDefinition,
|
|
45
|
+
type ToolProvider,
|
|
46
|
+
type ToolRegistry,
|
|
47
|
+
} from "@databricks/appkit/beta";
|
|
48
|
+
import { log } from "@dbx-tools/shared-core";
|
|
49
|
+
import {
|
|
50
|
+
email as emailWire,
|
|
51
|
+
type EmailMessage,
|
|
52
|
+
type EmailResult,
|
|
53
|
+
type EmailSenders,
|
|
54
|
+
} from "@dbx-tools/shared-email";
|
|
33
55
|
import { EMAIL_CONFIG_SCHEMA, type EmailPluginConfig } from "./config";
|
|
56
|
+
import { EMAIL_SENDERS_SETTINGS, EMAIL_VERIFY_SETTINGS } from "./defaults";
|
|
34
57
|
import { isSenderAllowed, listSenderOptions, resolveSenderAddress } from "./sender";
|
|
35
|
-
import {
|
|
58
|
+
import { SEND_EMAIL_DESCRIPTION } from "./tool";
|
|
59
|
+
import {
|
|
60
|
+
getEmailRuntime,
|
|
61
|
+
resetEmailRuntime,
|
|
62
|
+
sendEmail,
|
|
63
|
+
setEmailExecutor,
|
|
64
|
+
verifyEmailTransport,
|
|
65
|
+
} from "./transport";
|
|
36
66
|
|
|
37
67
|
/** Mount-relative route (under `/api/email`) for the sender-options lookup. */
|
|
38
68
|
const SENDERS_ROUTE = "/senders";
|
|
39
69
|
|
|
70
|
+
/** Registry key of the agent tool, which agents address as `email.send`. */
|
|
71
|
+
const SEND_TOOL = "send";
|
|
72
|
+
|
|
73
|
+
const logger = log.logger("email");
|
|
74
|
+
|
|
40
75
|
/**
|
|
41
76
|
* AppKit plugin that configures and verifies the SMTP transport used by
|
|
42
|
-
* the `send_email` tool.
|
|
77
|
+
* the `send_email` tool, and exposes sending as an AppKit agent tool.
|
|
78
|
+
*
|
|
79
|
+
* @example
|
|
80
|
+
* ```ts
|
|
81
|
+
* import { createApp, server } from "@databricks/appkit";
|
|
82
|
+
* import { plugin as emailPlugin } from "@dbx-tools/email";
|
|
83
|
+
*
|
|
84
|
+
* await createApp({
|
|
85
|
+
* plugins: [
|
|
86
|
+
* server(),
|
|
87
|
+
* emailPlugin.email({
|
|
88
|
+
* smtp: { host: "smtp.example.com", user: "apikey", password: process.env.SMTP_KEY },
|
|
89
|
+
* domain: "mail.example.com",
|
|
90
|
+
* }),
|
|
91
|
+
* ],
|
|
92
|
+
* });
|
|
93
|
+
* ```
|
|
43
94
|
*/
|
|
44
|
-
export class EmailPlugin extends Plugin<EmailPluginConfig> {
|
|
95
|
+
export class EmailPlugin extends Plugin<EmailPluginConfig> implements ToolProvider {
|
|
45
96
|
static manifest = {
|
|
46
97
|
name: "email",
|
|
47
98
|
displayName: "Email",
|
|
@@ -56,36 +107,94 @@ export class EmailPlugin extends Plugin<EmailPluginConfig> {
|
|
|
56
107
|
config: { schema: EMAIL_CONFIG_SCHEMA },
|
|
57
108
|
} satisfies PluginManifest<"email">;
|
|
58
109
|
|
|
59
|
-
|
|
110
|
+
/**
|
|
111
|
+
* The tool this plugin offers to an AppKit agent.
|
|
112
|
+
*
|
|
113
|
+
* Not `autoInheritable`: a send is irreversible and leaves the workspace,
|
|
114
|
+
* so it must only appear in an agent that asked for it and accepted the
|
|
115
|
+
* sender policy that comes with it.
|
|
116
|
+
*
|
|
117
|
+
* `execute` re-parses its arguments with the local schema: AppKit validates
|
|
118
|
+
* against the same schema first, but re-parsing is what gives the body typed
|
|
119
|
+
* arguments instead of `unknown`.
|
|
120
|
+
*/
|
|
121
|
+
private readonly tools: ToolRegistry = {
|
|
122
|
+
[SEND_TOOL]: defineTool({
|
|
123
|
+
description: SEND_EMAIL_DESCRIPTION,
|
|
124
|
+
schema: emailWire.emailMessageSchema,
|
|
125
|
+
annotations: { effect: "write", requiresUserContext: true },
|
|
126
|
+
autoInheritable: false,
|
|
127
|
+
execute: async (args, signal) =>
|
|
128
|
+
this.send(emailWire.emailMessageSchema.parse(args), undefined, signal),
|
|
129
|
+
}),
|
|
130
|
+
};
|
|
60
131
|
|
|
61
132
|
/**
|
|
62
|
-
* Prime the shared runtime from this plugin's config (over env)
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
133
|
+
* Prime the shared runtime from this plugin's config (over env), route the
|
|
134
|
+
* tools' sends through this plugin's interceptor chain, and log the
|
|
135
|
+
* effective sender policy so an active restriction is obvious at boot. In
|
|
136
|
+
* SMTP mode, fail setup when the transport cannot be verified: a bad host
|
|
137
|
+
* or credential is a deploy-time mistake and should stop the app rather
|
|
138
|
+
* than wait for a user to approve a send that cannot work. With no SMTP
|
|
66
139
|
* credentials the runtime is in file/outbox mode (only when
|
|
67
|
-
* `EMAIL_OUTBOX_MODE` is set), logged here so it
|
|
68
|
-
* being written to disk rather than sent.
|
|
140
|
+
* `EMAIL_OUTBOX_MODE` is set), logged loudly here so it is obvious mail
|
|
141
|
+
* is being written to disk rather than sent.
|
|
69
142
|
*/
|
|
70
143
|
override async setup(): Promise<void> {
|
|
71
144
|
const { transporter, config } = getEmailRuntime(this.config);
|
|
145
|
+
setEmailExecutor((fn, settings) => this.execute(fn, settings));
|
|
146
|
+
const policy = {
|
|
147
|
+
mode: config.mode,
|
|
148
|
+
senderPolicy: config.senderPolicy,
|
|
149
|
+
restricted: config.allowedSenders.length > 0,
|
|
150
|
+
...(config.allowedSenders.length > 0 ? { allowedSenders: config.allowedSenders } : {}),
|
|
151
|
+
};
|
|
72
152
|
if (config.mode === "file") {
|
|
73
|
-
|
|
153
|
+
logger.warn("outbox:enabled", {
|
|
74
154
|
dir: config.outDir,
|
|
75
155
|
reason: "no SMTP credentials configured; emails are written to disk instead of sent",
|
|
76
156
|
});
|
|
157
|
+
logger.info("ready", policy);
|
|
77
158
|
return;
|
|
78
159
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
160
|
+
const verified = await this.execute(
|
|
161
|
+
async (signal) => verifyEmailTransport(transporter, signal),
|
|
162
|
+
EMAIL_VERIFY_SETTINGS,
|
|
163
|
+
);
|
|
164
|
+
if (!verified.ok) {
|
|
165
|
+
logger.error("smtp:unverified", {
|
|
82
166
|
host: config.host,
|
|
83
167
|
port: config.port,
|
|
84
|
-
|
|
168
|
+
status: verified.status,
|
|
169
|
+
error: verified.message,
|
|
85
170
|
});
|
|
86
|
-
|
|
87
|
-
|
|
171
|
+
throw ConfigurationError.invalidConnection(
|
|
172
|
+
"SMTP",
|
|
173
|
+
`Could not verify ${config.host}:${config.port}. Check SMTP_HOST, SMTP_PORT, SMTP_SECURE, and the credentials, or set EMAIL_OUTBOX_MODE=1 for local outbox testing.`,
|
|
174
|
+
);
|
|
88
175
|
}
|
|
176
|
+
logger.info("ready", {
|
|
177
|
+
...policy,
|
|
178
|
+
host: config.host,
|
|
179
|
+
port: config.port,
|
|
180
|
+
secure: config.secure,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Close the SMTP connection pool. Idempotent. */
|
|
185
|
+
async shutdown(): Promise<void> {
|
|
186
|
+
resetEmailRuntime();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Abort in-flight work. AppKit's graceful shutdown only invokes this hook -
|
|
191
|
+
* it never calls {@link shutdown} - so the SMTP pool is closed from here or
|
|
192
|
+
* it leaks at SIGTERM. The teardown is synchronous and idempotent, so the
|
|
193
|
+
* un-awaited call costs nothing.
|
|
194
|
+
*/
|
|
195
|
+
override abortActiveOperations(): void {
|
|
196
|
+
super.abortActiveOperations();
|
|
197
|
+
void this.shutdown();
|
|
89
198
|
}
|
|
90
199
|
|
|
91
200
|
/**
|
|
@@ -96,11 +205,18 @@ export class EmailPlugin extends Plugin<EmailPluginConfig> {
|
|
|
96
205
|
* local part.
|
|
97
206
|
*/
|
|
98
207
|
override injectRoutes(router: IAppRouter): void {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
208
|
+
this.route(router, {
|
|
209
|
+
name: "listSenders",
|
|
210
|
+
method: "get",
|
|
211
|
+
path: SENDERS_ROUTE,
|
|
212
|
+
handler: async (req, res) => {
|
|
213
|
+
const result = await this.asUser(req).executeListSenders();
|
|
214
|
+
if (!result.ok) {
|
|
215
|
+
res.status(result.status).json({ error: result.message });
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
res.json(result.data);
|
|
219
|
+
},
|
|
104
220
|
});
|
|
105
221
|
}
|
|
106
222
|
|
|
@@ -111,33 +227,67 @@ export class EmailPlugin extends Plugin<EmailPluginConfig> {
|
|
|
111
227
|
* transport, bypassing the approval flow. For agent-driven sends
|
|
112
228
|
* use {@link emailTool} instead.
|
|
113
229
|
*/
|
|
114
|
-
sendEmail: (
|
|
115
|
-
|
|
230
|
+
sendEmail: (
|
|
231
|
+
message: EmailMessage,
|
|
232
|
+
from: string,
|
|
233
|
+
signal?: AbortSignal,
|
|
234
|
+
): Promise<EmailResult> => this.send(message, from, signal),
|
|
116
235
|
/**
|
|
117
236
|
* Sender options for the current user (the `GET /senders` payload).
|
|
118
237
|
* AppKit wraps this with `asUser(req)` for OBO scoping.
|
|
119
238
|
*/
|
|
120
|
-
listSenders: (): Promise<EmailSenders> => this.
|
|
239
|
+
listSenders: async (): Promise<EmailSenders> => unwrap(await this.executeListSenders()),
|
|
121
240
|
};
|
|
122
241
|
}
|
|
123
242
|
|
|
243
|
+
/** AppKit `ToolProvider`: the tool definitions offered to an agent. */
|
|
244
|
+
getAgentTools(): AgentToolDefinition[] {
|
|
245
|
+
return toolsFromRegistry(this.tools);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* AppKit `ToolProvider`: run one tool call. Arguments are validated against
|
|
250
|
+
* the tool's schema first, and a validation failure comes back as an
|
|
251
|
+
* LLM-friendly string so the model can correct itself on the next turn.
|
|
252
|
+
*/
|
|
253
|
+
async executeAgentTool(name: string, args: unknown, signal?: AbortSignal): Promise<unknown> {
|
|
254
|
+
return executeFromRegistry(this.tools, name, args, signal);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Send one message, resolving the sender for the caller in scope when
|
|
259
|
+
* `from` is not pinned. The interceptor chain is applied inside
|
|
260
|
+
* {@link sendEmail} through the executor installed at setup, so this must
|
|
261
|
+
* not wrap it again.
|
|
262
|
+
*/
|
|
263
|
+
private async send(
|
|
264
|
+
message: EmailMessage,
|
|
265
|
+
from: string | undefined,
|
|
266
|
+
signal?: AbortSignal,
|
|
267
|
+
): Promise<EmailResult> {
|
|
268
|
+
return sendEmail(message, from ?? this.resolveSender(), signal);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Run the sender-options lookup through the plugin's interceptor chain. */
|
|
272
|
+
private async executeListSenders(): Promise<ExecutionResult<EmailSenders>> {
|
|
273
|
+
return this.execute(async () => this.listSenders(), EMAIL_SENDERS_SETTINGS);
|
|
274
|
+
}
|
|
275
|
+
|
|
124
276
|
/**
|
|
125
277
|
* Compute the `From` options offered to the current user: the concrete
|
|
126
|
-
* addresses the
|
|
127
|
-
*
|
|
128
|
-
*
|
|
278
|
+
* addresses the effective allow-list permits (domain wildcards expanded
|
|
279
|
+
* against the OBO user's local part), the default among them, and
|
|
280
|
+
* whether the list is an enforced restriction. See
|
|
129
281
|
* {@link listSenderOptions}.
|
|
130
282
|
*/
|
|
131
283
|
private async listSenders(): Promise<EmailSenders> {
|
|
132
284
|
const { config } = getEmailRuntime();
|
|
133
|
-
const
|
|
134
|
-
const userEmail = "isUserContext" in ctx ? ctx.userEmail : undefined;
|
|
135
|
-
const senders = listSenderOptions(config, userEmail);
|
|
285
|
+
const senders = listSenderOptions(config, currentUserEmail());
|
|
136
286
|
// Prefer the address a send would actually default to; fall back to
|
|
137
287
|
// the first offered option when that can't be resolved / permitted.
|
|
138
288
|
let defaultSender = senders[0];
|
|
139
289
|
try {
|
|
140
|
-
const resolved =
|
|
290
|
+
const resolved = this.resolveSender().toLowerCase();
|
|
141
291
|
if (isSenderAllowed(resolved, config.allowedSenders)) defaultSender = resolved;
|
|
142
292
|
} catch {
|
|
143
293
|
// Keep the first offered option (or none) as the default.
|
|
@@ -149,15 +299,57 @@ export class EmailPlugin extends Plugin<EmailPluginConfig> {
|
|
|
149
299
|
};
|
|
150
300
|
}
|
|
151
301
|
|
|
152
|
-
/**
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
* warning on every request in local dev; behavior is unchanged in
|
|
156
|
-
* production where a missing token means a real OBO call.
|
|
157
|
-
*/
|
|
158
|
-
private userScopedSelf(req: express.Request): this {
|
|
159
|
-
return req.header("x-forwarded-access-token") ? (this.asUser(req) as this) : this;
|
|
302
|
+
/** The `From` a send defaults to for the caller in scope. */
|
|
303
|
+
private resolveSender(): string {
|
|
304
|
+
return resolveSenderAddress(getEmailRuntime().config, currentUserEmail());
|
|
160
305
|
}
|
|
161
306
|
}
|
|
162
307
|
|
|
308
|
+
/** The OBO user's address, or undefined outside a user context. */
|
|
309
|
+
function currentUserEmail(): string | undefined {
|
|
310
|
+
const ctx = getExecutionContext();
|
|
311
|
+
return "isUserContext" in ctx ? ctx.userEmail : undefined;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Re-raise a failed execution as the AppKit error class that already carries
|
|
316
|
+
* the status AppKit resolved, so a programmatic caller sees the same 400 /
|
|
317
|
+
* 401 / 503 an HTTP caller would.
|
|
318
|
+
*/
|
|
319
|
+
function toAppKitError(status: number, message: string): AppKitError {
|
|
320
|
+
if (status === 400) return new ValidationError(message);
|
|
321
|
+
if (status === 401) return new AuthenticationError(message);
|
|
322
|
+
if (status === 503) return new ConnectionError(message);
|
|
323
|
+
return new ExecutionError(message);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Surface a failed {@link ExecutionResult} to a programmatic caller as a
|
|
328
|
+
* throw. HTTP handlers map `status` onto the response instead.
|
|
329
|
+
*/
|
|
330
|
+
function unwrap<T>(result: ExecutionResult<T>): T {
|
|
331
|
+
if (result.ok) return result.data;
|
|
332
|
+
throw toAppKitError(result.status, result.message);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Register the email plugin.
|
|
337
|
+
*
|
|
338
|
+
* @example
|
|
339
|
+
* ```ts
|
|
340
|
+
* import { createApp, server } from "@databricks/appkit";
|
|
341
|
+
* import { brand, plugin as emailPlugin } from "@dbx-tools/email";
|
|
342
|
+
*
|
|
343
|
+
* await createApp({
|
|
344
|
+
* plugins: [
|
|
345
|
+
* server(),
|
|
346
|
+
* emailPlugin.email({
|
|
347
|
+
* domain: "mail.example.com",
|
|
348
|
+
* allowedSenders: ["*@mail.example.com"],
|
|
349
|
+
* brand: brand.defaultEmailBrand,
|
|
350
|
+
* }),
|
|
351
|
+
* ],
|
|
352
|
+
* });
|
|
353
|
+
* ```
|
|
354
|
+
*/
|
|
163
355
|
export const email = toPlugin(EmailPlugin);
|
package/src/sender.ts
CHANGED
|
@@ -9,20 +9,25 @@
|
|
|
9
9
|
* file/outbox fallback (no domain) keeps the user's address verbatim so
|
|
10
10
|
* test artifacts land under a recognizable folder.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
12
|
+
* The resolved `From` is then constrained to the effective allow-list: a
|
|
13
|
+
* pattern is either an exact address (`user@domain.com`), a domain wildcard
|
|
14
|
+
* (`*@domain.com` or the bare `domain.com`, matching any local part on that
|
|
15
|
+
* domain), or `*` (any). This module only matches patterns; which patterns
|
|
16
|
+
* apply is decided by the configured sender policy in `./config`, which
|
|
17
|
+
* under the default `"allowlist"` mode fills an empty list in from the
|
|
18
|
+
* sender source. {@link listSenderOptions} expands the effective list into
|
|
19
|
+
* the concrete addresses a UI dropdown can offer for the current user.
|
|
18
20
|
*
|
|
19
21
|
* @module
|
|
20
22
|
*/
|
|
21
23
|
|
|
22
|
-
import {
|
|
24
|
+
import { ConfigurationError, ValidationError } from "@databricks/appkit";
|
|
25
|
+
import { log, net } from "@dbx-tools/shared-core";
|
|
23
26
|
|
|
24
27
|
import type { ResolvedEmailConfig } from "./config";
|
|
25
28
|
|
|
29
|
+
const logger = log.logger("email/sender");
|
|
30
|
+
|
|
26
31
|
/**
|
|
27
32
|
* Re-home the OBO user's local part on `domain`. Throws when no usable
|
|
28
33
|
* local part is available (e.g. a service-context call with no user).
|
|
@@ -30,8 +35,9 @@ import type { ResolvedEmailConfig } from "./config";
|
|
|
30
35
|
export function deriveSenderAddress(userEmail: string | undefined, domain: string): string {
|
|
31
36
|
const local = userEmail?.split("@")[0]?.trim();
|
|
32
37
|
if (!local) {
|
|
33
|
-
throw
|
|
34
|
-
"
|
|
38
|
+
throw ConfigurationError.resourceNotFound(
|
|
39
|
+
"On-behalf-of user email",
|
|
40
|
+
"Set `from` / EMAIL_FROM to send from a fixed address instead of deriving one.",
|
|
35
41
|
);
|
|
36
42
|
}
|
|
37
43
|
return `${local}@${domain}`;
|
|
@@ -67,7 +73,9 @@ function matchesPattern(address: string, pattern: string): boolean {
|
|
|
67
73
|
|
|
68
74
|
/**
|
|
69
75
|
* Whether `from` is permitted by the allow-list. An empty (or absent)
|
|
70
|
-
* allow-list permits everything
|
|
76
|
+
* allow-list permits everything: {@link resolveEmailConfig} is what turns
|
|
77
|
+
* the configured {@link SenderPolicy} into concrete patterns, so an empty
|
|
78
|
+
* list here means the policy had nothing to narrow to.
|
|
71
79
|
*/
|
|
72
80
|
export function isSenderAllowed(from: string, patterns: string[]): boolean {
|
|
73
81
|
if (patterns.length === 0) return true;
|
|
@@ -82,11 +90,15 @@ export function isSenderAllowed(from: string, patterns: string[]): boolean {
|
|
|
82
90
|
* the address was derived server-side or chosen in a UI.
|
|
83
91
|
*/
|
|
84
92
|
export function assertSenderAllowed(from: string, patterns: string[]): void {
|
|
85
|
-
if (
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
93
|
+
if (isSenderAllowed(from, patterns)) return;
|
|
94
|
+
// The thrown message names the field only; the patterns are policy detail
|
|
95
|
+
// that belongs in the operator's logs, not in a client or model response.
|
|
96
|
+
logger.warn("sender:denied", { from, allowedSenders: patterns });
|
|
97
|
+
throw ValidationError.invalidValue(
|
|
98
|
+
"from",
|
|
99
|
+
from,
|
|
100
|
+
"an address permitted by the configured sender allow-list",
|
|
101
|
+
);
|
|
90
102
|
}
|
|
91
103
|
|
|
92
104
|
/**
|
|
@@ -103,8 +115,9 @@ export function resolveSenderAddress(
|
|
|
103
115
|
if (config.domain) return deriveSenderAddress(userEmail, config.domain);
|
|
104
116
|
const email = userEmail?.trim();
|
|
105
117
|
if (!email) {
|
|
106
|
-
throw
|
|
107
|
-
"
|
|
118
|
+
throw ConfigurationError.resourceNotFound(
|
|
119
|
+
"Email sender address",
|
|
120
|
+
"Set `from` / EMAIL_FROM, set `domain` / EMAIL_DOMAIN, or run on behalf of a user.",
|
|
108
121
|
);
|
|
109
122
|
}
|
|
110
123
|
return email;
|