@dbx-tools/email 0.3.18 → 0.3.19

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/index.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  // Regenerated from the exporting modules in ./src.
3
3
  // Hand edits are overwritten on the next watch; this file is read-only.
4
4
 
5
+ export * as brand from "./src/brand";
5
6
  export * as config from "./src/config";
6
7
  export * as emailHtml from "./src/email-html";
7
8
  export * as markdown from "./src/markdown";
@@ -10,6 +11,7 @@ export * as plugin from "./src/plugin";
10
11
  export * as sender from "./src/sender";
11
12
  export * as tool from "./src/tool";
12
13
  export * as transport from "./src/transport";
14
+ export type { EmailBrand } from "./src/brand";
13
15
  export type { SmtpConfig, EmailPluginConfig, ResolvedSmtpConfig, ResolvedFileConfig, ResolvedEmailConfig } from "./src/config";
14
16
  export type { EmailHtmlOptions } from "./src/email-html";
15
17
  export type { EmailToolOptions } from "./src/tool";
package/package.json CHANGED
@@ -19,15 +19,15 @@
19
19
  "juice": "^12.1.1",
20
20
  "marked": "^18.0.5",
21
21
  "nodemailer": "^7.0.13",
22
- "@dbx-tools/shared-core": "0.3.18",
23
- "@dbx-tools/shared-email": "0.3.18"
22
+ "@dbx-tools/shared-core": "0.3.19",
23
+ "@dbx-tools/shared-email": "0.3.19"
24
24
  },
25
25
  "main": "index.ts",
26
26
  "license": "UNLICENSED",
27
27
  "publishConfig": {
28
28
  "access": "public"
29
29
  },
30
- "version": "0.3.18",
30
+ "version": "0.3.19",
31
31
  "types": "index.ts",
32
32
  "type": "module",
33
33
  "exports": {
package/src/brand.ts ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Optional brand styling for outbound email. {@link EmailBrand} is the
3
+ * small, email-safe slice of a brand a message actually needs - an accent
4
+ * color, a font stack, a display name, and an optional logo image - and
5
+ * {@link emailBrandFromContext} derives it from the portable `BrandContext`
6
+ * shared across the UI and libraries.
7
+ *
8
+ * Email can't use the `[data-brand]` CSS bridge the browser UI uses (mail
9
+ * clients strip `<style>` blocks and ignore `var()`), so branding is applied
10
+ * by inlining these values at render time. A logo is only emitted when it's
11
+ * a fetchable `http(s):` or `data:` URL - a package-export path (the default
12
+ * asset form) can't resolve in an inbox, so it's dropped rather than shown
13
+ * as a broken image.
14
+ *
15
+ * @module
16
+ */
17
+ import { brand, type BrandContext } from "@dbx-tools/shared-core";
18
+
19
+ /** Email-safe brand values inlined into the message layout at render time. */
20
+ export interface EmailBrand {
21
+ /** Header-band background and link color. */
22
+ accent: string;
23
+ /** Text and logo color rendered on the accent band. Defaults to white. */
24
+ onAccent?: string;
25
+ /** Body font stack. */
26
+ fontFamily: string;
27
+ /** Product/display name, used as the header text and the logo `alt`. */
28
+ name?: string;
29
+ /**
30
+ * Logo image rendered in the header band. Only an `http(s):` or `data:`
31
+ * URL renders; other values (e.g. a package-export path) are ignored,
32
+ * since they can't load in a mail client.
33
+ */
34
+ logoUrl?: string;
35
+ }
36
+
37
+ /** Whether `value` is an image reference a mail client can actually load. */
38
+ function isRenderableImageUrl(value: string | undefined): value is string {
39
+ return typeof value === "string" && /^(?:https?:|data:)/i.test(value);
40
+ }
41
+
42
+ /**
43
+ * Derive the email-safe {@link EmailBrand} from a full brand context: the
44
+ * primary color as the accent, the sans font stack, the display name, and
45
+ * the dark-surface logo (the header band is dark) when it's a renderable URL.
46
+ */
47
+ export function emailBrandFromContext(context: BrandContext): EmailBrand {
48
+ const logo = context.assets.logo.dark ?? context.assets.logo.light;
49
+ return {
50
+ accent: context.colors.primary,
51
+ onAccent: "#ffffff",
52
+ fontFamily: context.typography.sans,
53
+ name: context.name,
54
+ ...(isRenderableImageUrl(logo) ? { logoUrl: logo } : {}),
55
+ };
56
+ }
57
+
58
+ /**
59
+ * The dbx-tools brand as an {@link EmailBrand}, ready to pass to the email
60
+ * plugin (`email({ brand: defaultEmailBrand })`). Convenience so a consumer
61
+ * needs only `@dbx-tools/email`, not the shared brand context, for the
62
+ * common case of the default brand.
63
+ */
64
+ export const defaultEmailBrand: EmailBrand = emailBrandFromContext(brand.defaultBrandContext);
package/src/config.ts CHANGED
@@ -26,6 +26,7 @@ import type { BasePluginConfig } from "@databricks/appkit";
26
26
  import type { JSONSchema7 } from "json-schema";
