@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.
- 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/tool.ts
CHANGED
|
@@ -9,18 +9,41 @@
|
|
|
9
9
|
* `getExecutionContext()` returns the OBO user whose local-part seeds
|
|
10
10
|
* the address (see {@link deriveSenderAddress}).
|
|
11
11
|
*
|
|
12
|
+
* The dispatch itself goes through the executor the plugin installs on the
|
|
13
|
+
* shared runtime, so a send from this tool picks up the same retry / timeout /
|
|
14
|
+
* telemetry chain as one from the AppKit tool. In a Mastra app with no AppKit
|
|
15
|
+
* plugin registered the send still runs, just without interceptors.
|
|
16
|
+
*
|
|
12
17
|
* @module
|
|
13
18
|
*/
|
|
14
19
|
|
|
15
20
|
import { getExecutionContext } from "@databricks/appkit";
|
|
16
21
|
import { log, string } from "@dbx-tools/shared-core";
|
|
17
|
-
import { email
|
|
22
|
+
import { email } from "@dbx-tools/shared-email";
|
|
18
23
|
import { createTool } from "@mastra/core/tools";
|
|
19
24
|
import { resolveSenderAddress } from "./sender";
|
|
20
25
|
import { getEmailRuntime, sendEmail } from "./transport";
|
|
21
26
|
|
|
22
27
|
const logger = log.logger("email/tool/send-email");
|
|
23
28
|
|
|
29
|
+
/**
|
|
30
|
+
* The model-facing description of the send capability, shared by the Mastra
|
|
31
|
+
* {@link emailTool} and the AppKit `email.send` tool so both agents get the
|
|
32
|
+
* same guidance about approval, scope, and body formatting.
|
|
33
|
+
*/
|
|
34
|
+
export const SEND_EMAIL_DESCRIPTION = string.toDescription(`
|
|
35
|
+
Send an email on the user's behalf. Pass one or more recipient
|
|
36
|
+
addresses (with optional cc / bcc and file attachments), a subject,
|
|
37
|
+
and a body; the user is prompted to approve the send before it goes
|
|
38
|
+
out (this tool is approval-gated). Use it only when the user
|
|
39
|
+
explicitly asks to send / forward / share something via email -
|
|
40
|
+
never autonomously. Keep subjects short and bodies self-contained:
|
|
41
|
+
the recipient has none of the chat context. Write the body in
|
|
42
|
+
GitHub-Flavored Markdown - headings, lists, and real Markdown
|
|
43
|
+
tables - not ASCII art (no "=====" dividers or space/pipe-drawn
|
|
44
|
+
tables); it is rendered to HTML before sending.
|
|
45
|
+
`);
|
|
46
|
+
|
|
24
47
|
/** Options accepted by {@link emailTool}. */
|
|
25
48
|
export interface EmailToolOptions {
|
|
26
49
|
/**
|
|
@@ -50,28 +73,17 @@ export interface EmailToolOptions {
|
|
|
50
73
|
export function emailTool(opts: EmailToolOptions = {}) {
|
|
51
74
|
return createTool({
|
|
52
75
|
id: opts.id ?? "send_email",
|
|
53
|
-
description:
|
|
54
|
-
Send an email on the user's behalf. Pass one or more recipient
|
|
55
|
-
addresses (with optional cc / bcc and file attachments), a subject,
|
|
56
|
-
and a body; the user is prompted to approve the send before it goes
|
|
57
|
-
out (this tool is approval-gated). Use it only when the user
|
|
58
|
-
explicitly asks to send / forward / share something via email -
|
|
59
|
-
never autonomously. Keep subjects short and bodies self-contained:
|
|
60
|
-
the recipient has none of the chat context. Write the body in
|
|
61
|
-
GitHub-Flavored Markdown - headings, lists, and real Markdown
|
|
62
|
-
tables - not ASCII art (no "=====" dividers or space/pipe-drawn
|
|
63
|
-
tables); it is rendered to HTML before sending.
|
|
64
|
-
`),
|
|
76
|
+
description: SEND_EMAIL_DESCRIPTION,
|
|
65
77
|
inputSchema: email.emailMessageSchema,
|
|
66
78
|
outputSchema: email.emailResultSchema,
|
|
67
79
|
requireApproval: true,
|
|
68
|
-
execute: async (input) => {
|
|
69
|
-
const message = input
|
|
80
|
+
execute: async (input, context) => {
|
|
81
|
+
const message = email.emailMessageSchema.parse(input);
|
|
70
82
|
const { config } = getEmailRuntime();
|
|
71
83
|
const ctx = getExecutionContext();
|
|
72
84
|
const userEmail = "isUserContext" in ctx ? ctx.userEmail : undefined;
|
|
73
85
|
const from = resolveSenderAddress(config, userEmail);
|
|
74
|
-
const result = await sendEmail(message, from);
|
|
86
|
+
const result = await sendEmail(message, from, context?.abortSignal);
|
|
75
87
|
logger.info("sent", {
|
|
76
88
|
to: result.recipient,
|
|
77
89
|
from: result.from,
|
package/src/transport.ts
CHANGED
|
@@ -8,37 +8,117 @@
|
|
|
8
8
|
* The first caller (normally the plugin at setup) primes it with the
|
|
9
9
|
* plugin's config; later callers reuse it.
|
|
10
10
|
*
|
|
11
|
+
* The runtime also carries the {@link EmailExecutor} every outbound send runs
|
|
12
|
+
* through. The plugin installs its own `execute()` there at setup, which is
|
|
13
|
+
* how the Mastra tool - a plain function with no plugin instance in scope -
|
|
14
|
+
* still gets AppKit's retry / timeout / telemetry chain. Without a registered
|
|
15
|
+
* plugin (a direct call from a script or a test) the send still runs, just
|
|
16
|
+
* without interceptors.
|
|
17
|
+
*
|
|
18
|
+
* Every entry point takes an optional {@link AbortSignal} so the plugin's
|
|
19
|
+
* `execute()` timeout and a client disconnect both stop the caller waiting
|
|
20
|
+
* on SMTP.
|
|
21
|
+
*
|
|
11
22
|
* @module
|
|
12
23
|
*/
|
|
13
24
|
|
|
25
|
+
import {
|
|
26
|
+
AppKitError,
|
|
27
|
+
ConfigurationError,
|
|
28
|
+
ExecutionError,
|
|
29
|
+
ValidationError,
|
|
30
|
+
type ExecutionResult,
|
|
31
|
+
} from "@databricks/appkit";
|
|
32
|
+
import { async, error, log } from "@dbx-tools/shared-core";
|
|
14
33
|
import type { EmailAttachment, EmailMessage, EmailResult } from "@dbx-tools/shared-email";
|
|
15
34
|
import nodemailer, { type SendMailOptions, type Transporter } from "nodemailer";
|
|
16
35
|
import { resolveEmailConfig, type EmailPluginConfig, type ResolvedEmailConfig } from "./config";
|
|
36
|
+
import {
|
|
37
|
+
EMAIL_SEND_SETTINGS,
|
|
38
|
+
MAX_ATTACHMENT_BYTES,
|
|
39
|
+
MAX_ATTACHMENT_COUNT,
|
|
40
|
+
MAX_ATTACHMENTS_TOTAL_BYTES,
|
|
41
|
+
MAX_BODY_CHARS,
|
|
42
|
+
type EmailExecutionSettings,
|
|
43
|
+
} from "./defaults";
|
|
17
44
|
import { renderEmailHtml } from "./email-html";
|
|
18
45
|
import { writeOutboxEmail } from "./outbox";
|
|
19
46
|
import { assertSenderAllowed } from "./sender";
|
|
20
47
|
|
|
21
|
-
|
|
48
|
+
const logger = log.logger("email/transport");
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Runs one outbound send through AppKit's interceptor chain. Matches
|
|
52
|
+
* `Plugin.execute()`, which never throws: a failure comes back as
|
|
53
|
+
* `{ ok: false }`.
|
|
54
|
+
*/
|
|
55
|
+
export type EmailExecutor = <T>(
|
|
56
|
+
fn: (signal?: AbortSignal) => Promise<T>,
|
|
57
|
+
settings: EmailExecutionSettings,
|
|
58
|
+
) => Promise<ExecutionResult<T>>;
|
|
59
|
+
|
|
60
|
+
/** The shared dispatcher, its resolved config, and the send executor. */
|
|
22
61
|
export interface EmailRuntime {
|
|
23
62
|
/** Present only in SMTP mode. */
|
|
24
63
|
transporter?: Transporter;
|
|
25
64
|
config: ResolvedEmailConfig;
|
|
65
|
+
execute: EmailExecutor;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Combine two optional cancellation sources into one signal. Returns the
|
|
70
|
+
* single signal when only one is present so the common path allocates
|
|
71
|
+
* nothing.
|
|
72
|
+
*/
|
|
73
|
+
function mergeSignals(a?: AbortSignal, b?: AbortSignal): AbortSignal | undefined {
|
|
74
|
+
if (!a) return b;
|
|
75
|
+
if (!b) return a;
|
|
76
|
+
const controller = new AbortController();
|
|
77
|
+
async.tieAbortSignal(controller, a);
|
|
78
|
+
async.tieAbortSignal(controller, b);
|
|
79
|
+
return controller.signal;
|
|
26
80
|
}
|
|
27
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Executor used until (or unless) the plugin installs its own: run the send
|
|
84
|
+
* directly, mapping a throw onto the same {@link ExecutionResult} shape so
|
|
85
|
+
* call sites branch on `ok` either way.
|
|
86
|
+
*/
|
|
87
|
+
const directExecute: EmailExecutor = async (fn) => {
|
|
88
|
+
try {
|
|
89
|
+
return { ok: true, data: await fn() };
|
|
90
|
+
} catch (err) {
|
|
91
|
+
return {
|
|
92
|
+
ok: false,
|
|
93
|
+
status: err instanceof AppKitError ? err.statusCode : 500,
|
|
94
|
+
message: error.errorMessage(err),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
28
99
|
let runtime: EmailRuntime | undefined;
|
|
29
100
|
|
|
30
101
|
/**
|
|
31
|
-
* Return the shared runtime, building it on first use
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
102
|
+
* Return the shared runtime, building it on first use by resolving
|
|
103
|
+
* `overrides` over the environment (`SMTP_HOST`, `SMTP_PORT`,
|
|
104
|
+
* `SMTP_SECURE`, `SMTP_USER`, `SMTP_PASSWORD`, `EMAIL_DOMAIN`,
|
|
105
|
+
* `EMAIL_FROM`, `EMAIL_ALLOWED_SENDERS`, `EMAIL_SENDER_POLICY`,
|
|
106
|
+
* `EMAIL_OUTBOX_MODE`, `EMAIL_OUTBOX_DIR`) through
|
|
107
|
+
* {@link resolveEmailConfig}. With SMTP credentials present it holds a
|
|
108
|
+
* nodemailer transport and its connection pool; otherwise it is in
|
|
109
|
+
* file/outbox mode and holds none.
|
|
110
|
+
*
|
|
111
|
+
* `overrides` is read only on the call that builds the runtime, so prime it
|
|
112
|
+
* from the plugin's config at setup; later callers (the tool's `execute`,
|
|
113
|
+
* the sender-options route) pass nothing and get the same instance. Throws
|
|
114
|
+
* whatever {@link resolveEmailConfig} throws for an unusable configuration.
|
|
36
115
|
*/
|
|
37
116
|
export function getEmailRuntime(overrides?: EmailPluginConfig): EmailRuntime {
|
|
38
117
|
if (!runtime) {
|
|
39
118
|
const config = resolveEmailConfig(overrides);
|
|
40
119
|
runtime = {
|
|
41
120
|
config,
|
|
121
|
+
execute: directExecute,
|
|
42
122
|
...(config.mode === "smtp"
|
|
43
123
|
? {
|
|
44
124
|
transporter: nodemailer.createTransport({
|
|
@@ -54,17 +134,180 @@ export function getEmailRuntime(overrides?: EmailPluginConfig): EmailRuntime {
|
|
|
54
134
|
return runtime;
|
|
55
135
|
}
|
|
56
136
|
|
|
57
|
-
/**
|
|
137
|
+
/**
|
|
138
|
+
* Install the executor outbound sends run through. The plugin calls this at
|
|
139
|
+
* setup with its own `execute()`; a second call replaces the previous one, so
|
|
140
|
+
* a re-registered plugin does not leave the tools bound to a dead instance.
|
|
141
|
+
*/
|
|
142
|
+
export function setEmailExecutor(execute: EmailExecutor): void {
|
|
143
|
+
getEmailRuntime().execute = execute;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Drop the memoized runtime, closing the SMTP connection pool, so the next
|
|
148
|
+
* {@link getEmailRuntime} rebuilds it from fresh config and stops calling
|
|
149
|
+
* through a torn-down plugin's `execute()`. Idempotent, and the plugin's
|
|
150
|
+
* `shutdown()` hook.
|
|
151
|
+
*/
|
|
58
152
|
export function resetEmailRuntime(): void {
|
|
59
153
|
runtime?.transporter?.close();
|
|
60
154
|
runtime = undefined;
|
|
61
155
|
}
|
|
62
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Run one non-idempotent write through the shared executor and unwrap it.
|
|
159
|
+
*
|
|
160
|
+
* `execute()` never throws, so a failed send arrives as `{ ok: false }` with a
|
|
161
|
+
* status the interceptors already sanitized; it is logged here and re-raised
|
|
162
|
+
* as a stable {@link ExecutionError} so an upstream message never becomes the
|
|
163
|
+
* caller's error text. `signal` is the caller's own cancellation (an agent
|
|
164
|
+
* run, a request teardown); it is merged with the signal the timeout
|
|
165
|
+
* interceptor supplies so either one unwinds the I/O.
|
|
166
|
+
*/
|
|
167
|
+
export async function executeWrite<T>(
|
|
168
|
+
operation: string,
|
|
169
|
+
settings: EmailExecutionSettings,
|
|
170
|
+
fn: (signal?: AbortSignal) => Promise<T>,
|
|
171
|
+
signal?: AbortSignal,
|
|
172
|
+
): Promise<T> {
|
|
173
|
+
const { execute } = getEmailRuntime();
|
|
174
|
+
const result = await execute(
|
|
175
|
+
(executeSignal) => fn(mergeSignals(executeSignal, signal)),
|
|
176
|
+
settings,
|
|
177
|
+
);
|
|
178
|
+
if (result.ok) return result.data;
|
|
179
|
+
// A caller that cancelled is not a failure worth reporting as one.
|
|
180
|
+
if (signal?.aborted) throw ExecutionError.canceled();
|
|
181
|
+
logger.warn("execution-failed", {
|
|
182
|
+
operation,
|
|
183
|
+
status: result.status,
|
|
184
|
+
error: result.message,
|
|
185
|
+
});
|
|
186
|
+
throw new ExecutionError(`email: ${operation} failed`, {
|
|
187
|
+
context: { operation, status: result.status },
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Open and tear down one SMTP connection to prove the host, port, and
|
|
193
|
+
* credentials work. Called at plugin setup so a bad relay shows up in the
|
|
194
|
+
* boot logs rather than on the first approved send.
|
|
195
|
+
*/
|
|
196
|
+
export async function verifyEmailTransport(
|
|
197
|
+
transporter: Transporter | undefined,
|
|
198
|
+
signal?: AbortSignal,
|
|
199
|
+
): Promise<void> {
|
|
200
|
+
if (!transporter) {
|
|
201
|
+
throw ConfigurationError.invalidConnection(
|
|
202
|
+
"SMTP",
|
|
203
|
+
"The runtime resolved to outbox mode, so there is no transport to verify.",
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
await abortable(transporter.verify(), signal);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Stop awaiting `promise` as soon as `signal` aborts. nodemailer exposes no
|
|
211
|
+
* cancellation hook, so the SMTP conversation itself finishes on its own
|
|
212
|
+
* connection; what unwinds is the caller and everything downstream of it.
|
|
213
|
+
*/
|
|
214
|
+
function abortable<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
|
|
215
|
+
if (!signal) return promise;
|
|
216
|
+
signal.throwIfAborted();
|
|
217
|
+
// The listener is detached once the race settles so a long-lived run signal
|
|
218
|
+
// does not accumulate one per send.
|
|
219
|
+
const listener = new AbortController();
|
|
220
|
+
const aborted = new Promise<never>((_, reject) => {
|
|
221
|
+
signal.addEventListener("abort", () => reject(signal.reason), {
|
|
222
|
+
once: true,
|
|
223
|
+
signal: listener.signal,
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
return Promise.race([promise, aborted]).finally(() => listener.abort());
|
|
227
|
+
}
|
|
228
|
+
|
|
63
229
|
/** The comma-joined recipient string echoed back in {@link EmailResult}. */
|
|
64
230
|
function recipientEcho(to: string[]): string {
|
|
65
231
|
return to.join(", ");
|
|
66
232
|
}
|
|
67
233
|
|
|
234
|
+
/**
|
|
235
|
+
* Node buffer encodings a wire attachment may name for its inline `content`.
|
|
236
|
+
* Anything else is measured as UTF-8, which over-counts rather than letting
|
|
237
|
+
* an unrecognized encoding slip past the size cap.
|
|
238
|
+
*/
|
|
239
|
+
const CONTENT_ENCODINGS: ReadonlySet<string> = new Set([
|
|
240
|
+
"ascii",
|
|
241
|
+
"base64",
|
|
242
|
+
"base64url",
|
|
243
|
+
"binary",
|
|
244
|
+
"hex",
|
|
245
|
+
"latin1",
|
|
246
|
+
"ucs2",
|
|
247
|
+
"ucs-2",
|
|
248
|
+
"utf8",
|
|
249
|
+
"utf-8",
|
|
250
|
+
"utf16le",
|
|
251
|
+
"utf-16le",
|
|
252
|
+
]);
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Decoded byte size of one attachment's inline content. A `path` attachment
|
|
256
|
+
* contributes nothing: nodemailer streams it, so its bytes never sit in this
|
|
257
|
+
* process and cannot be measured here.
|
|
258
|
+
*/
|
|
259
|
+
function attachmentBytes(attachment: EmailAttachment): number {
|
|
260
|
+
const { content, encoding } = attachment;
|
|
261
|
+
if (content === undefined) return 0;
|
|
262
|
+
const declared = encoding?.toLowerCase();
|
|
263
|
+
const resolved =
|
|
264
|
+
declared && CONTENT_ENCODINGS.has(declared) ? (declared as BufferEncoding) : "utf8";
|
|
265
|
+
return Buffer.byteLength(content, resolved);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Reject a message whose body or attachments exceed the plugin's caps. The
|
|
270
|
+
* body and attachment list arrive from a model, so they are unbounded until
|
|
271
|
+
* something bounds them; most SMTP relays also reject an oversized message
|
|
272
|
+
* only after the whole payload has been uploaded.
|
|
273
|
+
*/
|
|
274
|
+
function assertMessageWithinCaps(message: EmailMessage): void {
|
|
275
|
+
if (message.body.length > MAX_BODY_CHARS) {
|
|
276
|
+
throw ValidationError.invalidValue(
|
|
277
|
+
"body",
|
|
278
|
+
message.body.length,
|
|
279
|
+
`at most ${MAX_BODY_CHARS} characters`,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
const attachments = message.attachments ?? [];
|
|
283
|
+
if (attachments.length > MAX_ATTACHMENT_COUNT) {
|
|
284
|
+
throw ValidationError.invalidValue(
|
|
285
|
+
"attachments",
|
|
286
|
+
attachments.length,
|
|
287
|
+
`at most ${MAX_ATTACHMENT_COUNT} files`,
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
let total = 0;
|
|
291
|
+
for (const attachment of attachments) {
|
|
292
|
+
const bytes = attachmentBytes(attachment);
|
|
293
|
+
if (bytes > MAX_ATTACHMENT_BYTES) {
|
|
294
|
+
throw ValidationError.invalidValue(
|
|
295
|
+
"attachments[].content",
|
|
296
|
+
bytes,
|
|
297
|
+
`at most ${MAX_ATTACHMENT_BYTES} bytes per file`,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
total += bytes;
|
|
301
|
+
}
|
|
302
|
+
if (total > MAX_ATTACHMENTS_TOTAL_BYTES) {
|
|
303
|
+
throw ValidationError.invalidValue(
|
|
304
|
+
"attachments",
|
|
305
|
+
total,
|
|
306
|
+
`at most ${MAX_ATTACHMENTS_TOTAL_BYTES} bytes across all files`,
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
68
311
|
/**
|
|
69
312
|
* Map the wire-format {@link EmailAttachment}s onto nodemailer's
|
|
70
313
|
* attachment shape, dropping unset optional keys so nodemailer applies
|
|
@@ -86,44 +329,48 @@ function toMailAttachments(
|
|
|
86
329
|
}
|
|
87
330
|
|
|
88
331
|
/**
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
* body is markdown: SMTP sends it as both a plain-text part (the raw
|
|
93
|
-
* source) and an HTML part (rendered), and the outbox embeds the
|
|
94
|
-
* rendered HTML in a document. In file mode the returned `messageId` is
|
|
95
|
-
* the path written. Throws when `to` carries no recipient, or when `from`
|
|
96
|
-
* is not permitted by the configured sender allow-list.
|
|
332
|
+
* Hand one already-validated message to SMTP, or write it to the outbox when
|
|
333
|
+
* no credentials are configured. The half of a send that performs I/O, so it
|
|
334
|
+
* is what runs inside the interceptor chain.
|
|
97
335
|
*/
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
336
|
+
async function dispatch(
|
|
337
|
+
message: EmailMessage,
|
|
338
|
+
from: string,
|
|
339
|
+
signal?: AbortSignal,
|
|
340
|
+
): Promise<EmailResult> {
|
|
102
341
|
const { config, transporter } = getEmailRuntime();
|
|
103
|
-
assertSenderAllowed(from, config.allowedSenders);
|
|
104
342
|
const recipient = recipientEcho(message.to);
|
|
343
|
+
signal?.throwIfAborted();
|
|
105
344
|
|
|
106
345
|
if (config.mode === "file") {
|
|
107
346
|
const path = await writeOutboxEmail(message, from, config.outDir, config.brand);
|
|
108
347
|
return { sent: true, recipient, from, messageId: path };
|
|
109
348
|
}
|
|
110
349
|
|
|
111
|
-
if (!transporter)
|
|
350
|
+
if (!transporter) {
|
|
351
|
+
throw ConfigurationError.invalidConnection(
|
|
352
|
+
"SMTP",
|
|
353
|
+
"No transport was built for the resolved configuration.",
|
|
354
|
+
);
|
|
355
|
+
}
|
|
112
356
|
const attachments = toMailAttachments(message.attachments);
|
|
113
|
-
const info = await
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
text: message.body,
|
|
118
|
-
html: renderEmailHtml({
|
|
357
|
+
const info = await abortable(
|
|
358
|
+
transporter.sendMail({
|
|
359
|
+
from,
|
|
360
|
+
to: message.to,
|
|
119
361
|
subject: message.subject,
|
|
120
|
-
|
|
121
|
-
|
|
362
|
+
text: message.body,
|
|
363
|
+
html: renderEmailHtml({
|
|
364
|
+
subject: message.subject,
|
|
365
|
+
body: message.body,
|
|
366
|
+
...(config.brand ? { brand: config.brand } : {}),
|
|
367
|
+
}),
|
|
368
|
+
...(message.cc && message.cc.length > 0 ? { cc: message.cc } : {}),
|
|
369
|
+
...(message.bcc && message.bcc.length > 0 ? { bcc: message.bcc } : {}),
|
|
370
|
+
...(attachments ? { attachments } : {}),
|
|
122
371
|
}),
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
...(attachments ? { attachments } : {}),
|
|
126
|
-
});
|
|
372
|
+
signal,
|
|
373
|
+
);
|
|
127
374
|
return {
|
|
128
375
|
sent: true,
|
|
129
376
|
recipient,
|
|
@@ -131,3 +378,36 @@ export async function sendEmail(message: EmailMessage, from: string): Promise<Em
|
|
|
131
378
|
...(info.messageId ? { messageId: info.messageId } : {}),
|
|
132
379
|
};
|
|
133
380
|
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Send (SMTP mode) or persist (file/outbox mode) one message from the
|
|
384
|
+
* resolved `from` address. `to` (and optional `cc` / `bcc`) each accept
|
|
385
|
+
* one or more addresses, and `attachments` are forwarded as files. The
|
|
386
|
+
* body is markdown: SMTP sends it as both a plain-text part (the raw
|
|
387
|
+
* source) and an HTML part (rendered), and the outbox embeds the
|
|
388
|
+
* rendered HTML in a document. In file mode the returned `messageId` is
|
|
389
|
+
* the path written. Throws when `to` carries no recipient, when the body
|
|
390
|
+
* or attachments exceed the plugin's caps, or when `from` is not permitted
|
|
391
|
+
* by the effective sender allow-list.
|
|
392
|
+
*
|
|
393
|
+
* The recipient, cap, and sender checks run before the interceptor chain so
|
|
394
|
+
* their specific status and actionable message reach the caller instead of
|
|
395
|
+
* the chain's stable failure text. `signal` cancels the send.
|
|
396
|
+
*/
|
|
397
|
+
export async function sendEmail(
|
|
398
|
+
message: EmailMessage,
|
|
399
|
+
from: string,
|
|
400
|
+
signal?: AbortSignal,
|
|
401
|
+
): Promise<EmailResult> {
|
|
402
|
+
if (message.to.length === 0) {
|
|
403
|
+
throw ValidationError.missingField("to");
|
|
404
|
+
}
|
|
405
|
+
assertMessageWithinCaps(message);
|
|
406
|
+
assertSenderAllowed(from, getEmailRuntime().config.allowedSenders);
|
|
407
|
+
return executeWrite(
|
|
408
|
+
"send",
|
|
409
|
+
EMAIL_SEND_SETTINGS,
|
|
410
|
+
(executeSignal) => dispatch(message, from, executeSignal),
|
|
411
|
+
signal,
|
|
412
|
+
);
|
|
413
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { beforeEach, describe, it } from "node:test";
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_SMTP_PORT,
|
|
6
|
+
IMPLICIT_TLS_SMTP_PORT,
|
|
7
|
+
resolveEmailConfig,
|
|
8
|
+
type ResolvedSmtpConfig,
|
|
9
|
+
type SenderPolicy,
|
|
10
|
+
} from "../src/config";
|
|
11
|
+
|
|
12
|
+
/** Every env var {@link resolveEmailConfig} reads, cleared between cases. */
|
|
13
|
+
const ENV_KEYS = [
|
|
14
|
+
"SMTP_HOST",
|
|
15
|
+
"SMTP_PORT",
|
|
16
|
+
"SMTP_SECURE",
|
|
17
|
+
"SMTP_USER",
|
|
18
|
+
"SMTP_PASSWORD",
|
|
19
|
+
"EMAIL_DOMAIN",
|
|
20
|
+
"EMAIL_FROM",
|
|
21
|
+
"EMAIL_ALLOWED_SENDERS",
|
|
22
|
+
"EMAIL_SENDER_POLICY",
|
|
23
|
+
"EMAIL_OUTBOX_MODE",
|
|
24
|
+
"EMAIL_OUTBOX_DIR",
|
|
25
|
+
] as const;
|
|
26
|
+
|
|
27
|
+
const SMTP_CREDENTIALS = {
|
|
28
|
+
host: "smtp.example.com",
|
|
29
|
+
user: "apikey",
|
|
30
|
+
password: "secret",
|
|
31
|
+
} as const;
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
for (const key of ENV_KEYS) delete process.env[key];
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("resolveEmailConfig modes", () => {
|
|
38
|
+
it("refuses to resolve with neither SMTP credentials nor an outbox opt-in", () => {
|
|
39
|
+
assert.throws(() => resolveEmailConfig(), /SMTP connection not configured/);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("names the missing env vars for a partial SMTP configuration", () => {
|
|
43
|
+
assert.throws(
|
|
44
|
+
() => resolveEmailConfig({ smtp: { host: "smtp.example.com" } }),
|
|
45
|
+
/Missing required environment variables: SMTP_USER, SMTP_PASSWORD/,
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("refuses SMTP with no sender source to derive a From from", () => {
|
|
50
|
+
assert.throws(() => resolveEmailConfig({ smtp: SMTP_CREDENTIALS }), /Email sender source/);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("resolves SMTP mode with the default port and STARTTLS", () => {
|
|
54
|
+
const config = resolveEmailConfig({
|
|
55
|
+
smtp: SMTP_CREDENTIALS,
|
|
56
|
+
domain: "mail.example.com",
|
|
57
|
+
}) as ResolvedSmtpConfig;
|
|
58
|
+
assert.equal(config.mode, "smtp");
|
|
59
|
+
assert.equal(config.port, DEFAULT_SMTP_PORT);
|
|
60
|
+
assert.equal(config.secure, false);
|
|
61
|
+
assert.deepEqual(config.auth, { user: "apikey", pass: "secret" });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("turns on TLS-on-connect for the implicit-TLS port", () => {
|
|
65
|
+
const config = resolveEmailConfig({
|
|
66
|
+
smtp: { ...SMTP_CREDENTIALS, port: IMPLICIT_TLS_SMTP_PORT },
|
|
67
|
+
domain: "mail.example.com",
|
|
68
|
+
}) as ResolvedSmtpConfig;
|
|
69
|
+
assert.equal(config.secure, true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("prefers explicit config over the matching env var", () => {
|
|
73
|
+
process.env.SMTP_HOST = "env.example.com";
|
|
74
|
+
process.env.SMTP_PORT = "2525";
|
|
75
|
+
process.env.SMTP_USER = "envuser";
|
|
76
|
+
process.env.SMTP_PASSWORD = "envpass";
|
|
77
|
+
process.env.EMAIL_DOMAIN = "env.example.com";
|
|
78
|
+
const config = resolveEmailConfig({
|
|
79
|
+
smtp: SMTP_CREDENTIALS,
|
|
80
|
+
domain: "mail.example.com",
|
|
81
|
+
}) as ResolvedSmtpConfig;
|
|
82
|
+
assert.equal(config.host, "smtp.example.com");
|
|
83
|
+
assert.equal(config.domain, "mail.example.com");
|
|
84
|
+
// The port has no explicit value, so the env var still wins over the default.
|
|
85
|
+
assert.equal(config.port, 2525);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("resolves outbox mode to an absolute directory when opted in", () => {
|
|
89
|
+
process.env.EMAIL_OUTBOX_MODE = "1";
|
|
90
|
+
const config = resolveEmailConfig({ outDir: "tmp/email-outbox" });
|
|
91
|
+
assert.equal(config.mode, "file");
|
|
92
|
+
assert.equal(config.mode === "file" && config.outDir, resolve("tmp/email-outbox"));
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("keeps a sender source optional in outbox mode", () => {
|
|
96
|
+
process.env.EMAIL_OUTBOX_MODE = "1";
|
|
97
|
+
const config = resolveEmailConfig();
|
|
98
|
+
assert.equal(config.mode, "file");
|
|
99
|
+
assert.equal(config.domain, undefined);
|
|
100
|
+
assert.equal(config.from, undefined);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("resolveEmailConfig sender policy", () => {
|
|
105
|
+
it("defaults to allowlist and narrows an empty list to the configured domain", () => {
|
|
106
|
+
const config = resolveEmailConfig({ smtp: SMTP_CREDENTIALS, domain: "mail.example.com" });
|
|
107
|
+
assert.equal(config.senderPolicy, "allowlist");
|
|
108
|
+
assert.deepEqual(config.allowedSenders, ["*@mail.example.com"]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("narrows an empty list to a fixed From when that is the sender source", () => {
|
|
112
|
+
const config = resolveEmailConfig({ smtp: SMTP_CREDENTIALS, from: "Alerts@Example.com" });
|
|
113
|
+
assert.deepEqual(config.allowedSenders, ["alerts@example.com"]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("includes both patterns when both sender sources are configured", () => {
|
|
117
|
+
const config = resolveEmailConfig({
|
|
118
|
+
smtp: SMTP_CREDENTIALS,
|
|
119
|
+
from: "alerts@example.com",
|
|
120
|
+
domain: "mail.example.com",
|
|
121
|
+
});
|
|
122
|
+
assert.deepEqual(config.allowedSenders, ["alerts@example.com", "*@mail.example.com"]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("leaves the list empty under the named unrestricted policy", () => {
|
|
126
|
+
const config = resolveEmailConfig({
|
|
127
|
+
smtp: SMTP_CREDENTIALS,
|
|
128
|
+
domain: "mail.example.com",
|
|
129
|
+
senderPolicy: "unrestricted",
|
|
130
|
+
});
|
|
131
|
+
assert.equal(config.senderPolicy, "unrestricted");
|
|
132
|
+
assert.deepEqual(config.allowedSenders, []);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("reads the policy from EMAIL_SENDER_POLICY", () => {
|
|
136
|
+
process.env.EMAIL_SENDER_POLICY = "unrestricted";
|
|
137
|
+
const config = resolveEmailConfig({ smtp: SMTP_CREDENTIALS, domain: "mail.example.com" });
|
|
138
|
+
assert.equal(config.senderPolicy, "unrestricted");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("rejects an unrecognized policy", () => {
|
|
142
|
+
const senderPolicy = "open" as SenderPolicy;
|
|
143
|
+
assert.throws(
|
|
144
|
+
() => resolveEmailConfig({ smtp: SMTP_CREDENTIALS, domain: "d.com", senderPolicy }),
|
|
145
|
+
/Invalid value for senderPolicy/,
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("keeps an explicit allow-list instead of the implied one", () => {
|
|
150
|
+
const config = resolveEmailConfig({
|
|
151
|
+
smtp: SMTP_CREDENTIALS,
|
|
152
|
+
domain: "mail.example.com",
|
|
153
|
+
allowedSenders: "Alerts@Example.com, *@other.example.com",
|
|
154
|
+
});
|
|
155
|
+
assert.deepEqual(config.allowedSenders, ["alerts@example.com", "*@other.example.com"]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("reads an explicit allow-list from EMAIL_ALLOWED_SENDERS", () => {
|
|
159
|
+
process.env.EMAIL_OUTBOX_MODE = "1";
|
|
160
|
+
process.env.EMAIL_ALLOWED_SENDERS = "a@x.com b@x.com";
|
|
161
|
+
assert.deepEqual(resolveEmailConfig().allowedSenders, ["a@x.com", "b@x.com"]);
|
|
162
|
+
});
|
|
163
|
+
});
|