@dbx-tools/email 0.6.46 → 0.6.49
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 +43 -25
- package/index.ts +1 -2
- package/lib/index.d.ts +1 -2
- package/lib/index.js +2 -2
- package/lib/src/brand.d.ts +5 -42
- package/lib/src/brand.js +5 -39
- package/lib/src/config.d.ts +6 -7
- package/lib/src/config.js +25 -20
- package/lib/src/defaults.d.ts +1 -1
- package/lib/src/defaults.js +2 -2
- package/lib/src/email-html.d.ts +14 -49
- package/lib/src/email-html.js +20 -141
- package/lib/src/markdown.d.ts +4 -22
- package/lib/src/markdown.js +11 -79
- package/lib/src/outbox.js +2 -2
- package/lib/src/plugin.d.ts +14 -0
- package/lib/src/plugin.js +19 -3
- package/lib/src/tool.d.ts +1 -1
- package/lib/src/tool.js +5 -7
- package/lib/src/transport.d.ts +2 -2
- package/lib/src/transport.js +30 -36
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +8 -6
- package/src/brand.ts +9 -59
- package/src/config.ts +29 -25
- package/src/defaults.ts +1 -1
- package/src/email-html.ts +23 -164
- package/src/markdown.ts +12 -92
- package/src/outbox.ts +1 -1
- package/src/plugin.ts +18 -2
- package/src/tool.ts +4 -6
- package/src/transport.ts +28 -35
- package/src/juice.d.ts +0 -17
package/src/config.ts
CHANGED
|
@@ -25,9 +25,9 @@
|
|
|
25
25
|
*/
|
|
26
26
|
import { resolve } from "node:path";
|
|
27
27
|
import { ConfigurationError, ValidationError, type BasePluginConfig } from "@databricks/appkit";
|
|
28
|
-
import { object } from "@dbx-tools/shared-core";
|
|
28
|
+
import { env, object } from "@dbx-tools/shared-core";
|
|
29
29
|
import type { JSONSchema7 } from "json-schema";
|
|
30
|
-
import type
|
|
30
|
+
import { defaultEmailBrand, type EmailBrand } from "./brand.ts";
|
|
31
31
|
import { parseAllowedSenders } from "./sender.ts";
|
|
32
32
|
|
|
33
33
|
/** SMTP submission port used when none is configured. */
|
|
@@ -108,10 +108,9 @@ export interface EmailPluginConfig extends BasePluginConfig {
|
|
|
108
108
|
*/
|
|
109
109
|
senderPolicy?: SenderPolicy;
|
|
110
110
|
/**
|
|
111
|
-
* Optional brand styling
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
* `BrandContext`.
|
|
111
|
+
* Optional brand styling applied to every rendered message. Omit to use the
|
|
112
|
+
* repository's dbx-tools brand. Pass {@link emailBrandFromContext} to derive
|
|
113
|
+
* a custom value from a shared `BrandContext`.
|
|
115
114
|
*/
|
|
116
115
|
brand?: EmailBrand;
|
|
117
116
|
}
|
|
@@ -131,8 +130,8 @@ interface ResolvedSender {
|
|
|
131
130
|
allowedSenders: string[];
|
|
132
131
|
/** The restriction mode the allow-list was resolved under. */
|
|
133
132
|
senderPolicy: SenderPolicy;
|
|
134
|
-
/** Brand styling applied to rendered HTML
|
|
135
|
-
brand
|
|
133
|
+
/** Brand styling applied to rendered HTML. */
|
|
134
|
+
brand: EmailBrand;
|
|
136
135
|
}
|
|
137
136
|
|
|
138
137
|
/** Resolved config for real SMTP delivery. */
|
|
@@ -207,7 +206,7 @@ export const EMAIL_CONFIG_SCHEMA: JSONSchema7 = {
|
|
|
207
206
|
brand: {
|
|
208
207
|
type: "object",
|
|
209
208
|
description:
|
|
210
|
-
"Brand styling
|
|
209
|
+
"Brand styling applied to every React Email message. Omit to use the dbx-tools brand.",
|
|
211
210
|
properties: {
|
|
212
211
|
accent: {
|
|
213
212
|
type: "string",
|
|
@@ -230,6 +229,13 @@ export const EMAIL_CONFIG_SCHEMA: JSONSchema7 = {
|
|
|
230
229
|
description:
|
|
231
230
|
"Logo image for the header band. Only an http(s): or data: URL renders; other values are dropped because a mail client cannot load them.",
|
|
232
231
|
},
|
|
232
|
+
background: { type: "string", description: "Inbox canvas color." },
|
|
233
|
+
surface: { type: "string", description: "Message-card color." },
|
|
234
|
+
foreground: { type: "string", description: "Primary text color." },
|
|
235
|
+
muted: { type: "string", description: "Secondary text color." },
|
|
236
|
+
border: { type: "string", description: "Border and divider color." },
|
|
237
|
+
tagline: { type: "string", description: "Footer product line." },
|
|
238
|
+
website: { type: "string", description: "Optional footer website URL." },
|
|
233
239
|
},
|
|
234
240
|
required: ["accent", "fontFamily"],
|
|
235
241
|
},
|
|
@@ -238,13 +244,12 @@ export const EMAIL_CONFIG_SCHEMA: JSONSchema7 = {
|
|
|
238
244
|
|
|
239
245
|
/** Parse the `SMTP_SECURE` env / config flag, defaulting by port. */
|
|
240
246
|
function resolveSecure(flag: boolean | undefined, port: number): boolean {
|
|
241
|
-
|
|
242
|
-
return object.toBoolean(process.env.SMTP_SECURE) ?? port === IMPLICIT_TLS_SMTP_PORT;
|
|
247
|
+
return env.boolean(flag, "SMTP_SECURE") ?? port === IMPLICIT_TLS_SMTP_PORT;
|
|
243
248
|
}
|
|
244
249
|
|
|
245
250
|
/** Parse the `EMAIL_SENDER_POLICY` env / config value, defaulting to deny-by-default. */
|
|
246
251
|
function resolveSenderPolicy(policy: SenderPolicy | undefined): SenderPolicy {
|
|
247
|
-
const raw = policy ??
|
|
252
|
+
const raw = policy ?? env.text("EMAIL_SENDER_POLICY")?.toLowerCase();
|
|
248
253
|
if (raw === "unrestricted") return "unrestricted";
|
|
249
254
|
if (raw === undefined || raw === "" || raw === "allowlist") return "allowlist";
|
|
250
255
|
throw ValidationError.invalidValue("senderPolicy", raw, '"allowlist" or "unrestricted"');
|
|
@@ -266,7 +271,7 @@ function impliedSenderPatterns(domain: string | undefined, from: string | undefi
|
|
|
266
271
|
|
|
267
272
|
/** Whether `EMAIL_OUTBOX_MODE` explicitly opts into the file/outbox fallback. */
|
|
268
273
|
function isOutboxModeEnabled(): boolean {
|
|
269
|
-
return
|
|
274
|
+
return env.boolean(undefined, "EMAIL_OUTBOX_MODE") ?? false;
|
|
270
275
|
}
|
|
271
276
|
|
|
272
277
|
const SMTP_REQUIRED_FIELDS = ["SMTP_HOST", "SMTP_USER", "SMTP_PASSWORD"] as const;
|
|
@@ -301,25 +306,25 @@ function missingSmtpFields(
|
|
|
301
306
|
*/
|
|
302
307
|
export function resolveEmailConfig(config: EmailPluginConfig = {}): ResolvedEmailConfig {
|
|
303
308
|
const smtp = config.smtp ?? {};
|
|
304
|
-
const host = smtp.host ??
|
|
305
|
-
const user = smtp.user ??
|
|
306
|
-
const pass = smtp.password ??
|
|
307
|
-
const domain = config.domain ??
|
|
308
|
-
const from = config.from ??
|
|
309
|
+
const host = env.string(smtp.host, "SMTP_HOST") ?? undefined;
|
|
310
|
+
const user = env.string(smtp.user, "SMTP_USER") ?? undefined;
|
|
311
|
+
const pass = env.string(smtp.password, "SMTP_PASSWORD") ?? undefined;
|
|
312
|
+
const domain = env.string(config.domain, "EMAIL_DOMAIN") ?? undefined;
|
|
313
|
+
const from = env.string(config.from, "EMAIL_FROM") ?? undefined;
|
|
309
314
|
const senderPolicy = resolveSenderPolicy(config.senderPolicy);
|
|
310
315
|
const configuredSenders = parseAllowedSenders(
|
|
311
|
-
config.allowedSenders ??
|
|
316
|
+
config.allowedSenders ?? env.text("EMAIL_ALLOWED_SENDERS") ?? undefined,
|
|
312
317
|
);
|
|
313
318
|
const allowedSenders =
|
|
314
319
|
configuredSenders.length > 0 || senderPolicy === "unrestricted"
|
|
315
320
|
? configuredSenders
|
|
316
321
|
: impliedSenderPatterns(domain, from);
|
|
317
322
|
const sender: ResolvedSender = {
|
|
318
|
-
...(domain
|
|
319
|
-
...(from
|
|
323
|
+
...object.optional("domain", domain),
|
|
324
|
+
...object.optional("from", from),
|
|
320
325
|
allowedSenders,
|
|
321
326
|
senderPolicy,
|
|
322
|
-
|
|
327
|
+
brand: config.brand ?? defaultEmailBrand,
|
|
323
328
|
};
|
|
324
329
|
|
|
325
330
|
const hasAllSmtp = Boolean(host && user && pass);
|
|
@@ -336,8 +341,7 @@ export function resolveEmailConfig(config: EmailPluginConfig = {}): ResolvedEmai
|
|
|
336
341
|
"Set EMAIL_DOMAIN to derive <user-local-part>@<domain>, or EMAIL_FROM for a fixed address.",
|
|
337
342
|
);
|
|
338
343
|
}
|
|
339
|
-
const
|
|
340
|
-
const port = Number.isFinite(portRaw) && portRaw ? Number(portRaw) : DEFAULT_SMTP_PORT;
|
|
344
|
+
const port = env.positiveInt(smtp.port, "SMTP_PORT", DEFAULT_SMTP_PORT);
|
|
341
345
|
return {
|
|
342
346
|
mode: "smtp",
|
|
343
347
|
host: host!,
|
|
@@ -356,7 +360,7 @@ export function resolveEmailConfig(config: EmailPluginConfig = {}): ResolvedEmai
|
|
|
356
360
|
}
|
|
357
361
|
|
|
358
362
|
const outDir = resolve(
|
|
359
|
-
config.outDir
|
|
363
|
+
env.string(config.outDir, "EMAIL_OUTBOX_DIR") ?? resolve(process.cwd(), "tmp"),
|
|
360
364
|
);
|
|
361
365
|
return { mode: "file", outDir, ...sender };
|
|
362
366
|
}
|
package/src/defaults.ts
CHANGED
|
@@ -50,7 +50,7 @@ export const MAX_ATTACHMENTS_TOTAL_BYTES = 20_971_520;
|
|
|
50
50
|
/** Largest number of attachments accepted on one message. */
|
|
51
51
|
export const MAX_ATTACHMENT_COUNT = 20;
|
|
52
52
|
|
|
53
|
-
/** Largest
|
|
53
|
+
/** Largest email body accepted, in characters. */
|
|
54
54
|
export const MAX_BODY_CHARS = 200_000;
|
|
55
55
|
|
|
56
56
|
/** Execution settings for a send (SMTP dispatch or an outbox write). */
|
package/src/email-html.ts
CHANGED
|
@@ -1,174 +1,33 @@
|
|
|
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
|
-
* Brand styling (accent color, font, header logo) is optional: pass an
|
|
16
|
-
* {@link EmailBrand} to color the layout, or omit it for the neutral
|
|
17
|
-
* default. Branding is inlined here because the browser UI's `[data-brand]`
|
|
18
|
-
* CSS bridge can't reach an inbox (see `./brand`).
|
|
19
|
-
*
|
|
20
|
-
* @module
|
|
21
|
-
*/
|
|
22
|
-
|
|
1
|
+
/** Node rendering adapters for the shared React Email document. @module */
|
|
23
2
|
import { string } from "@dbx-tools/shared-core";
|
|
24
|
-
import
|
|
25
|
-
import
|
|
26
|
-
import {
|
|
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;
|
|
3
|
+
import { EmailDocument, type EmailDocumentProps } from "@dbx-tools/shared-email-template";
|
|
4
|
+
import { render } from "@react-email/render";
|
|
5
|
+
import { createElement } from "react";
|
|
33
6
|
|
|
34
|
-
/**
|
|
35
|
-
const DEFAULT_BRAND: Required<Pick<EmailBrand, "accent" | "onAccent" | "fontFamily">> = {
|
|
36
|
-
accent: "#0b6bcb",
|
|
37
|
-
onAccent: "#ffffff",
|
|
38
|
-
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif",
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
/** Escape HTML-significant characters (re-exported from `@dbx-tools/shared-core`). */
|
|
7
|
+
/** Escape HTML-significant characters (re-exported from shared-core). */
|
|
42
8
|
export const escapeHtml = string.escapeHtml;
|
|
43
9
|
|
|
44
|
-
/**
|
|
45
|
-
|
|
46
|
-
* onto elements). Outer layout styling is written inline directly so it
|
|
47
|
-
* survives even if inlining is skipped; the `@media` rule is preserved
|
|
48
|
-
* by juice for clients that honor it. Parameterized by the resolved accent
|
|
49
|
-
* so body links match the brand.
|
|
50
|
-
*/
|
|
51
|
-
function contentCss(accent: string): string {
|
|
52
|
-
return `
|
|
53
|
-
.email-body { font-size: 15px; line-height: 1.55; color: #1a1a1a; }
|
|
54
|
-
.email-body p { margin: 0 0 1rem; }
|
|
55
|
-
.email-body a { color: ${accent}; }
|
|
56
|
-
.email-body h1, .email-body h2, .email-body h3 { margin: 1.4rem 0 0.6rem; line-height: 1.25; }
|
|
57
|
-
.email-body ul, .email-body ol { margin: 0 0 1rem; padding-left: 1.4rem; }
|
|
58
|
-
.email-body table { border-collapse: collapse; margin: 1rem 0; width: 100%; font-size: 14px; }
|
|
59
|
-
.email-body th, .email-body td { border: 1px solid #d0d7de; padding: 6px 10px; text-align: left; }
|
|
60
|
-
.email-body th { background: #f6f8fa; font-weight: 600; }
|
|
61
|
-
.email-body code { background: #f1f3f5; padding: 2px 5px; border-radius: 4px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.9em; }
|
|
62
|
-
.email-body pre { background: #f1f3f5; padding: 12px; border-radius: 6px; overflow-x: auto; }
|
|
63
|
-
.email-body pre code { background: none; padding: 0; }
|
|
64
|
-
.email-body blockquote { margin: 1rem 0; padding: 0 1rem; color: #57606a; border-left: 3px solid #d0d7de; }
|
|
65
|
-
.email-body img { max-width: 100%; height: auto; }
|
|
66
|
-
.meta { border-collapse: collapse; font-size: 13px; margin-bottom: 4px; }
|
|
67
|
-
.meta th { text-align: left; padding: 2px 12px 2px 0; vertical-align: top; color: #6b7280; font-weight: 600; white-space: nowrap; }
|
|
68
|
-
.meta td { padding: 2px 0; color: #374151; }
|
|
69
|
-
@media only screen and (max-width: 620px) {
|
|
70
|
-
.container { width: 100% !important; }
|
|
71
|
-
.gutter { padding-left: 20px !important; padding-right: 20px !important; }
|
|
72
|
-
}`;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** Options for {@link renderEmailHtml}. */
|
|
76
|
-
export interface EmailHtmlOptions {
|
|
77
|
-
/** Markdown body. Rendered to HTML, then wrapped in the layout. */
|
|
78
|
-
body: string;
|
|
79
|
-
/** Header-band title and document `<title>`. Defaults to "Message". */
|
|
80
|
-
subject?: string;
|
|
81
|
-
/**
|
|
82
|
-
* Optional `[label, value]` envelope rows shown above the body (used
|
|
83
|
-
* by the outbox preview; omitted for SMTP sends, where the mail client
|
|
84
|
-
* shows the envelope itself).
|
|
85
|
-
*/
|
|
86
|
-
headers?: ReadonlyArray<readonly [string, string]>;
|
|
87
|
-
/** Optional small-print footer line. Omitted when unset. */
|
|
88
|
-
footer?: string;
|
|
89
|
-
/**
|
|
90
|
-
* Optional brand styling for the layout (accent, font, header logo).
|
|
91
|
-
* Omit for the neutral default palette.
|
|
92
|
-
*/
|
|
93
|
-
brand?: EmailBrand;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/** Render the optional envelope-header table block. */
|
|
97
|
-
function metaBlock(headers: EmailHtmlOptions["headers"]): string {
|
|
98
|
-
if (!headers || headers.length === 0) return "";
|
|
99
|
-
const rows = headers
|
|
100
|
-
.map(([label, value]) => `<tr><th>${escapeHtml(label)}</th><td>${escapeHtml(value)}</td></tr>`)
|
|
101
|
-
.join("");
|
|
102
|
-
return `<table role="presentation" class="meta"><tbody>${rows}</tbody></table>`;
|
|
103
|
-
}
|
|
10
|
+
/** Options accepted by the shared React Email document. */
|
|
11
|
+
export type EmailHtmlOptions = EmailDocumentProps;
|
|
104
12
|
|
|
105
|
-
/** Render
|
|
106
|
-
function
|
|
107
|
-
|
|
108
|
-
return `
|
|
109
|
-
<tr>
|
|
110
|
-
<td class="gutter" style="padding: 16px 32px; border-top: 1px solid #eaecef; color: #9aa0a6; font-size: 12px; line-height: 1.5;">
|
|
111
|
-
${escapeHtml(footer)}
|
|
112
|
-
</td>
|
|
113
|
-
</tr>`;
|
|
13
|
+
/** Render a complete responsive React Email document. */
|
|
14
|
+
export async function renderEmailHtml(options: EmailHtmlOptions): Promise<string> {
|
|
15
|
+
return render(createElement(EmailDocument, options), { pretty: true });
|
|
114
16
|
}
|
|
115
17
|
|
|
116
|
-
/**
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
* at {@link LOGO_HEIGHT_PX} and tinted implicitly by its own artwork; the
|
|
120
|
-
* title always shows so the band is never empty.
|
|
121
|
-
*/
|
|
122
|
-
function headerBand(title: string, brand: EmailBrand, onAccent: string): string {
|
|
123
|
-
const logo = brand.logoUrl
|
|
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;" />`
|
|
125
|
-
: "";
|
|
126
|
-
return `${logo}<span style="color: ${onAccent}; font-size: 18px; font-weight: 700; line-height: 1.3;">${escapeHtml(title)}</span>`;
|
|
18
|
+
/** Render the same React Email document as its plain-text alternative. */
|
|
19
|
+
export async function renderEmailText(options: EmailHtmlOptions): Promise<string> {
|
|
20
|
+
return render(createElement(EmailDocument, options), { plainText: true });
|
|
127
21
|
}
|
|
128
22
|
|
|
129
|
-
/**
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const brand: EmailBrand = opts.brand ?? { accent, onAccent, fontFamily };
|
|
140
|
-
const doc = `<!doctype html>
|
|
141
|
-
<html lang="en">
|
|
142
|
-
<head>
|
|
143
|
-
<meta charset="utf-8" />
|
|
144
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
145
|
-
<meta name="color-scheme" content="light only" />
|
|
146
|
-
<title>${escapeHtml(title)}</title>
|
|
147
|
-
<style>${contentCss(accent)}
|
|
148
|
-
</style>
|
|
149
|
-
</head>
|
|
150
|
-
<body style="margin: 0; padding: 0; background-color: #f4f5f7; -webkit-text-size-adjust: 100%;">
|
|
151
|
-
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color: #f4f5f7;">
|
|
152
|
-
<tr>
|
|
153
|
-
<td align="center" style="padding: 24px 12px;">
|
|
154
|
-
<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: ${fontFamily};">
|
|
155
|
-
<tr>
|
|
156
|
-
<td class="gutter" style="padding: 20px 32px; background-color: ${accent};">
|
|
157
|
-
${headerBand(title, brand, onAccent)}
|
|
158
|
-
</td>
|
|
159
|
-
</tr>
|
|
160
|
-
<tr>
|
|
161
|
-
<td class="gutter" style="padding: 24px 32px 8px;">
|
|
162
|
-
${metaBlock(opts.headers)}
|
|
163
|
-
<div class="email-body">${markdownToHtml(opts.body)}</div>
|
|
164
|
-
</td>
|
|
165
|
-
</tr>${footerRow(opts.footer)}
|
|
166
|
-
</table>
|
|
167
|
-
</td>
|
|
168
|
-
</tr>
|
|
169
|
-
</table>
|
|
170
|
-
</body>
|
|
171
|
-
</html>
|
|
172
|
-
`;
|
|
173
|
-
return juice(doc);
|
|
23
|
+
/** Render both MIME alternatives from one shared React Email component tree. */
|
|
24
|
+
export async function renderEmail(
|
|
25
|
+
options: EmailHtmlOptions,
|
|
26
|
+
): Promise<{ html: string; text: string }> {
|
|
27
|
+
const element = createElement(EmailDocument, options);
|
|
28
|
+
const [html, text] = await Promise.all([
|
|
29
|
+
render(element, { pretty: true }),
|
|
30
|
+
render(element, { plainText: true }),
|
|
31
|
+
]);
|
|
32
|
+
return { html, text };
|
|
174
33
|
}
|
package/src/markdown.ts
CHANGED
|
@@ -1,94 +1,14 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
import { marked } from "marked";
|
|
15
|
-
|
|
16
|
-
/** A line of only `=` or `_` (length >= 3): an ASCII divider rule. */
|
|
17
|
-
function isAsciiRule(line: string): boolean {
|
|
18
|
-
return /^[ \t]*[=_]{3,}[ \t]*$/.test(line);
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/** A line that participates in a markdown pipe table (has a `|`). */
|
|
22
|
-
function isPipeRow(line: string): boolean {
|
|
23
|
-
return line.includes("|") && line.trim().length > 0;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/** A markdown table separator row, e.g. `| --- | :--: |`. */
|
|
27
|
-
function isSeparatorRow(line: string): boolean {
|
|
28
|
-
return /^[ \t]*\|?[ \t]*:?-{2,}:?[ \t]*(\|[ \t]*:?-{2,}:?[ \t]*)+\|?[ \t]*$/.test(line);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/** Column count of a pipe row (outer pipes optional). */
|
|
32
|
-
function pipeColumns(line: string): number {
|
|
33
|
-
return line.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").length;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** A GFM separator row with `columns` cells. */
|
|
37
|
-
function separatorRow(columns: number): string {
|
|
38
|
-
return `| ${Array.from({ length: columns }, () => "---").join(" | ")} |`;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Repair common LLM "looks-like-markdown-but-isn't" output: convert
|
|
43
|
-
* standalone `=====` / `_____` rules to a `---` thematic break (leaving
|
|
44
|
-
* genuine setext-heading underlines intact), and insert a `| --- |`
|
|
45
|
-
* separator after the first row of a pipe block that lacks one so GFM
|
|
46
|
-
* renders it as a table.
|
|
47
|
-
*/
|
|
48
|
-
export function normalizeMarkdown(src: string): string {
|
|
49
|
-
const lines = src.split("\n");
|
|
50
|
-
const out: string[] = [];
|
|
51
|
-
for (let i = 0; i < lines.length; i++) {
|
|
52
|
-
const line = lines[i]!;
|
|
53
|
-
const prev = i > 0 ? lines[i - 1]! : "";
|
|
54
|
-
const next = i + 1 < lines.length ? lines[i + 1]! : "";
|
|
55
|
-
|
|
56
|
-
if (isAsciiRule(line)) {
|
|
57
|
-
// A run of `=` directly under a non-blank text line is a setext H1
|
|
58
|
-
// underline - keep it. Anything else is a decorative divider.
|
|
59
|
-
const isSetextUnderline =
|
|
60
|
-
/^[ \t]*={3,}[ \t]*$/.test(line) && prev.trim() !== "" && !isPipeRow(prev);
|
|
61
|
-
out.push(isSetextUnderline ? line : "---");
|
|
62
|
-
continue;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// First row of a pipe block (prev is not itself a pipe row) followed
|
|
66
|
-
// by another aligned pipe row, with no separator and no `###` bar-
|
|
67
|
-
// chart fill: treat as a table header and inject the separator.
|
|
68
|
-
const startsPipeBlock = isPipeRow(line) && !isPipeRow(prev);
|
|
69
|
-
if (
|
|
70
|
-
startsPipeBlock &&
|
|
71
|
-
isPipeRow(next) &&
|
|
72
|
-
!isSeparatorRow(next) &&
|
|
73
|
-
!/#{3,}/.test(line) &&
|
|
74
|
-
pipeColumns(line) >= 2 &&
|
|
75
|
-
pipeColumns(line) === pipeColumns(next)
|
|
76
|
-
) {
|
|
77
|
-
out.push(line);
|
|
78
|
-
out.push(separatorRow(pipeColumns(line)));
|
|
79
|
-
continue;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
out.push(line);
|
|
83
|
-
}
|
|
84
|
-
return out.join("\n");
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** Render a markdown body to an HTML fragment (GFM tables enabled). */
|
|
88
|
-
export function markdownToHtml(body: string): string {
|
|
89
|
-
return marked.parse(normalizeMarkdown(body), {
|
|
90
|
-
async: false,
|
|
91
|
-
gfm: true,
|
|
92
|
-
breaks: true,
|
|
1
|
+
/** Compatibility helpers backed by the shared React Email body. @module */
|
|
2
|
+
import { EmailBody, normalizeEmailMarkdown } from "@dbx-tools/shared-email-template";
|
|
3
|
+
import { render } from "@react-email/render";
|
|
4
|
+
import { createElement } from "react";
|
|
5
|
+
|
|
6
|
+
/** Normalize indentation in authored content. */
|
|
7
|
+
export const normalizeMarkdown = normalizeEmailMarkdown;
|
|
8
|
+
|
|
9
|
+
/** Render a standalone message body through React Email. */
|
|
10
|
+
export async function markdownToHtml(body: string): Promise<string> {
|
|
11
|
+
return render(createElement(EmailBody, { body: normalizeEmailMarkdown(body) }), {
|
|
12
|
+
pretty: true,
|
|
93
13
|
});
|
|
94
14
|
}
|
package/src/outbox.ts
CHANGED
|
@@ -59,7 +59,7 @@ export async function writeOutboxEmail(
|
|
|
59
59
|
const folder = resolve(dir, from);
|
|
60
60
|
await mkdir(folder, { recursive: true });
|
|
61
61
|
const path = join(folder, `${Date.now()}-${subjectSlug(message.subject)}.html`);
|
|
62
|
-
const html = renderEmailHtml({
|
|
62
|
+
const html = await renderEmailHtml({
|
|
63
63
|
subject: message.subject,
|
|
64
64
|
headers: headerRows(message, from),
|
|
65
65
|
body: message.body,
|
package/src/plugin.ts
CHANGED
|
@@ -45,7 +45,7 @@ import {
|
|
|
45
45
|
type ToolProvider,
|
|
46
46
|
type ToolRegistry,
|
|
47
47
|
} from "@databricks/appkit/beta";
|
|
48
|
-
import { log } from "@dbx-tools/shared-core";
|
|
48
|
+
import { log, string, token } from "@dbx-tools/shared-core";
|
|
49
49
|
import {
|
|
50
50
|
email as emailWire,
|
|
51
51
|
type EmailMessage,
|
|
@@ -203,6 +203,20 @@ export class EmailPlugin extends Plugin<EmailPluginConfig> implements ToolProvid
|
|
|
203
203
|
* plugin base path, i.e. `GET /api/email/senders`. Runs in the OBO
|
|
204
204
|
* user scope so domain wildcards resolve against the caller's own
|
|
205
205
|
* local part.
|
|
206
|
+
*
|
|
207
|
+
* OBO is used only WHEN the request can support it. `asUser(req)` throws
|
|
208
|
+
* `AuthenticationError` outside `NODE_ENV=development` if the request carries
|
|
209
|
+
* no forwarded OBO token, and AppKit does not catch a rejection raised inside
|
|
210
|
+
* a handler - so unconditionally wrapping this route takes the process down
|
|
211
|
+
* for a caller that authenticated some other way (a `@dbx-tools/cli-tunnel`
|
|
212
|
+
* OTP session, a health probe, a local `curl`). The user context is only ever
|
|
213
|
+
* an ENRICHMENT here: without it, wildcard senders simply expand against no
|
|
214
|
+
* local part. Degrading to the service context therefore answers correctly
|
|
215
|
+
* instead of failing, and a front-door request is unchanged.
|
|
216
|
+
*
|
|
217
|
+
* This is the same rule `@dbx-tools/appkit`'s `identity` module applies in
|
|
218
|
+
* `"auto"` mode; the header check is inlined rather than taking a dependency
|
|
219
|
+
* on that package for one predicate.
|
|
206
220
|
*/
|
|
207
221
|
override injectRoutes(router: IAppRouter): void {
|
|
208
222
|
this.route(router, {
|
|
@@ -210,7 +224,9 @@ export class EmailPlugin extends Plugin<EmailPluginConfig> implements ToolProvid
|
|
|
210
224
|
method: "get",
|
|
211
225
|
path: SENDERS_ROUTE,
|
|
212
226
|
handler: async (req, res) => {
|
|
213
|
-
const
|
|
227
|
+
const oboToken = string.trimToNull(req.header(token.ACCESS_TOKEN_HEADER));
|
|
228
|
+
const scoped = oboToken === null ? this : this.asUser(req);
|
|
229
|
+
const result = await scoped.executeListSenders();
|
|
214
230
|
if (!result.ok) {
|
|
215
231
|
res.status(result.status).json({ error: result.message });
|
|
216
232
|
return;
|
package/src/tool.ts
CHANGED
|
@@ -29,7 +29,7 @@ const logger = log.logger("email/tool/send-email");
|
|
|
29
29
|
/**
|
|
30
30
|
* The model-facing description of the send capability, shared by the Mastra
|
|
31
31
|
* {@link emailTool} and the AppKit `email.send` tool so both agents get the
|
|
32
|
-
* same guidance about approval
|
|
32
|
+
* same guidance about approval and scope.
|
|
33
33
|
*/
|
|
34
34
|
export const SEND_EMAIL_DESCRIPTION = string.toDescription(`
|
|
35
35
|
Send an email on the user's behalf. Pass one or more recipient
|
|
@@ -37,11 +37,9 @@ export const SEND_EMAIL_DESCRIPTION = string.toDescription(`
|
|
|
37
37
|
and a body; the user is prompted to approve the send before it goes
|
|
38
38
|
out (this tool is approval-gated). Use it only when the user
|
|
39
39
|
explicitly asks to send / forward / share something via email -
|
|
40
|
-
never autonomously.
|
|
41
|
-
the
|
|
42
|
-
|
|
43
|
-
tables - not ASCII art (no "=====" dividers or space/pipe-drawn
|
|
44
|
-
tables); it is rendered to HTML before sending.
|
|
40
|
+
never autonomously. Compose whatever subject and body best fulfill
|
|
41
|
+
the user's request; the configured React Email template handles the
|
|
42
|
+
responsive layout, rich presentation, and brand styling.
|
|
45
43
|
`);
|
|
46
44
|
|
|
47
45
|
/** Options accepted by {@link emailTool}. */
|
package/src/transport.ts
CHANGED
|
@@ -23,13 +23,12 @@
|
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
import {
|
|
26
|
-
AppKitError,
|
|
27
26
|
ConfigurationError,
|
|
28
27
|
ExecutionError,
|
|
29
28
|
ValidationError,
|
|
30
29
|
type ExecutionResult,
|
|
31
30
|
} from "@databricks/appkit";
|
|
32
|
-
import {
|
|
31
|
+
import { execution, log } from "@dbx-tools/shared-core";
|
|
33
32
|
import type { EmailAttachment, EmailMessage, EmailResult } from "@dbx-tools/shared-email";
|
|
34
33
|
import nodemailer, { type SendMailOptions, type Transporter } from "nodemailer";
|
|
35
34
|
import { resolveEmailConfig, type EmailPluginConfig, type ResolvedEmailConfig } from "./config.ts";
|
|
@@ -41,7 +40,7 @@ import {
|
|
|
41
40
|
MAX_BODY_CHARS,
|
|
42
41
|
type EmailExecutionSettings,
|
|
43
42
|
} from "./defaults.ts";
|
|
44
|
-
import {
|
|
43
|
+
import { renderEmail } from "./email-html.ts";
|
|
45
44
|
import { writeOutboxEmail } from "./outbox.ts";
|
|
46
45
|
import { assertSenderAllowed } from "./sender.ts";
|
|
47
46
|
|
|
@@ -70,17 +69,7 @@ export interface EmailRuntime {
|
|
|
70
69
|
* directly, mapping a throw onto the same {@link ExecutionResult} shape so
|
|
71
70
|
* call sites branch on `ok` either way.
|
|
72
71
|
*/
|
|
73
|
-
const directExecute
|
|
74
|
-
try {
|
|
75
|
-
return { ok: true, data: await fn() };
|
|
76
|
-
} catch (err) {
|
|
77
|
-
return {
|
|
78
|
-
ok: false,
|
|
79
|
-
status: err instanceof AppKitError ? err.statusCode : 500,
|
|
80
|
-
message: error.errorMessage(err),
|
|
81
|
-
};
|
|
82
|
-
}
|
|
83
|
-
};
|
|
72
|
+
const directExecute = execution.directExecutor<EmailExecutionSettings>();
|
|
84
73
|
|
|
85
74
|
let runtime: EmailRuntime | undefined;
|
|
86
75
|
|
|
@@ -157,20 +146,23 @@ export async function executeWrite<T>(
|
|
|
157
146
|
signal?: AbortSignal,
|
|
158
147
|
): Promise<T> {
|
|
159
148
|
const { execute } = getEmailRuntime();
|
|
160
|
-
|
|
161
|
-
(executeSignal) => fn(async.combineAbortSignals(executeSignal, signal)),
|
|
162
|
-
settings,
|
|
163
|
-
);
|
|
164
|
-
if (result.ok) return result.data;
|
|
165
|
-
// A caller that cancelled is not a failure worth reporting as one.
|
|
166
|
-
if (signal?.aborted) throw ExecutionError.canceled();
|
|
167
|
-
logger.warn("execution-failed", {
|
|
149
|
+
return execution.run({
|
|
168
150
|
operation,
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
151
|
+
settings,
|
|
152
|
+
execute,
|
|
153
|
+
fn,
|
|
154
|
+
signal,
|
|
155
|
+
canceled: ExecutionError.canceled,
|
|
156
|
+
failed: (failure) => {
|
|
157
|
+
logger.warn("execution-failed", {
|
|
158
|
+
operation: failure.operation,
|
|
159
|
+
status: failure.status,
|
|
160
|
+
error: failure.message,
|
|
161
|
+
});
|
|
162
|
+
return new ExecutionError(`email: ${failure.operation} failed`, {
|
|
163
|
+
context: { operation: failure.operation, status: failure.status },
|
|
164
|
+
});
|
|
165
|
+
},
|
|
174
166
|
});
|
|
175
167
|
}
|
|
176
168
|
|
|
@@ -340,17 +332,18 @@ async function dispatch(
|
|
|
340
332
|
);
|
|
341
333
|
}
|
|
342
334
|
const attachments = toMailAttachments(message.attachments);
|
|
335
|
+
const rendered = await renderEmail({
|
|
336
|
+
subject: message.subject,
|
|
337
|
+
body: message.body,
|
|
338
|
+
brand: config.brand,
|
|
339
|
+
});
|
|
343
340
|
const info = await abortable(
|
|
344
341
|
transporter.sendMail({
|
|
345
342
|
from,
|
|
346
343
|
to: message.to,
|
|
347
344
|
subject: message.subject,
|
|
348
|
-
text:
|
|
349
|
-
html:
|
|
350
|
-
subject: message.subject,
|
|
351
|
-
body: message.body,
|
|
352
|
-
...(config.brand ? { brand: config.brand } : {}),
|
|
353
|
-
}),
|
|
345
|
+
text: rendered.text,
|
|
346
|
+
html: rendered.html,
|
|
354
347
|
...(message.cc && message.cc.length > 0 ? { cc: message.cc } : {}),
|
|
355
348
|
...(message.bcc && message.bcc.length > 0 ? { bcc: message.bcc } : {}),
|
|
356
349
|
...(attachments ? { attachments } : {}),
|
|
@@ -369,8 +362,8 @@ async function dispatch(
|
|
|
369
362
|
* Send (SMTP mode) or persist (file/outbox mode) one message from the
|
|
370
363
|
* resolved `from` address. `to` (and optional `cc` / `bcc`) each accept
|
|
371
364
|
* one or more addresses, and `attachments` are forwarded as files. The
|
|
372
|
-
* body is
|
|
373
|
-
*
|
|
365
|
+
* body is rendered by React Email into matching plain-text and HTML MIME
|
|
366
|
+
* alternatives, and the outbox embeds the
|
|
374
367
|
* rendered HTML in a document. In file mode the returned `messageId` is
|
|
375
368
|
* the path written. Throws when `to` carries no recipient, when the body
|
|
376
369
|
* or attachments exceed the plugin's caps, or when `from` is not permitted
|