27
27
  import { object } from "@dbx-tools/shared-core";
28
28
  import { resolve } from "node:path";
29
+ import type { EmailBrand } from "./brand";
29
30
  import { parseAllowedSenders } from "./sender";
30
31
 
31
32
  /** SMTP connection + credentials. All fields fall back to env when unset. */
@@ -74,9 +75,16 @@ export interface EmailPluginConfig extends BasePluginConfig {
74
75
  * `EMAIL_ALLOWED_SENDERS`. Omit (or leave empty) for no restriction.
75
76
  */
76
77
  allowedSenders?: string | string[];
78
+ /**
79
+ * Optional brand styling (accent, font, header logo) applied to the
80
+ * rendered HTML of every message. Omit for the neutral default layout.
81
+ * Pass {@link emailBrandFromContext} to derive it from a shared
82
+ * `BrandContext`.
83
+ */
84
+ brand?: EmailBrand;
77
85
  }
78
86
 
79
- /** Sender source shared by both resolved modes. */
87
+ /** Config shared by both resolved modes. */
80
88
  interface ResolvedSender {
81
89
  /** Sender domain; present whenever {@link from} is absent. */
82
90
  domain?: string;
@@ -87,6 +95,8 @@ interface ResolvedSender {
87
95
  * Empty means no restriction.
88
96
  */
89
97
  allowedSenders: string[];
98
+ /** Brand styling applied to rendered HTML; absent for the default layout. */
99
+ brand?: EmailBrand;
90
100
  }
91
101
 
92
102
  /** Resolved config for real SMTP delivery. */
@@ -199,6 +209,7 @@ export function resolveEmailConfig(config: EmailPluginConfig = {}): ResolvedEmai
199
209
  ...(domain ? { domain } : {}),
200
210
  ...(from ? { from } : {}),
201
211
  allowedSenders,
212
+ ...(config.brand ? { brand: config.brand } : {}),
202
213
  };
203
214
 
204
215
  const hasAllSmtp = Boolean(host && user && pass);
package/src/email-html.ts CHANGED
@@ -12,15 +12,25 @@
12
12
  * renderer feeds both the local outbox preview and the SMTP HTML part,
13
13
  * so a browser and an inbox show the same thing.
14
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
+ *
15
20
  * @module
16
21
  */
17
22
 
18
23
  import { string } from "@dbx-tools/shared-core";
19
24
  import juice from "juice";
25
+ import type { EmailBrand } from "./brand";
20
26
  import { markdownToHtml } from "./markdown";
21
27
 
