@dbx-tools/email 0.6.45 → 0.6.48

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/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 juice from "juice";
25
- import type { EmailBrand } from "./brand.ts";
26
- import { markdownToHtml } from "./markdown.ts";
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
- /** Neutral fallback styling when no brand is supplied. */
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
- * Content stylesheet inlined onto the markdown body (juice maps these
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 the optional footer row. */
106
- function footerRow(footer: string | undefined): string {
107
- if (!footer) return "";
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
- * Render the header-band content: the brand logo (when the brand supplies a
118
- * renderable image) above the title, or just the title. The logo is capped
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
- * Render `body` (markdown) into a complete, style-inlined email document
131
- * using the responsive layout. When `opts.brand` is set its accent, font,
132
- * and logo style the layout; otherwise a neutral default palette is used.
133
- */
134
- export function renderEmailHtml(opts: EmailHtmlOptions): string {
135
- const title = opts.subject?.trim() || "Message";
136
- const accent = opts.brand?.accent ?? DEFAULT_BRAND.accent;
137
- const onAccent = opts.brand?.onAccent ?? DEFAULT_BRAND.onAccent;
138
- const fontFamily = opts.brand?.fontFamily ?? DEFAULT_BRAND.fontFamily;
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
- * Server-side markdown -> HTML rendering for email bodies. The model
3
- * drafts bodies in markdown; this turns them into real HTML (GFM tables,
4
- * lists, code, links). {@link normalizeMarkdown} first repairs the two
5
- * structures LLMs most often emit as plain text instead of markdown -
6
- * `=====` divider rules and pipe tables missing their `| --- |`
7
- * separator row - so they render as a `<hr>` / `<table>` rather than
8
- * literal text. The prompt steers the model away from ASCII art; this is
9
- * the belt-and-suspenders fallback.
10
- *
11
- * @module
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/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, scope, and body formatting.
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. 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.
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 { async, error, log } from "@dbx-tools/shared-core";
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 { renderEmailHtml } from "./email-html.ts";
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: EmailExecutor = async (fn) => {
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
- const result = await execute(
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
- status: result.status,
170
- error: result.message,
171
- });
172
- throw new ExecutionError(`email: ${operation} failed`, {
173
- context: { operation, status: result.status },
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: message.body,
349
- html: renderEmailHtml({
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 markdown: SMTP sends it as both a plain-text part (the raw
373
- * source) and an HTML part (rendered), and the outbox embeds the
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
package/src/juice.d.ts DELETED
@@ -1,17 +0,0 @@
1
- // Minimal ambient types for `juice` (no `@types/juice` is published).
2
- // We only use the default export: inline a stylesheet found in the
3
- // document's <style> tags into element `style` attributes.
4
- declare module "juice" {
5
- interface JuiceOptions {
6
- applyStyleTags?: boolean;
7
- removeStyleTags?: boolean;
8
- preserveImportant?: boolean;
9
- preserveMediaQueries?: boolean;
10
- preserveFontFaces?: boolean;
11
- inlinePseudoElements?: boolean;
12
- xmlMode?: boolean;
13
- [option: string]: unknown;
14
- }
15
- function juice(html: string, options?: JuiceOptions): string;
16
- export = juice;
17
- }