@cosmicdrift/kumiko-renderer-mail-html 0.364.0 → 0.365.1
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/dist/default-layout.d.ts +1 -1
- package/dist/default-layout.js +9 -5
- package/dist/document-kit.d.ts +3 -0
- package/dist/document-kit.js +20 -2
- package/dist/feature.d.ts +2 -1
- package/dist/feature.js +9 -4
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -2
- package/dist/mail-chrome.d.ts +16 -0
- package/dist/mail-chrome.js +63 -0
- package/dist/mail-footer.d.ts +29 -0
- package/dist/mail-footer.js +73 -0
- package/dist/render.d.ts +11 -1
- package/dist/render.js +150 -26
- package/dist/structured-layout.d.ts +14 -1
- package/dist/structured-layout.js +52 -17
- package/package.json +6 -6
- package/src/changes.json +24 -0
package/dist/default-layout.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export { DEFAULT_MAIL_LAYOUT_SLUG } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
|
|
2
|
-
export declare const DEFAULT_MAIL_LAYOUT_MJML = "<mjml>\n <mj-head>\n <mj-title>{{subject}}</mj-title>\n <mj-preview>{{previewText}}</mj-preview>\n <mj-attributes>\n <mj-all font-family=\"Helvetica, Arial, sans-serif\" />\n <mj-text font-size=\"14px\" color=\"#333\" line-height=\"1.6\" />\n </mj-attributes>\n </mj-head>\n <mj-body background-color=\"#f5f5f5\">\n <mj-section background-color=\"#ffffff\" padding=\"20px\">\n <mj-column>\n <mj-text font-size=\"22px\" font-weight=\"bold\" color=\"#111\">{{tenantName}}</mj-text>\n </mj-column>\n </mj-section>\n <mj-section background-color=\"#ffffff\" padding=\"20px\">\n <mj-column>\n <mj-text>{{{content}}}</mj-text>\n </mj-column>\n </mj-section>\n <mj-section background-color=\"#f5f5f5\" padding=\"20px\">\n <mj-column>\n <mj-text font-size=\"11px\" color=\"#888\" align=\"center\">\n {{#if tenantName}}Sent by {{tenantName}}{{/if}}{{#if
|
|
2
|
+
export declare const DEFAULT_MAIL_LAYOUT_MJML = "<mjml lang=\"{{lang}}\">\n <mj-head>\n <mj-title>{{subject}}</mj-title>\n <mj-preview>{{previewText}}</mj-preview>\n <mj-attributes>\n <mj-all font-family=\"Helvetica, Arial, sans-serif\" />\n <mj-text font-size=\"14px\" color=\"#333\" line-height=\"1.6\" />\n </mj-attributes>\n </mj-head>\n <mj-body background-color=\"#f5f5f5\">\n <mj-section background-color=\"#ffffff\" padding=\"20px\">\n <mj-column>\n <mj-text font-size=\"22px\" font-weight=\"bold\" color=\"#111\">{{tenantName}}</mj-text>\n </mj-column>\n </mj-section>\n <mj-section background-color=\"#ffffff\" padding=\"20px\">\n <mj-column>\n <mj-text>{{{content}}}</mj-text>\n </mj-column>\n </mj-section>\n <mj-section background-color=\"#f5f5f5\" padding=\"20px\">\n <mj-column>\n <mj-text font-size=\"11px\" color=\"#888\" align=\"center\">\n {{#if tenantName}}<p>Sent by {{tenantName}}</p>{{/if}}{{#if mailFooterHtml}}<p>{{{mailFooterHtml}}}</p>{{/if}}\n </mj-text>\n </mj-column>\n </mj-section>\n </mj-body>\n</mjml>";
|
package/dist/default-layout.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// Default MJML layout that apps can
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// Default MJML layout that apps can seed as their editable system layout
|
|
2
|
+
// (seedSystemMailLayout). Without any layout row the renderer uses an in-code
|
|
3
|
+
// structured layout instead, which also shows the branding logo and accent.
|
|
4
4
|
//
|
|
5
5
|
// **Variables the layout receives (nothing else):**
|
|
6
6
|
// - {{tenantName}} - header + footer
|
|
@@ -11,13 +11,17 @@
|
|
|
11
11
|
// - {{subject}} - mj-title
|
|
12
12
|
// - {{previewText}} - inbox preview line
|
|
13
13
|
// - {{unsubscribeUrl}} - optional footer link
|
|
14
|
+
// - {{lang}} - the mail locale for <mjml lang>
|
|
15
|
+
// - {{imprint}} - imprint URL from the app's footer resolver
|
|
16
|
+
// - {{{mailFooterHtml}}} - escaped footer line (resolver text, imprint, privacy,
|
|
17
|
+
// unsubscribe link), built in code
|
|
14
18
|
//
|
|
15
19
|
// **Tenant override:** upsert a template of kind "mail-layout", contentFormat
|
|
16
20
|
// "html" (structured: data-content slot plus header/signature/footer regions),
|
|
17
21
|
// slug "default" (or the slug a mail names in meta.layoutSlug). Raw mjml is
|
|
18
22
|
// system-only.
|
|
19
23
|
export { DEFAULT_MAIL_LAYOUT_SLUG } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
|
|
20
|
-
export const DEFAULT_MAIL_LAYOUT_MJML = `<mjml>
|
|
24
|
+
export const DEFAULT_MAIL_LAYOUT_MJML = `<mjml lang="{{lang}}">
|
|
21
25
|
<mj-head>
|
|
22
26
|
<mj-title>{{subject}}</mj-title>
|
|
23
27
|
<mj-preview>{{previewText}}</mj-preview>
|
|
@@ -40,7 +44,7 @@ export const DEFAULT_MAIL_LAYOUT_MJML = `<mjml>
|
|
|
40
44
|
<mj-section background-color="#f5f5f5" padding="20px">
|
|
41
45
|
<mj-column>
|
|
42
46
|
<mj-text font-size="11px" color="#888" align="center">
|
|
43
|
-
{{#if tenantName}}Sent by {{tenantName}}{{/if}}{{#if
|
|
47
|
+
{{#if tenantName}}<p>Sent by {{tenantName}}</p>{{/if}}{{#if mailFooterHtml}}<p>{{{mailFooterHtml}}}</p>{{/if}}
|
|
44
48
|
</mj-text>
|
|
45
49
|
</mj-column>
|
|
46
50
|
</mj-section>
|
package/dist/document-kit.d.ts
CHANGED
|
@@ -12,6 +12,9 @@ export declare function compileDocumentSource(hbs: typeof Handlebars, source: st
|
|
|
12
12
|
export declare function readDocumentFormat(ctx: RendererContext, templateLocale: string): Promise<DocumentFormat>;
|
|
13
13
|
export declare function readTenantName(ctx: RendererContext): Promise<string>;
|
|
14
14
|
export declare function readBrandingAccentColor(ctx: RendererContext): Promise<string | undefined>;
|
|
15
|
+
export declare function readBrandingSiteUrl(ctx: RendererContext): Promise<string | undefined>;
|
|
16
|
+
export declare const LOGO_RESOURCE_KEY = "logo";
|
|
15
17
|
export declare function resolveResourceFileRefs(tplApi: TemplateResolverApi, ctx: RendererContext, template: TemplateResource, keys: readonly string[]): Promise<ReadonlyMap<string, string>>;
|
|
18
|
+
export declare function readBrandingLogo(ctx: RendererContext): Promise<string | undefined>;
|
|
16
19
|
export declare function collectRegionResourceKeys(regions: LayoutRegions): readonly string[];
|
|
17
20
|
export declare function mergeResourceFileRefs(bodyRefs: ReadonlyMap<string, string>, layoutRefs: ReadonlyMap<string, string>): ReadonlyMap<string, string>;
|
package/dist/document-kit.js
CHANGED
|
@@ -71,8 +71,22 @@ export function createDocumentHandlebars(format) {
|
|
|
71
71
|
return value;
|
|
72
72
|
});
|
|
73
73
|
hbs.registerHelper("link", (value) => typeof value === "string" && SAFE_LINK_PROTOCOL.test(value.trim()) ? value : "#");
|
|
74
|
+
hbs.registerHelper("stepIs", function stepIs(value, index, keys, state, options) {
|
|
75
|
+
return isStepInState(value, index, keys, state) ? options.fn(this) : options.inverse(this);
|
|
76
|
+
});
|
|
74
77
|
return hbs;
|
|
75
78
|
}
|
|
79
|
+
// An unknown stage value reaches no stage: the row shows all stages as open.
|
|
80
|
+
function isStepInState(value, index, keys, state) {
|
|
81
|
+
if (typeof index !== "number" || typeof keys !== "string")
|
|
82
|
+
return false;
|
|
83
|
+
const current = typeof value === "string" || typeof value === "number"
|
|
84
|
+
? keys.split(",").indexOf(String(value).trim())
|
|
85
|
+
: -1;
|
|
86
|
+
if (current < 0)
|
|
87
|
+
return false;
|
|
88
|
+
return state === "current" ? index === current : index <= current;
|
|
89
|
+
}
|
|
76
90
|
const KNOWN_HELPERS = {
|
|
77
91
|
each: true,
|
|
78
92
|
if: true,
|
|
@@ -80,6 +94,7 @@ const KNOWN_HELPERS = {
|
|
|
80
94
|
date: true,
|
|
81
95
|
link: true,
|
|
82
96
|
richText: true,
|
|
97
|
+
stepIs: true,
|
|
83
98
|
};
|
|
84
99
|
export function compileDocumentSource(hbs, source, options = {}) {
|
|
85
100
|
return hbs.compile(source, {
|
|
@@ -111,7 +126,10 @@ export async function readBrandingAccentColor(ctx) {
|
|
|
111
126
|
const value = await readConfigText(ctx, BRANDING_QN.accentColor);
|
|
112
127
|
return value !== undefined && isHexColor(value) ? value : undefined;
|
|
113
128
|
}
|
|
114
|
-
|
|
129
|
+
export async function readBrandingSiteUrl(ctx) {
|
|
130
|
+
return readConfigText(ctx, BRANDING_QN.siteUrl);
|
|
131
|
+
}
|
|
132
|
+
export const LOGO_RESOURCE_KEY = "logo";
|
|
115
133
|
// Lookup order: the template's own link, the system template it overrides, and for
|
|
116
134
|
// "logo" the tenant's branding logo. The system lookup is skipped for system templates.
|
|
117
135
|
export async function resolveResourceFileRefs(tplApi, ctx, template, keys) {
|
|
@@ -145,7 +163,7 @@ export async function resolveResourceFileRefs(tplApi, ctx, template, keys) {
|
|
|
145
163
|
}
|
|
146
164
|
// The token is a free field of the tenant admin and the send reader also reaches the system
|
|
147
165
|
// tenant's files, so only a file of the rendering tenant itself counts.
|
|
148
|
-
async function readBrandingLogo(ctx) {
|
|
166
|
+
export async function readBrandingLogo(ctx) {
|
|
149
167
|
const fileRefId = await readConfigText(ctx, BRANDING_QN.logoFile);
|
|
150
168
|
if (fileRefId === undefined || !isUuid(fileRefId))
|
|
151
169
|
return undefined;
|
package/dist/feature.d.ts
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
import type { FeatureDefinition } from "@cosmicdrift/kumiko-types/feature";
|
|
2
|
-
|
|
2
|
+
import { type MailHtmlRenderOptions } from "./render.js";
|
|
3
|
+
export declare function createRendererMailHtmlFeature(options?: MailHtmlRenderOptions): FeatureDefinition;
|
package/dist/feature.js
CHANGED
|
@@ -11,15 +11,20 @@ import { mailHtmlRender } from "./render.js";
|
|
|
11
11
|
// and optionally `config` for the tenant's currency and branding.
|
|
12
12
|
//
|
|
13
13
|
// **Layout:** meta.layoutSlug ?? "default", kind "mail-layout". Without a seeded
|
|
14
|
-
// layout
|
|
15
|
-
|
|
14
|
+
// layout an in-code structured layout with the tenant branding is used, so mail
|
|
15
|
+
// works from day 1.
|
|
16
|
+
//
|
|
17
|
+
// **Footer:** options.footer returns the platform's imprint, privacy link and
|
|
18
|
+
// footer text per tenant and locale. Structured layouts always render it below
|
|
19
|
+
// the tenant-editable footer region, so a tenant cannot remove it.
|
|
20
|
+
export function createRendererMailHtmlFeature(options = {}) {
|
|
16
21
|
return defineFeature("renderer-mail-html", (r) => {
|
|
17
|
-
r.describe('A `renderer-foundation` plugin that handles `RenderRequest { kind: "mail" }` and returns `{ subject, html, text, previewText? }` suitable for sending via `channel-email`. The mail template (`kind: "mail"`, document HTML, subject in `meta`) resolves through `template-resolver`\'s DB fallback, its body is compiled to Handlebars by template-resolver and rendered with escaping, then placed into an MJML layout (`kind: "mail-layout"`, slug `default` unless `meta.layoutSlug` says otherwise, in-code
|
|
22
|
+
r.describe('A `renderer-foundation` plugin that handles `RenderRequest { kind: "mail" }` and returns `{ subject, html, text, previewText? }` suitable for sending via `channel-email`. The mail template (`kind: "mail"`, document HTML, subject in `meta`) resolves through `template-resolver`\'s DB fallback, its body is compiled to Handlebars by template-resolver and rendered with escaping, then placed into an MJML layout (`kind: "mail-layout"`, slug `default` unless `meta.layoutSlug` says otherwise, an in-code structured layout with the tenant branding logo and accent color as last resort) that `mjml()` compiles to email-safe table HTML, with `html-to-text` producing the plain-text alternative. An optional `footer` resolver adds the platform imprint, privacy link and footer text below every structured layout, and an optional `brandSuffix` resolver adds a word after the tenant name in the brand row of the in-code layout. Mail meta `headerChip`, `tone` and `footerNote` add a chip beside the brand, the tone of the top line and chip, and a note above the site and unsubscribe links of the card. Seed an editable system layout with `seedSystemMailLayout(db)`. Requires `renderer-foundation` + `template-resolver`.');
|
|
18
23
|
r.requires("renderer-foundation");
|
|
19
24
|
r.requires("template-resolver");
|
|
20
25
|
r.useExtension("renderer", "mail-html", {
|
|
21
26
|
kinds: ["mail"],
|
|
22
|
-
render: mailHtmlRender,
|
|
27
|
+
render: (req, ctx) => mailHtmlRender(req, ctx, options),
|
|
23
28
|
});
|
|
24
29
|
});
|
|
25
30
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { DEFAULT_MAIL_LAYOUT_MJML } from "./default-layout.js";
|
|
2
|
-
export { collectRegionResourceKeys, compileDocumentSource, createDocumentHandlebars, type DocumentFormat, mergeResourceFileRefs, readDocumentFormat, readTenantName, resolveResourceFileRefs, } from "./document-kit.js";
|
|
2
|
+
export { collectRegionResourceKeys, compileDocumentSource, createDocumentHandlebars, type DocumentFormat, mergeResourceFileRefs, readBrandingAccentColor, readDocumentFormat, readTenantName, resolveResourceFileRefs, } from "./document-kit.js";
|
|
3
3
|
export { createRendererMailHtmlFeature } from "./feature.js";
|
|
4
|
-
export {
|
|
4
|
+
export type { MailFooter, MailFooterLink, MailFooterResolver } from "./mail-footer.js";
|
|
5
|
+
export { type MailBrandSuffixResolver, type MailHtmlRenderOptions, mailHtmlRender, } from "./render.js";
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { DEFAULT_MAIL_LAYOUT_MJML } from "./default-layout.js";
|
|
2
|
-
export { collectRegionResourceKeys, compileDocumentSource, createDocumentHandlebars, mergeResourceFileRefs, readDocumentFormat, readTenantName, resolveResourceFileRefs, } from "./document-kit.js";
|
|
2
|
+
export { collectRegionResourceKeys, compileDocumentSource, createDocumentHandlebars, mergeResourceFileRefs, readBrandingAccentColor, readDocumentFormat, readTenantName, resolveResourceFileRefs, } from "./document-kit.js";
|
|
3
3
|
export { createRendererMailHtmlFeature } from "./feature.js";
|
|
4
|
-
export { mailHtmlRender } from "./render.js";
|
|
4
|
+
export { mailHtmlRender, } from "./render.js";
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type MailTone } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
|
|
2
|
+
export type ToneColors = {
|
|
3
|
+
readonly bar: string;
|
|
4
|
+
readonly fg: string;
|
|
5
|
+
readonly soft: string;
|
|
6
|
+
};
|
|
7
|
+
export declare const DEFAULT_ACCENT_COLOR = "#1f5468";
|
|
8
|
+
export declare function readMailTone(rendered: string | undefined): MailTone | undefined;
|
|
9
|
+
export declare function toneColors(tone: MailTone, accentColor: string | undefined): ToneColors;
|
|
10
|
+
export declare function brandHeaderHtml(input: {
|
|
11
|
+
readonly tenantName: string;
|
|
12
|
+
readonly suffix: string;
|
|
13
|
+
readonly logoSrc: string | undefined;
|
|
14
|
+
readonly accentColor: string;
|
|
15
|
+
}): string;
|
|
16
|
+
export declare function headerRowHtml(leftHtml: string, chipLabel: string, colors: ToneColors): string;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { isHexColor, MAIL_TONES, } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
|
|
2
|
+
import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
|
|
3
|
+
import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
4
|
+
const FIXED_TONES = {
|
|
5
|
+
info: { bar: "#7C8DB5", fg: "#3E4C70", soft: "#EEF1F7" },
|
|
6
|
+
success: { bar: "#22B35E", fg: "#0E7A3E", soft: "#E8F7EE" },
|
|
7
|
+
warning: { bar: "#F2B53A", fg: "#8A4B00", soft: "#FEF4E2" },
|
|
8
|
+
danger: { bar: "#E5484D", fg: "#A1161B", soft: "#FDECEC" },
|
|
9
|
+
neutral: { bar: "#8B919C", fg: "#4A515E", soft: "#F1F2F0" },
|
|
10
|
+
};
|
|
11
|
+
export const DEFAULT_ACCENT_COLOR = "#1f5468";
|
|
12
|
+
const INK = "#0B0D12";
|
|
13
|
+
const MUTED = "#5A6170";
|
|
14
|
+
const LOGO_HEIGHT_PX = 28;
|
|
15
|
+
// The rendered meta value is author input: only a known tone name selects colors.
|
|
16
|
+
export function readMailTone(rendered) {
|
|
17
|
+
const value = rendered?.trim().toLowerCase();
|
|
18
|
+
return isOneOf(MAIL_TONES, value) ? value : undefined;
|
|
19
|
+
}
|
|
20
|
+
function tintTowardsWhite(hex, whiteShare) {
|
|
21
|
+
const channel = (offset) => {
|
|
22
|
+
const value = Number.parseInt(hex.slice(offset, offset + 2), 16);
|
|
23
|
+
const mixed = Math.round(value + (255 - value) * whiteShare);
|
|
24
|
+
return mixed.toString(16).padStart(2, "0");
|
|
25
|
+
};
|
|
26
|
+
return `#${channel(1)}${channel(3)}${channel(5)}`;
|
|
27
|
+
}
|
|
28
|
+
export function toneColors(tone, accentColor) {
|
|
29
|
+
if (tone !== "accent")
|
|
30
|
+
return FIXED_TONES[tone];
|
|
31
|
+
const accent = accentColor !== undefined && isHexColor(accentColor) ? accentColor : DEFAULT_ACCENT_COLOR;
|
|
32
|
+
return { bar: accent, fg: accent, soft: tintTowardsWhite(accent, 0.9) };
|
|
33
|
+
}
|
|
34
|
+
function tenantInitial(tenantName) {
|
|
35
|
+
const first = Array.from(tenantName.trim())[0];
|
|
36
|
+
return first === undefined ? "" : first.toLocaleUpperCase();
|
|
37
|
+
}
|
|
38
|
+
// Logo (or an initial tile in the accent color), tenant name and an optional suffix in one row.
|
|
39
|
+
export function brandHeaderHtml(input) {
|
|
40
|
+
const initial = tenantInitial(input.tenantName);
|
|
41
|
+
const tileColor = isHexColor(input.accentColor) ? input.accentColor : DEFAULT_ACCENT_COLOR;
|
|
42
|
+
const mark = input.logoSrc !== undefined
|
|
43
|
+
? `<img src="${escapeHtmlAttr(input.logoSrc)}" alt="" height="${LOGO_HEIGHT_PX}" style="height:${LOGO_HEIGHT_PX}px;width:auto;max-width:140px;border:0;vertical-align:middle;margin:0 10px 0 0">`
|
|
44
|
+
: initial === ""
|
|
45
|
+
? ""
|
|
46
|
+
: `<span style="display:inline-block;width:${LOGO_HEIGHT_PX}px;height:${LOGO_HEIGHT_PX}px;line-height:${LOGO_HEIGHT_PX}px;text-align:center;border-radius:8px;background:${escapeHtmlAttr(tileColor)};color:#ffffff;font-weight:700;font-size:14px;vertical-align:middle;margin:0 10px 0 0">${escapeHtml(initial)}</span>`;
|
|
47
|
+
const name = input.tenantName.trim() === ""
|
|
48
|
+
? ""
|
|
49
|
+
: `<span style="vertical-align:middle;font-size:15px;font-weight:600;color:${INK}">${escapeHtml(input.tenantName)}</span>`;
|
|
50
|
+
const suffix = input.suffix.trim() === ""
|
|
51
|
+
? ""
|
|
52
|
+
: ` <span style="vertical-align:middle;font-size:13px;color:${MUTED}">${escapeHtml(input.suffix)}</span>`;
|
|
53
|
+
return `${mark}${name}${suffix}`;
|
|
54
|
+
}
|
|
55
|
+
function chipHtml(label, colors) {
|
|
56
|
+
return `<span style="display:inline-block;font-size:12px;font-weight:600;line-height:1.4;color:${escapeHtmlAttr(colors.fg)};background:${escapeHtmlAttr(colors.soft)};padding:4px 10px;border-radius:999px;white-space:nowrap"><span style="display:inline-block;width:6px;height:6px;border-radius:999px;background:${escapeHtmlAttr(colors.bar)};margin:0 6px 1px 0;vertical-align:middle"></span>${escapeHtml(label)}</span>`;
|
|
57
|
+
}
|
|
58
|
+
// A table instead of two mj-columns: columns stack on narrow screens, the chip belongs beside the brand.
|
|
59
|
+
export function headerRowHtml(leftHtml, chipLabel, colors) {
|
|
60
|
+
if (chipLabel.trim() === "")
|
|
61
|
+
return leftHtml;
|
|
62
|
+
return `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr><td align="left" style="vertical-align:middle">${leftHtml}</td><td align="right" style="vertical-align:middle">${chipHtml(chipLabel, colors)}</td></tr></table>`;
|
|
63
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { RendererContext } from "@cosmicdrift/kumiko-bundled-features/renderer-foundation";
|
|
2
|
+
export type MailFooterLink = {
|
|
3
|
+
readonly label: string;
|
|
4
|
+
readonly url: string;
|
|
5
|
+
};
|
|
6
|
+
export type MailFooter = {
|
|
7
|
+
readonly text?: string;
|
|
8
|
+
readonly imprint?: MailFooterLink;
|
|
9
|
+
readonly privacy?: MailFooterLink;
|
|
10
|
+
};
|
|
11
|
+
export type MailFooterResolver = (input: {
|
|
12
|
+
readonly locale: string;
|
|
13
|
+
readonly ctx: RendererContext;
|
|
14
|
+
}) => MailFooter | null | Promise<MailFooter | null>;
|
|
15
|
+
export declare function buildMailBandLinksHtml(input: {
|
|
16
|
+
readonly siteUrl: string | undefined;
|
|
17
|
+
readonly unsubscribeUrl: string;
|
|
18
|
+
readonly locale: string;
|
|
19
|
+
readonly layoutLinksUnsubscribe: boolean;
|
|
20
|
+
}): string;
|
|
21
|
+
export declare function safeFooterUrl(link: MailFooterLink | undefined): string;
|
|
22
|
+
export declare function buildMailFooterHtml(input: {
|
|
23
|
+
readonly footer: MailFooter | null;
|
|
24
|
+
readonly unsubscribeUrl: string;
|
|
25
|
+
readonly locale: string;
|
|
26
|
+
readonly layoutLinksImprint: boolean;
|
|
27
|
+
readonly layoutLinksUnsubscribe: boolean;
|
|
28
|
+
}): string;
|
|
29
|
+
export declare function layoutLinksToPlaceholder(layoutHtml: string, name: string): boolean;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
|
|
2
|
+
const SAFE_FOOTER_URL = /^(https?:|mailto:)/i;
|
|
3
|
+
const BAND_LINK_COLOR = "#4A515E";
|
|
4
|
+
const UNSUBSCRIBE_LABEL_BY_LANGUAGE = {
|
|
5
|
+
de: "Abmelden",
|
|
6
|
+
en: "Unsubscribe",
|
|
7
|
+
es: "Darse de baja",
|
|
8
|
+
};
|
|
9
|
+
function unsubscribeLabel(locale) {
|
|
10
|
+
const language = (locale.split("-")[0] ?? locale).toLowerCase();
|
|
11
|
+
return UNSUBSCRIBE_LABEL_BY_LANGUAGE[language] ?? "Unsubscribe";
|
|
12
|
+
}
|
|
13
|
+
function linkHtml(link, color = "#888") {
|
|
14
|
+
const url = link.url.trim();
|
|
15
|
+
if (!SAFE_FOOTER_URL.test(url) || link.label.trim() === "")
|
|
16
|
+
return null;
|
|
17
|
+
return `<a href="${escapeHtmlAttr(url)}" style="color:${escapeHtmlAttr(color)}">${escapeHtml(link.label)}</a>`;
|
|
18
|
+
}
|
|
19
|
+
function webHost(url) {
|
|
20
|
+
try {
|
|
21
|
+
const parsed = new URL(url);
|
|
22
|
+
return parsed.protocol === "https:" || parsed.protocol === "http:" ? parsed.host : null;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
// The card's own footer line: the tenant's site and the unsubscribe link.
|
|
29
|
+
export function buildMailBandLinksHtml(input) {
|
|
30
|
+
const host = input.siteUrl === undefined ? null : webHost(input.siteUrl);
|
|
31
|
+
const links = [
|
|
32
|
+
host === null || input.siteUrl === undefined
|
|
33
|
+
? null
|
|
34
|
+
: linkHtml({ label: host, url: input.siteUrl }, BAND_LINK_COLOR),
|
|
35
|
+
input.unsubscribeUrl === "" || input.layoutLinksUnsubscribe
|
|
36
|
+
? null
|
|
37
|
+
: linkHtml({ label: unsubscribeLabel(input.locale), url: input.unsubscribeUrl }, BAND_LINK_COLOR),
|
|
38
|
+
];
|
|
39
|
+
return links.filter((link) => link !== null).join(" · ");
|
|
40
|
+
}
|
|
41
|
+
export function safeFooterUrl(link) {
|
|
42
|
+
return link !== undefined && SAFE_FOOTER_URL.test(link.url.trim()) ? link.url.trim() : "";
|
|
43
|
+
}
|
|
44
|
+
// Built in code with every value escaped, so it can go into the layout as raw
|
|
45
|
+
// HTML. Links the layout regions already carry are left out to avoid doubling them.
|
|
46
|
+
export function buildMailFooterHtml(input) {
|
|
47
|
+
const { footer, unsubscribeUrl, locale } = input;
|
|
48
|
+
const links = [];
|
|
49
|
+
if (footer?.imprint !== undefined && !input.layoutLinksImprint) {
|
|
50
|
+
const html = linkHtml(footer.imprint);
|
|
51
|
+
if (html !== null)
|
|
52
|
+
links.push(html);
|
|
53
|
+
}
|
|
54
|
+
if (footer?.privacy !== undefined) {
|
|
55
|
+
const html = linkHtml(footer.privacy);
|
|
56
|
+
if (html !== null)
|
|
57
|
+
links.push(html);
|
|
58
|
+
}
|
|
59
|
+
if (unsubscribeUrl !== "" && !input.layoutLinksUnsubscribe) {
|
|
60
|
+
const html = linkHtml({ label: unsubscribeLabel(locale), url: unsubscribeUrl });
|
|
61
|
+
if (html !== null)
|
|
62
|
+
links.push(html);
|
|
63
|
+
}
|
|
64
|
+
const text = footer?.text?.trim() ?? "";
|
|
65
|
+
const lines = [
|
|
66
|
+
...(text === "" ? [] : [escapeHtml(text)]),
|
|
67
|
+
...(links.length === 0 ? [] : [links.join(" · ")]),
|
|
68
|
+
];
|
|
69
|
+
return lines.join("<br>");
|
|
70
|
+
}
|
|
71
|
+
export function layoutLinksToPlaceholder(layoutHtml, name) {
|
|
72
|
+
return new RegExp(`href="\\{\\{\\s*${name}\\s*\\}\\}"`).test(layoutHtml);
|
|
73
|
+
}
|
package/dist/render.d.ts
CHANGED
|
@@ -1,2 +1,12 @@
|
|
|
1
1
|
import { type RendererContext, type RenderRequest, type RenderResponse } from "@cosmicdrift/kumiko-bundled-features/renderer-foundation";
|
|
2
|
-
|
|
2
|
+
import { type MailFooterResolver } from "./mail-footer.js";
|
|
3
|
+
export type MailBrandSuffixResolver = (input: {
|
|
4
|
+
readonly locale: string;
|
|
5
|
+
readonly ctx: RendererContext;
|
|
6
|
+
}) => string | null | Promise<string | null>;
|
|
7
|
+
export type MailHtmlRenderOptions = {
|
|
8
|
+
readonly footer?: MailFooterResolver;
|
|
9
|
+
/** Text after the tenant name in the in-code layout's brand row, e.g. "Status". */
|
|
10
|
+
readonly brandSuffix?: MailBrandSuffixResolver;
|
|
11
|
+
};
|
|
12
|
+
export declare function mailHtmlRender(req: RenderRequest, ctx: RendererContext, options?: MailHtmlRenderOptions): Promise<RenderResponse>;
|
package/dist/render.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// renderer-mail-html pipeline for kind "mail":
|
|
2
2
|
// 1. Resolve the mail template (tenant -> system) and its layout
|
|
3
|
-
// (meta.layoutSlug ?? "default", kind mail-layout, fallback to
|
|
4
|
-
//
|
|
3
|
+
// (meta.layoutSlug ?? "default", kind mail-layout, fallback to an in-code
|
|
4
|
+
// structured layout with the branding logo or tenant name as header).
|
|
5
5
|
// 2. compileDocumentBodyToHandlebars turns the sanitized document body into
|
|
6
6
|
// Handlebars source; every author brace is neutralized there, so the only
|
|
7
7
|
// expressions are the ones template-resolver wrote.
|
|
@@ -14,20 +14,35 @@ import { collectDocumentResourceKeys, compileDocumentBodyToHandlebars, createTem
|
|
|
14
14
|
import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
15
15
|
import { convert as htmlToText } from "html-to-text";
|
|
16
16
|
import mjml2html from "mjml";
|
|
17
|
-
import {
|
|
18
|
-
import { collectRegionResourceKeys, compileDocumentSource, createDocumentHandlebars, mergeResourceFileRefs, readBrandingAccentColor, readDocumentFormat, readTenantName, resolveResourceFileRefs, } from "./document-kit.js";
|
|
17
|
+
import { DEFAULT_MAIL_LAYOUT_SLUG } from "./default-layout.js";
|
|
18
|
+
import { collectRegionResourceKeys, compileDocumentSource, createDocumentHandlebars, LOGO_RESOURCE_KEY, mergeResourceFileRefs, readBrandingAccentColor, readBrandingLogo, readBrandingSiteUrl, readDocumentFormat, readTenantName, resolveResourceFileRefs, } from "./document-kit.js";
|
|
19
19
|
import { buildRenderedPdfAttachments, combineMailAttachments, mergeInheritedAttachments, } from "./mail-attachments.js";
|
|
20
|
+
import { brandHeaderHtml, DEFAULT_ACCENT_COLOR, readMailTone, toneColors, } from "./mail-chrome.js";
|
|
21
|
+
import { buildMailBandLinksHtml, buildMailFooterHtml, layoutLinksToPlaceholder, safeFooterUrl, } from "./mail-footer.js";
|
|
20
22
|
import { MAIL_IMAGE_MAX_WIDTH_PX, renderStructuredLayoutMjml } from "./structured-layout.js";
|
|
21
23
|
const LINE_BREAKS = /[\r\n]+/g;
|
|
22
24
|
function describe(e) {
|
|
23
25
|
return e instanceof Error ? e.message : String(e);
|
|
24
26
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
// Without a seeded layout every mail still carries the tenant's branding: accent
|
|
28
|
+
// line and button color, and a brand row with the logo (or an initial tile) and the name.
|
|
29
|
+
const IN_CODE_LAYOUT_META = {
|
|
30
|
+
accentLine: "branding",
|
|
31
|
+
width: 640,
|
|
32
|
+
outerBackground: "#ecedea",
|
|
30
33
|
};
|
|
34
|
+
async function inCodeLayout(ctx) {
|
|
35
|
+
const logoFileRefId = await readBrandingLogo(ctx);
|
|
36
|
+
return {
|
|
37
|
+
content: "<div data-content></div>",
|
|
38
|
+
format: "html",
|
|
39
|
+
ref: null,
|
|
40
|
+
template: null,
|
|
41
|
+
appearanceMeta: IN_CODE_LAYOUT_META,
|
|
42
|
+
brandHeader: { hasLogo: logoFileRefId !== undefined },
|
|
43
|
+
fileRefs: logoFileRefId === undefined ? new Map() : new Map([[LOGO_RESOURCE_KEY, logoFileRefId]]),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
31
46
|
// Only system layouts are trusted as written: a tenant mjml row left over from
|
|
32
47
|
// before that rule, or a tenant html row failing the layout sanitizer, is
|
|
33
48
|
// replaced by the system layout of the same slug.
|
|
@@ -70,6 +85,7 @@ async function resolveLayout(tplApi, ctx, meta, locale) {
|
|
|
70
85
|
format: layout.contentFormat === "html" ? "html" : "mjml",
|
|
71
86
|
ref: { templateId: layout.id, templateVersion: layout.version, scope: layout.scope },
|
|
72
87
|
template: layout,
|
|
88
|
+
appearanceMeta: layout.meta,
|
|
73
89
|
};
|
|
74
90
|
}
|
|
75
91
|
catch (e) {
|
|
@@ -78,7 +94,7 @@ async function resolveLayout(tplApi, ctx, meta, locale) {
|
|
|
78
94
|
if (!(e instanceof TemplateNotFoundError)) {
|
|
79
95
|
throw new RendererError(`renderer-mail-html: layout resolution failed — ${describe(e)}`, "other");
|
|
80
96
|
}
|
|
81
|
-
return
|
|
97
|
+
return null;
|
|
82
98
|
}
|
|
83
99
|
}
|
|
84
100
|
async function findStoredMailTemplate(tplApi, ctx, slug, locale) {
|
|
@@ -92,6 +108,8 @@ async function findStoredMailTemplate(tplApi, ctx, slug, locale) {
|
|
|
92
108
|
}
|
|
93
109
|
}
|
|
94
110
|
async function resolveLayoutFileRefs(tplApi, ctx, layout) {
|
|
111
|
+
if (layout.fileRefs !== undefined)
|
|
112
|
+
return layout.fileRefs;
|
|
95
113
|
if (layout.format !== "html" || layout.template === null)
|
|
96
114
|
return new Map();
|
|
97
115
|
try {
|
|
@@ -160,7 +178,7 @@ async function resolveMailTemplate(tplApi, ctx, slug, locale, draft) {
|
|
|
160
178
|
}
|
|
161
179
|
// Each data-resource image is referenced by cid; the bytes are read at send time, so
|
|
162
180
|
// the render result carries only file ref ids.
|
|
163
|
-
async function renderMailBody(tplApi, ctx, template, hbs, context, buttonInlineStyle) {
|
|
181
|
+
async function renderMailBody(tplApi, ctx, template, hbs, context, buttonInlineStyle, stepsColor) {
|
|
164
182
|
try {
|
|
165
183
|
const resourceFileRefs = await resolveResourceFileRefs(tplApi, ctx, template, collectDocumentResourceKeys(template.content));
|
|
166
184
|
const handlebarsSource = compileDocumentBodyToHandlebars(template.content, resolveTemplateVariables(ctx.registry, template.slug, template.variableSchema), {
|
|
@@ -169,6 +187,8 @@ async function renderMailBody(tplApi, ctx, template, hbs, context, buttonInlineS
|
|
|
169
187
|
alignmentAsInlineStyle: true,
|
|
170
188
|
buttonAsInlineStyle: true,
|
|
171
189
|
tableAsInlineStyle: true,
|
|
190
|
+
headingAsInlineStyle: true,
|
|
191
|
+
stepsColor,
|
|
172
192
|
...(buttonInlineStyle !== undefined && { buttonInlineStyle }),
|
|
173
193
|
});
|
|
174
194
|
return {
|
|
@@ -204,6 +224,24 @@ function renderSubjectLines(hbs, meta, slug, context) {
|
|
|
204
224
|
throw new RendererError(`renderer-mail-html: rendering subject or preview text of "${slug}" failed — ${describe(e)}`, "other");
|
|
205
225
|
}
|
|
206
226
|
}
|
|
227
|
+
// Chip and tone are author-chosen per mail; a chip without a known tone takes the accent tone.
|
|
228
|
+
function renderMailChrome(hbs, meta, slug, context, brandColor) {
|
|
229
|
+
const render = (source) => source === undefined ? "" : renderSubjectLine(hbs, source, context).trim();
|
|
230
|
+
try {
|
|
231
|
+
const headerChip = render(meta.headerChip);
|
|
232
|
+
const tone = readMailTone(render(meta.tone));
|
|
233
|
+
return {
|
|
234
|
+
tone: tone === undefined && headerChip === ""
|
|
235
|
+
? undefined
|
|
236
|
+
: toneColors(tone ?? "accent", brandColor),
|
|
237
|
+
headerChip,
|
|
238
|
+
footerNote: render(meta.footerNote),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
catch (e) {
|
|
242
|
+
throw new RendererError(`renderer-mail-html: rendering chip, tone or footer note of "${slug}" failed — ${describe(e)}`, "other");
|
|
243
|
+
}
|
|
244
|
+
}
|
|
207
245
|
// Same fallback as for images (resolveResourceFileRefs): a tenant override of a system mail
|
|
208
246
|
// keeps the attachments the system mail carries.
|
|
209
247
|
async function resolveSystemAttachments(tplApi, template) {
|
|
@@ -252,8 +290,18 @@ function compileLayoutMjml(args) {
|
|
|
252
290
|
resourceSrc: (key) => (resourceFileRefs.has(key) ? `cid:${key}` : undefined),
|
|
253
291
|
...(appearance !== undefined && { appearance }),
|
|
254
292
|
...(accentColor !== undefined && { accentColor }),
|
|
293
|
+
...(args.chrome.tone !== undefined && { tone: args.chrome.tone }),
|
|
294
|
+
...(args.brandHeaderHtml !== undefined && { brandHeaderHtml: args.brandHeaderHtml }),
|
|
295
|
+
headerChip: args.chrome.headerChip,
|
|
296
|
+
footerNote: args.chrome.footerNote,
|
|
297
|
+
bandLinksHtml: args.bandLinksHtml,
|
|
298
|
+
footerHtml: args.footerHtml,
|
|
255
299
|
})
|
|
256
|
-
: compileDocumentSource(hbs, layout.content)({
|
|
300
|
+
: compileDocumentSource(hbs, layout.content)({
|
|
301
|
+
...layoutContext,
|
|
302
|
+
content: contentHtml,
|
|
303
|
+
mailFooterHtml: args.footerHtml,
|
|
304
|
+
});
|
|
257
305
|
}
|
|
258
306
|
catch (e) {
|
|
259
307
|
// "other": the caller's payload is fine, the layout template is broken.
|
|
@@ -303,7 +351,67 @@ function buildMailRenderResponse(args) {
|
|
|
303
351
|
...(layout.ref === null ? {} : { layout: layout.ref }),
|
|
304
352
|
};
|
|
305
353
|
}
|
|
306
|
-
|
|
354
|
+
async function resolveMailFooter(options, ctx, locale) {
|
|
355
|
+
if (options.footer === undefined)
|
|
356
|
+
return null;
|
|
357
|
+
try {
|
|
358
|
+
return await options.footer({ locale, ctx });
|
|
359
|
+
}
|
|
360
|
+
catch (e) {
|
|
361
|
+
throw new RendererError(`renderer-mail-html: the mail footer resolver failed — ${describe(e)}`, "other");
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
// Only a structured (html) layout carries appearance; an mjml layout keeps its own look.
|
|
365
|
+
async function resolveMailBranding(ctx, layout) {
|
|
366
|
+
const brandColor = await readBrandingAccentColor(ctx);
|
|
367
|
+
if (layout.format !== "html") {
|
|
368
|
+
return { appearance: undefined, accentColor: undefined, brandColor };
|
|
369
|
+
}
|
|
370
|
+
const accentColor = mailLayoutAppearance(layout.appearanceMeta).accentLine
|
|
371
|
+
? brandColor
|
|
372
|
+
: undefined;
|
|
373
|
+
return {
|
|
374
|
+
appearance: mailLayoutAppearance(layout.appearanceMeta, accentColor),
|
|
375
|
+
accentColor,
|
|
376
|
+
brandColor,
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
async function resolveBrandSuffix(options, ctx, locale) {
|
|
380
|
+
if (options.brandSuffix === undefined)
|
|
381
|
+
return "";
|
|
382
|
+
try {
|
|
383
|
+
return (await options.brandSuffix({ locale, ctx })) ?? "";
|
|
384
|
+
}
|
|
385
|
+
catch (e) {
|
|
386
|
+
throw new RendererError(`renderer-mail-html: the brand suffix resolver failed — ${describe(e)}`, "other");
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
// A structured layout shows the site and unsubscribe links inside the card and only the
|
|
390
|
+
// platform line below it; an mjml layout gets everything in one line.
|
|
391
|
+
function renderFooterParts(args) {
|
|
392
|
+
const { layout, footer, unsubscribeUrl, locale } = args;
|
|
393
|
+
const structured = layout.format === "html";
|
|
394
|
+
const layoutLinksUnsubscribe = structured && layoutLinksToPlaceholder(layout.content, "unsubscribeUrl");
|
|
395
|
+
const footerHtml = buildMailFooterHtml({
|
|
396
|
+
footer,
|
|
397
|
+
unsubscribeUrl: structured ? "" : unsubscribeUrl,
|
|
398
|
+
locale,
|
|
399
|
+
layoutLinksImprint: structured && layoutLinksToPlaceholder(layout.content, "imprint"),
|
|
400
|
+
layoutLinksUnsubscribe,
|
|
401
|
+
});
|
|
402
|
+
return {
|
|
403
|
+
footerHtml,
|
|
404
|
+
bandLinksHtml: structured
|
|
405
|
+
? buildMailBandLinksHtml({
|
|
406
|
+
siteUrl: args.siteUrl,
|
|
407
|
+
unsubscribeUrl,
|
|
408
|
+
locale,
|
|
409
|
+
layoutLinksUnsubscribe,
|
|
410
|
+
})
|
|
411
|
+
: "",
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
export async function mailHtmlRender(req, ctx, options = {}) {
|
|
307
415
|
if (req.kind !== "mail") {
|
|
308
416
|
// Foundation selects plugins only for matching kinds; defensive guard.
|
|
309
417
|
throw new RendererError(`renderer-mail-html supports only kind="mail", got "${req.kind}"`, "invalid_payload");
|
|
@@ -311,22 +419,25 @@ export async function mailHtmlRender(req, ctx) {
|
|
|
311
419
|
const { slug, locale, variables, unsubscribeUrl, draft } = req.payload;
|
|
312
420
|
const tplApi = createTemplateResolverApi(ctx.db);
|
|
313
421
|
const { template, meta } = await resolveMailTemplate(tplApi, ctx, slug, locale, draft);
|
|
314
|
-
const
|
|
315
|
-
const [format, tenantName] = await Promise.all([
|
|
422
|
+
const [format, tenantName, footer, siteUrl, brandSuffix] = await Promise.all([
|
|
316
423
|
readDocumentFormat(ctx, locale),
|
|
317
424
|
readTenantName(ctx),
|
|
425
|
+
resolveMailFooter(options, ctx, locale),
|
|
426
|
+
readBrandingSiteUrl(ctx),
|
|
427
|
+
resolveBrandSuffix(options, ctx, locale),
|
|
318
428
|
]);
|
|
429
|
+
const layout = (await resolveLayout(tplApi, ctx, meta, locale)) ?? (await inCodeLayout(ctx));
|
|
319
430
|
const hbs = createDocumentHandlebars(format);
|
|
320
|
-
const
|
|
321
|
-
...variables,
|
|
431
|
+
const systemPlaceholders = {
|
|
322
432
|
tenantName,
|
|
323
433
|
signature: "",
|
|
324
|
-
imprint:
|
|
434
|
+
imprint: safeFooterUrl(footer?.imprint),
|
|
325
435
|
unsubscribeUrl: unsubscribeUrl ?? "",
|
|
326
436
|
};
|
|
327
|
-
|
|
328
|
-
const appearance
|
|
329
|
-
const
|
|
437
|
+
const context = { ...variables, ...systemPlaceholders };
|
|
438
|
+
const { appearance, accentColor, brandColor } = await resolveMailBranding(ctx, layout);
|
|
439
|
+
const chrome = renderMailChrome(hbs, meta, slug, context, brandColor);
|
|
440
|
+
const body = await renderMailBody(tplApi, ctx, template, hbs, context, appearance?.buttonStyle, chrome.tone?.bar ?? brandColor ?? DEFAULT_ACCENT_COLOR);
|
|
330
441
|
const { contentHtml } = body;
|
|
331
442
|
const layoutFileRefs = await resolveLayoutFileRefs(tplApi, ctx, layout);
|
|
332
443
|
const resourceFileRefs = mergeResourceFileRefs(body.resourceFileRefs, layoutFileRefs);
|
|
@@ -340,7 +451,6 @@ export async function mailHtmlRender(req, ctx) {
|
|
|
340
451
|
renderFileName: (pattern) => renderFileNamePattern(hbs, pattern, slug, context),
|
|
341
452
|
});
|
|
342
453
|
const { subject, previewText } = renderSubjectLines(hbs, meta, slug, context);
|
|
343
|
-
const accentColor = appearance?.accentLine === true ? await readBrandingAccentColor(ctx) : undefined;
|
|
344
454
|
const mjmlSource = compileLayoutMjml({
|
|
345
455
|
layout,
|
|
346
456
|
hbs,
|
|
@@ -349,13 +459,27 @@ export async function mailHtmlRender(req, ctx) {
|
|
|
349
459
|
accentColor,
|
|
350
460
|
resourceFileRefs,
|
|
351
461
|
layoutContext: {
|
|
462
|
+
...systemPlaceholders,
|
|
463
|
+
lang: locale,
|
|
352
464
|
subject,
|
|
353
465
|
previewText: previewText ?? "",
|
|
354
|
-
tenantName,
|
|
355
|
-
signature: "",
|
|
356
|
-
imprint: "",
|
|
357
|
-
unsubscribeUrl: unsubscribeUrl ?? "",
|
|
358
466
|
},
|
|
467
|
+
chrome,
|
|
468
|
+
brandHeaderHtml: layout.brandHeader === undefined
|
|
469
|
+
? undefined
|
|
470
|
+
: brandHeaderHtml({
|
|
471
|
+
tenantName,
|
|
472
|
+
suffix: brandSuffix,
|
|
473
|
+
logoSrc: layout.brandHeader.hasLogo ? `cid:${LOGO_RESOURCE_KEY}` : undefined,
|
|
474
|
+
accentColor: brandColor ?? DEFAULT_ACCENT_COLOR,
|
|
475
|
+
}),
|
|
476
|
+
...renderFooterParts({
|
|
477
|
+
layout,
|
|
478
|
+
footer,
|
|
479
|
+
unsubscribeUrl: systemPlaceholders.unsubscribeUrl,
|
|
480
|
+
siteUrl,
|
|
481
|
+
locale,
|
|
482
|
+
}),
|
|
359
483
|
});
|
|
360
484
|
const html = await compileMjmlToHtml(mjmlSource);
|
|
361
485
|
return buildMailRenderResponse({
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { type MailLayoutAppearance } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
|
|
2
|
+
import { type ToneColors } from "./mail-chrome.js";
|
|
2
3
|
export declare const MAIL_IMAGE_MAX_WIDTH_PX = 180;
|
|
3
4
|
export type StructuredLayoutInput = {
|
|
4
5
|
readonly layoutHtml: string;
|
|
5
6
|
readonly contentHtml: string;
|
|
6
|
-
/** Layout context (tenantName, subject, previewText, signature, imprint, unsubscribeUrl). */
|
|
7
|
+
/** Layout context (lang, tenantName, subject, previewText, signature, imprint, unsubscribeUrl). */
|
|
7
8
|
readonly context: Readonly<Record<string, unknown>>;
|
|
8
9
|
/** Compiles Handlebars source with the renderer's isolated instance and helper set. */
|
|
9
10
|
readonly compile: (source: string) => (context: Readonly<Record<string, unknown>>) => string;
|
|
@@ -13,5 +14,17 @@ export type StructuredLayoutInput = {
|
|
|
13
14
|
readonly appearance?: MailLayoutAppearance;
|
|
14
15
|
/** Tenant branding accent color for the accent line; ignored unless it is a #rrggbb hex. */
|
|
15
16
|
readonly accentColor?: string;
|
|
17
|
+
/** Colors of the mail's tone; when set, the top line and the header chip use them. */
|
|
18
|
+
readonly tone?: ToneColors;
|
|
19
|
+
/** Escaped brand row built in code; replaces the header region (in-code layout only). */
|
|
20
|
+
readonly brandHeaderHtml?: string;
|
|
21
|
+
/** Plain text of the header chip, escaped here. */
|
|
22
|
+
readonly headerChip?: string;
|
|
23
|
+
/** Plain text above the card's footer links, escaped here. */
|
|
24
|
+
readonly footerNote?: string;
|
|
25
|
+
/** Escaped site and unsubscribe links from buildMailBandLinksHtml. */
|
|
26
|
+
readonly bandLinksHtml?: string;
|
|
27
|
+
/** Escaped platform line from buildMailFooterHtml; rendered below the card, never editable. */
|
|
28
|
+
readonly footerHtml?: string;
|
|
16
29
|
};
|
|
17
30
|
export declare function renderStructuredLayoutMjml(input: StructuredLayoutInput): string;
|
|
@@ -2,72 +2,107 @@
|
|
|
2
2
|
// a fixed in-code MJML frame, so a tenant layout never contributes MJML source.
|
|
3
3
|
import { compileDocumentBodyToHandlebars, isHexColor, mailLayoutAppearance, readLayoutRegions, } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
|
|
4
4
|
import { escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
|
|
5
|
+
import { headerRowHtml } from "./mail-chrome.js";
|
|
5
6
|
// The logo of a mail layout and of a mail body is capped here; the editors show the same width.
|
|
6
7
|
export const MAIL_IMAGE_MAX_WIDTH_PX = 180;
|
|
8
|
+
const MAIL_HEADER_IMAGE_MAX_HEIGHT_PX = 64;
|
|
9
|
+
const CARD_BORDER = "#DCDFDA";
|
|
10
|
+
const BAND_BACKGROUND = "#FBFBFA";
|
|
11
|
+
const BAND_BORDER = "#ECEDEA";
|
|
7
12
|
// Appearance values are enum members or a strict hex (see mailLayoutAppearance / isHexColor),
|
|
8
13
|
// so interpolating them into the frame cannot inject MJML; the escaping keeps that if the schema widens.
|
|
9
|
-
function structuredLayoutFrame(appearance,
|
|
10
|
-
const accentLine =
|
|
11
|
-
? `<mj-section background-color="${escapeHtmlAttr(
|
|
14
|
+
function structuredLayoutFrame(appearance, lineColor) {
|
|
15
|
+
const accentLine = lineColor !== undefined && isHexColor(lineColor)
|
|
16
|
+
? `<mj-section background-color="${escapeHtmlAttr(lineColor)}" border-radius="14px 14px 0 0" padding="0">
|
|
12
17
|
<mj-column padding="0">
|
|
13
18
|
<mj-spacer height="4px" padding="0" />
|
|
14
19
|
</mj-column>
|
|
15
20
|
</mj-section>`
|
|
16
21
|
: "";
|
|
17
|
-
return `<mjml>
|
|
22
|
+
return `<mjml lang="{{lang}}">
|
|
18
23
|
<mj-head>
|
|
19
24
|
<mj-title>{{subject}}</mj-title>
|
|
20
25
|
<mj-preview>{{previewText}}</mj-preview>
|
|
21
26
|
<mj-attributes>
|
|
22
27
|
<mj-all font-family="${escapeHtmlAttr(appearance.fontFamily)}" />
|
|
23
|
-
<mj-text font-size="
|
|
28
|
+
<mj-text font-size="16px" color="#2A2F3A" line-height="1.65" padding="0" />
|
|
24
29
|
</mj-attributes>
|
|
25
30
|
</mj-head>
|
|
26
31
|
<mj-body width="${appearance.width}px" background-color="${escapeHtmlAttr(appearance.outerBackground)}">
|
|
32
|
+
<mj-section padding="0">
|
|
33
|
+
<mj-column>
|
|
34
|
+
<mj-spacer height="48px" padding="0" />
|
|
35
|
+
</mj-column>
|
|
36
|
+
</mj-section>
|
|
37
|
+
<mj-wrapper padding="0" border="1px solid ${CARD_BORDER}" border-radius="14px" background-color="#ffffff">
|
|
27
38
|
${accentLine}
|
|
28
|
-
{{#if
|
|
29
|
-
<mj-section
|
|
39
|
+
{{#if headerRowHtml}}
|
|
40
|
+
<mj-section padding="36px 40px 0 40px">
|
|
30
41
|
<mj-column>
|
|
31
|
-
<mj-text>{{{
|
|
42
|
+
<mj-text>{{{headerRowHtml}}}</mj-text>
|
|
32
43
|
</mj-column>
|
|
33
44
|
</mj-section>
|
|
34
45
|
{{/if}}
|
|
35
|
-
<mj-section
|
|
46
|
+
<mj-section padding="24px 40px 32px 40px">
|
|
36
47
|
<mj-column>
|
|
37
48
|
<mj-text>{{{content}}}</mj-text>
|
|
38
49
|
</mj-column>
|
|
39
50
|
</mj-section>
|
|
40
51
|
{{#if regionSignature}}
|
|
41
|
-
<mj-section
|
|
52
|
+
<mj-section padding="0 40px 32px 40px">
|
|
42
53
|
<mj-column>
|
|
43
54
|
<mj-text>{{{regionSignature}}}</mj-text>
|
|
44
55
|
</mj-column>
|
|
45
56
|
</mj-section>
|
|
46
57
|
{{/if}}
|
|
47
|
-
{{#if
|
|
48
|
-
<mj-section background-color="${
|
|
58
|
+
{{#if hasBand}}
|
|
59
|
+
<mj-section background-color="${BAND_BACKGROUND}" border-top="1px solid ${BAND_BORDER}" border-radius="0 0 14px 14px" padding="20px 40px 24px 40px">
|
|
49
60
|
<mj-column>
|
|
50
|
-
<mj-text font-size="
|
|
61
|
+
<mj-text font-size="13px" color="#5A6170">{{#if footerNote}}<p style="margin:0">{{footerNote}}</p>{{/if}}{{#if bandLinksHtml}}<p style="margin:0">{{{bandLinksHtml}}}</p>{{/if}}{{{regionFooter}}}</mj-text>
|
|
51
62
|
</mj-column>
|
|
52
63
|
</mj-section>
|
|
53
64
|
{{/if}}
|
|
65
|
+
</mj-wrapper>
|
|
66
|
+
<mj-section padding="20px 24px 48px 24px">
|
|
67
|
+
<mj-column>
|
|
68
|
+
<mj-text font-size="12px" color="#5A6170" align="center">{{{mailFooterHtml}}}</mj-text>
|
|
69
|
+
</mj-column>
|
|
70
|
+
</mj-section>
|
|
54
71
|
</mj-body>
|
|
55
72
|
</mjml>`;
|
|
56
73
|
}
|
|
74
|
+
function topLineColor(input, appearance) {
|
|
75
|
+
if (input.tone !== undefined)
|
|
76
|
+
return input.tone.bar;
|
|
77
|
+
return appearance.accentLine ? input.accentColor : undefined;
|
|
78
|
+
}
|
|
57
79
|
export function renderStructuredLayoutMjml(input) {
|
|
58
80
|
const regions = readLayoutRegions(input.layoutHtml);
|
|
59
|
-
const renderRegion = (region) => region.trim() === ""
|
|
81
|
+
const renderRegion = (region, maxHeightPx) => region.trim() === ""
|
|
60
82
|
? ""
|
|
61
83
|
: input.compile(compileDocumentBodyToHandlebars(region, {}, {
|
|
62
84
|
...(input.resourceSrc && { resourceSrc: input.resourceSrc }),
|
|
63
85
|
resourceImageMaxWidthPx: MAIL_IMAGE_MAX_WIDTH_PX,
|
|
86
|
+
...(maxHeightPx !== undefined && { resourceImageMaxHeightPx: maxHeightPx }),
|
|
64
87
|
}))(input.context);
|
|
65
|
-
const
|
|
88
|
+
const appearance = input.appearance ?? mailLayoutAppearance({});
|
|
89
|
+
const frame = structuredLayoutFrame(appearance, topLineColor(input, appearance));
|
|
90
|
+
const headerLeft = input.brandHeaderHtml ?? renderRegion(regions.header, MAIL_HEADER_IMAGE_MAX_HEIGHT_PX);
|
|
91
|
+
const chip = input.headerChip ?? "";
|
|
92
|
+
const regionFooter = renderRegion(regions.footer);
|
|
93
|
+
const footerNote = input.footerNote?.trim() ?? "";
|
|
94
|
+
const bandLinksHtml = input.bandLinksHtml ?? "";
|
|
66
95
|
return input.compile(frame)({
|
|
67
96
|
...input.context,
|
|
68
97
|
content: input.contentHtml,
|
|
69
|
-
|
|
98
|
+
headerRowHtml: input.tone === undefined || chip.trim() === ""
|
|
99
|
+
? headerLeft
|
|
100
|
+
: headerRowHtml(headerLeft, chip, input.tone),
|
|
70
101
|
regionSignature: renderRegion(regions.signature),
|
|
71
|
-
regionFooter
|
|
102
|
+
regionFooter,
|
|
103
|
+
footerNote,
|
|
104
|
+
bandLinksHtml,
|
|
105
|
+
mailFooterHtml: input.footerHtml ?? "",
|
|
106
|
+
hasBand: regionFooter !== "" || footerNote !== "" || bandLinksHtml !== "",
|
|
72
107
|
});
|
|
73
108
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-mail-html",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.365.1",
|
|
4
4
|
"description": "Renderer-Foundation plugin for branded HTML emails: mail-kind templates (document HTML + subject meta) rendered with isolated Handlebars into an MJML layout, plus plain text via html-to-text. Templates resolved from template-resolver.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -19,16 +19,16 @@
|
|
|
19
19
|
}
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@cosmicdrift/kumiko-bundled-features": "0.
|
|
23
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
24
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
25
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
22
|
+
"@cosmicdrift/kumiko-bundled-features": "0.365.1",
|
|
23
|
+
"@cosmicdrift/kumiko-framework": "0.365.1",
|
|
24
|
+
"@cosmicdrift/kumiko-headless": "0.365.1",
|
|
25
|
+
"@cosmicdrift/kumiko-types": "0.365.1",
|
|
26
26
|
"handlebars": "^4.7.8",
|
|
27
27
|
"html-to-text": "^9.0.5",
|
|
28
28
|
"mjml": "^5.2.2"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
|
-
"@cosmicdrift/kumiko-testing": "0.
|
|
31
|
+
"@cosmicdrift/kumiko-testing": "0.365.1",
|
|
32
32
|
"@types/html-to-text": "^9.0.4",
|
|
33
33
|
"@types/mjml": "^5.0.0"
|
|
34
34
|
},
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,28 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.365.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Structured mail layouts render as a card with a footer band",
|
|
6
|
+
"detail": "The structured frame now draws a rounded white card with a border on the outer background. The top line sits inside the card, the footer region goes into a grey band at the bottom of the card, and the platform line from the footer resolver sits below the card. The in-code layout uses 640 px width and #ecedea as outer background and shows the tenant logo at 28 px height beside the tenant name, or an initial tile in the accent colour when no logo is set. Mail headings carry inline sizes (h1 30 px bold) and small text renders at 13 px. Existing structured layouts change their look accordingly; mjml layouts are unchanged."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.365.0",
|
|
10
|
+
"type": "improvement",
|
|
11
|
+
"title": "Mail meta headerChip, tone and footerNote",
|
|
12
|
+
"detail": "A mail template can set meta.headerChip (a chip right of the brand row), meta.tone (accent, info, success, warning, danger or neutral; colours the chip, the top line of the card and the steps block) and meta.footerNote (a line above the site and unsubscribe links in the card band). All three accept placeholders and are rendered escaped; an unknown tone falls back to the accent colour. The band links the tenant site from the branding siteUrl next to the unsubscribe link. createRendererMailHtmlFeature({ brandSuffix }) adds a word after the tenant name in the brand row."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.365.0",
|
|
16
|
+
"type": "improvement",
|
|
17
|
+
"title": "createRendererMailHtmlFeature takes a footer resolver for imprint, privacy and platform line",
|
|
18
|
+
"detail": "createRendererMailHtmlFeature({ footer }) resolves a footer per mail from locale and renderer context: an optional text line plus imprint and privacy links. The renderer appends it below every layout, the in-code one, mjml layouts and structured tenant layouts, so a tenant cannot edit or delete it. Values are escaped and only http(s) and mailto links are kept. The imprint URL also fills the {{imprint}} system placeholder, and a layout region that already links {{imprint}} or {{unsubscribeUrl}} is not doubled. The unsubscribe link is labelled in the mail language (de, en, es, English otherwise). Without a resolver only the unsubscribe link is added, as before."
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"version": "0.365.0",
|
|
22
|
+
"type": "improvement",
|
|
23
|
+
"title": "Without a stored layout mails use the tenant branding logo and accent colour",
|
|
24
|
+
"detail": "When no tenant or system layout exists, the renderer no longer falls back to a bare MJML frame. It renders the structured frame with the branding logo in the header (or the tenant name when no logo is set), the accent line and buttons in the branding accent colour, and the platform footer. Layout HTML now carries lang from the mail locale instead of lang=\"und\"."
|
|
25
|
+
},
|
|
2
26
|
{
|
|
3
27
|
"version": "0.364.0",
|
|
4
28
|
"type": "fix",
|