22
- /** Accent color for the header band and links. */
23
- const ACCENT = "#0b6bcb";
28
+ /** Neutral fallback styling when no brand is supplied. */
29
+ const DEFAULT_BRAND: Required<Pick<EmailBrand, "accent" | "onAccent" | "fontFamily">> = {
30
+ accent: "#0b6bcb",
31
+ onAccent: "#ffffff",
32
+ fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif",
33
+ };
24
34
 
25
35
  /** Escape HTML-significant characters (re-exported from `@dbx-tools/shared`). */
26
36
  export const escapeHtml = string.escapeHtml;
@@ -29,12 +39,14 @@ export const escapeHtml = string.escapeHtml;
29
39
  * Content stylesheet inlined onto the markdown body (juice maps these
30
40
  * onto elements). Outer layout styling is written inline directly so it
31
41
  * survives even if inlining is skipped; the `@media` rule is preserved
32
- * by juice for clients that honor it.
42
+ * by juice for clients that honor it. Parameterized by the resolved accent
43
+ * so body links match the brand.
33
44
  */
34
- const CONTENT_CSS = `
45
+ function contentCss(accent: string): string {
46
+ return `
35
47
  .email-body { font-size: 15px; line-height: 1.55; color: #1a1a1a; }
36
48
  .email-body p { margin: 0 0 1rem; }
37
- .email-body a { color: ${ACCENT}; }
49
+ .email-body a { color: ${accent}; }
38
50
  .email-body h1, .email-body h2, .email-body h3 { margin: 1.4rem 0 0.6rem; line-height: 1.25; }
39
51
  .email-body ul, .email-body ol { margin: 0 0 1rem; padding-left: 1.4rem; }
40
52
  .email-body table { border-collapse: collapse; margin: 1rem 0; width: 100%; font-size: 14px; }
@@ -52,6 +64,7 @@ const CONTENT_CSS = `
52
64
  .container { width: 100% !important; }
53
65
  .gutter { padding-left: 20px !important; padding-right: 20px !important; }
54
66
  }`;
67
+ }
55
68
 
56
69
  /** Options for {@link renderEmailHtml}. */
