@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 +2 -0
- package/package.json +3 -3
- package/src/brand.ts +64 -0
- package/src/config.ts +12 -1
- package/src/email-html.ts +46 -10
- package/src/outbox.ts +3 -0
- package/src/transport.ts +6 -2
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.
|
|
23
|
-
"@dbx-tools/shared-email": "0.3.
|
|
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.
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
23
|
-
const
|
|
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
|
-
|
|
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: ${
|
|
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
|
|
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>${
|
|
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:
|
|
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: ${
|
|
115
|
-
|
|
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({
|
|
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 } : {}),
|