@dbx-tools/email 0.3.28 → 0.3.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 28px tall and tinted implicitly by its own artwork; the title always
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="28" style="height: 28px; width: auto; display: block; margin-bottom: 8px;" />`
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: 48 }, subject) || "email";
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. 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.
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`, and an
13
- * optional `allowedSenders` restriction), with unprefixed `SMTP_*` /
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 { error, log } from "@dbx-tools/shared-core";
31
- import type { EmailMessage, EmailResult, EmailSenders } from "@dbx-tools/shared-email";
32
- import type express from "express";
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 { getEmailRuntime, sendEmail } from "./transport";
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
- private logger = log.logger(this);
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). In
63
- * SMTP mode, verify connectivity - a failed verify is logged as a
64
- * warning rather than thrown, so the app still boots and the first
65
- * send surfaces the real error via the approval flow. With no SMTP
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's obvious mail is
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
- this.logger.warn("outbox:enabled", {
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
- try {
80
- await transporter?.verify();
81
- this.logger.info("smtp:ready", {
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
- secure: config.secure,
168
+ status: verified.status,
169
+ error: verified.message,
85
170
  });
86
- } catch (err) {
87
- this.logger.warn("smtp:unverified", { error: error.errorMessage(err) });
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
- router.get(SENDERS_ROUTE, (req, res, next) => {
100
- this.userScopedSelf(req)
101
- .listSenders()
102
- .then((senders) => res.json(senders))
103
- .catch(next);
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: (message: EmailMessage, from: string): Promise<EmailResult> =>
115
- sendEmail(message, from),
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.listSenders(),
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 configured allow-list permits (domain wildcards
127
- * expanded against the OBO user's local part), the default among them,
128
- * and whether the list is an enforced restriction. See
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 ctx = getExecutionContext();
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 = resolveSenderAddress(config, userEmail).toLowerCase();
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
- * Return `this.asUser(req)` when the request carries an OBO token,
154
- * otherwise `this`. Avoids the noisy AppKit "asUser without token"
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
- * 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.
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 { net } from "@dbx-tools/shared-core";
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 new Error(
34
- "email: cannot derive sender address - no on-behalf-of user email is available; set `from` / EMAIL_FROM to send from a fixed address",
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 (!isSenderAllowed(from, patterns)) {
86
- throw new Error(
87
- `email: sender "${from}" is not permitted by the configured allow-list (${patterns.join(", ")})`,
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 new Error(
107
- "email: no sender address available - set `from` / EMAIL_FROM, `domain` / EMAIL_DOMAIN, or run on behalf of a user",
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;