57
70
  export interface EmailHtmlOptions {
@@ -67,6 +80,11 @@ export interface EmailHtmlOptions {
67
80
  headers?: ReadonlyArray<readonly [string, string]>;
68
81
  /** Optional small-print footer line. Omitted when unset. */
69
82
  footer?: string;
83
+ /**
84
+ * Optional brand styling for the layout (accent, font, header logo).
85
+ * Omit for the neutral default palette.
86
+ */
87
+ brand?: EmailBrand;
70
88
  }
71
89
 
72
90
  /** Render the optional envelope-header table block. */
@@ -89,12 +107,30 @@ function footerRow(footer: string | undefined): string {
89
107
  </tr>`;
90
108
  }
91
109
 
110
+ /**
111
+ * Render the header-band content: the brand logo (when the brand supplies a
112
+ * 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.
115
+ */
116
+ function headerBand(title: string, brand: EmailBrand, onAccent: string): string {
117
+ 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;" />`
119
+ : "";
120
+ return `${logo}<span style="color: ${onAccent}; font-size: 18px; font-weight: 700; line-height: 1.3;">${escapeHtml(title)}</span>`;
121
+ }
122
+
92
123
  /**
93
124
  * Render `body` (markdown) into a complete, style-inlined email document
94
- * using the branded responsive layout.
125
+ * using the responsive layout. When `opts.brand` is set its accent, font,
126
+ * and logo style the layout; otherwise a neutral default palette is used.
95
127
  */
96
128
  export function renderEmailHtml(opts: EmailHtmlOptions): string {
97
129
  const title = opts.subject?.trim() || "Message";
130
+ const accent = opts.brand?.accent ?? DEFAULT_BRAND.accent;
131
+ const onAccent = opts.brand?.onAccent ?? DEFAULT_BRAND.onAccent;
132
+ const fontFamily = opts.brand?.fontFamily ?? DEFAULT_BRAND.fontFamily;
133
+ const brand: EmailBrand = opts.brand ?? { accent, onAccent, fontFamily };
98
134
  const doc = `<!doctype html>
99
135
  <html lang="en">
100
136
  <head>
@@ -102,17 +138,17 @@ export function renderEmailHtml(opts: EmailHtmlOptions): string {
102
138
  <meta name="viewport" content="width=device-width, initial-scale=1" />
103
139
  <meta name="color-scheme" content="light only" />
104
140
  <title>${escapeHtml(title)}</title>
105
- <style>${CONTENT_CSS}
141
+ <style>${contentCss(accent)}
106
142
  </style>
107
143
  </head>
108
144
  <body style="margin: 0; padding: 0; background-color: #f4f5f7; -webkit-text-size-adjust: 100%;">
109
145
  <table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color: #f4f5f7;">
110
146
  <tr>
111
147
  <td align="center" style="padding: 24px 12px;">
112
- <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: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;">
148
+ <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};">
113
149
  <tr>
114
- <td class="gutter" style="padding: 20px 32px; background-color: ${ACCENT};">
115
- <span style="color: #ffffff; font-size: 18px; font-weight: 700; line-height: 1.3;">${escapeHtml(title)}</span>
150
+ <td class="gutter" style="padding: 20px 32px; background-color: ${accent};">
151
+ ${headerBand(title, brand, onAccent)}
116
152
  </td>
117
153
  </tr>
118
154
  <tr>
package/src/outbox.ts CHANGED
@@ -17,6 +17,7 @@ import type { EmailMessage } from "@dbx-tools/shared-email";
17
17
  import { string } from "@dbx-tools/shared-core";
18
18
  import { mkdir, writeFile } from "node:fs/promises";
19
19
  import { join, resolve } from "node:path";
20
+ import type { EmailBrand } from "./brand";
20
21
  import { renderEmailHtml } from "./email-html";
21
22
 
22
23
  /** Filesystem-safe slug of the subject for the file name. */
@@ -47,6 +48,7 @@ export async function writeOutboxEmail(
47
48
  message: EmailMessage,
48
49
  from: string,
49
50
  dir: string,
51
+ brand?: EmailBrand,
50
52
  ): Promise<string> {
51
53
  const folder = resolve(dir, from);
52
54
  await mkdir(folder, { recursive: true });
@@ -56,6 +58,7 @@ export async function writeOutboxEmail(
56
58
  headers: headerRows(message, from),
57
59
  body: message.body,
58
60
  footer: "Local outbox preview - written to disk, not sent (no SMTP credentials configured).",
61
+ ...(brand ? { brand } : {}),
59
62
  });
60
63
  await writeFile(path, html, "utf8");
61
64
  return path;
package/src/transport.ts CHANGED
@@ -104,7 +104,7 @@ export async function sendEmail(message: EmailMessage, from: string): Promise<Em
104
104
  const recipient = recipientEcho(message.to);
105
105
 
106
106
  if (config.mode === "file") {
107
- const path = await writeOutboxEmail(message, from, config.outDir);
107
+ const path = await writeOutboxEmail(message, from, config.outDir, config.brand);
108
108
  return { sent: true, recipient, from, messageId: path };
109
109
  }
110
110
 
@@ -115,7 +115,11 @@ export async function sendEmail(message: EmailMessage, from: string): Promise<Em
115
115
  to: message.to,
116
116
  subject: message.subject,
117
117
  text: message.body,
118
- html: renderEmailHtml({ subject: message.subject, body: message.body }),
118
+ html: renderEmailHtml({
119
+ subject: message.subject,
120
+ body: message.body,
121
+ ...(config.brand ? { brand: config.brand } : {}),
122
+ }),
119
123
  ...(message.cc && message.cc.length > 0 ? { cc: message.cc } : {}),
120
124
  ...(message.bcc && message.bcc.length > 0 ? { bcc: message.bcc } : {}),
121
125
  ...(attachments ? { attachments } : {}),