@cosmicdrift/kumiko-renderer-mail-html 0.364.0 → 0.365.0

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.
@@ -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 unsubscribeUrl}} \u00B7 <a href=\"{{link unsubscribeUrl}}\">Unsubscribe</a>{{/if}}\n </mj-text>\n </mj-column>\n </mj-section>\n </mj-body>\n</mjml>";
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>";
@@ -1,6 +1,6 @@
1
- // Default MJML layout that apps can use out of the box for mail templates. The
2
- // renderer falls back to it when neither tenant nor system has a "mail-layout"
3
- // template with the requested slug (default: "default").
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 unsubscribeUrl}} · <a href="{{link unsubscribeUrl}}">Unsubscribe</a>{{/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>
@@ -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>;
@@ -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
- const LOGO_RESOURCE_KEY = "logo";
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
- export declare function createRendererMailHtmlFeature(): FeatureDefinition;
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 the in-code DEFAULT_MAIL_LAYOUT_MJML is used, so mail works from day 1.
15
- export function createRendererMailHtmlFeature() {
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 `DEFAULT_MAIL_LAYOUT_MJML` as last resort) that `mjml()` compiles to email-safe table HTML, with `html-to-text` producing the plain-text alternative. Seed an editable system layout with `seedSystemMailLayout(db)`. Requires `renderer-foundation` + `template-resolver`.');
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 { mailHtmlRender } from "./render.js";
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
- export declare function mailHtmlRender(req: RenderRequest, ctx: RendererContext): Promise<RenderResponse>;
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 the in-code
4
- // DEFAULT_MAIL_LAYOUT_MJML).
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 { DEFAULT_MAIL_LAYOUT_MJML, DEFAULT_MAIL_LAYOUT_SLUG } from "./default-layout.js";
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
- const IN_CODE_LAYOUT = {
26
- content: DEFAULT_MAIL_LAYOUT_MJML,
27
- format: "mjml",
28
- ref: null,
29
- template: null,
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 IN_CODE_LAYOUT;
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)({ ...layoutContext, content: contentHtml });
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
- export async function mailHtmlRender(req, ctx) {
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 layout = await resolveLayout(tplApi, ctx, meta, locale);
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 context = {
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
- // Only a structured (html) layout carries appearance; an mjml layout keeps its own look.
328
- const appearance = layout.format === "html" ? mailLayoutAppearance(layout.template?.meta) : undefined;
329
- const body = await renderMailBody(tplApi, ctx, template, hbs, context, appearance?.buttonStyle);
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, accentColor) {
10
- const accentLine = appearance.accentLine && accentColor !== undefined && isHexColor(accentColor)
11
- ? `<mj-section background-color="${escapeHtmlAttr(accentColor)}" padding="0">
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="14px" color="#333" line-height="1.6" />
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 regionHeader}}
29
- <mj-section background-color="#ffffff" padding="20px">
39
+ {{#if headerRowHtml}}
40
+ <mj-section padding="36px 40px 0 40px">
30
41
  <mj-column>
31
- <mj-text>{{{regionHeader}}}</mj-text>
42
+ <mj-text>{{{headerRowHtml}}}</mj-text>
32
43
  </mj-column>
33
44
  </mj-section>
34
45
  {{/if}}
35
- <mj-section background-color="#ffffff" padding="20px">
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 background-color="#ffffff" padding="20px">
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 regionFooter}}
48
- <mj-section background-color="${escapeHtmlAttr(appearance.outerBackground)}" padding="20px">
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="11px" color="#888" align="center">{{{regionFooter}}}</mj-text>
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 frame = structuredLayoutFrame(input.appearance ?? mailLayoutAppearance({}), input.accentColor);
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
- regionHeader: renderRegion(regions.header),
98
+ headerRowHtml: input.tone === undefined || chip.trim() === ""
99
+ ? headerLeft
100
+ : headerRowHtml(headerLeft, chip, input.tone),
70
101
  regionSignature: renderRegion(regions.signature),
71
- regionFooter: renderRegion(regions.footer),
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.364.0",
3
+ "version": "0.365.0",
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.364.0",
23
- "@cosmicdrift/kumiko-framework": "0.364.0",
24
- "@cosmicdrift/kumiko-headless": "0.364.0",
25
- "@cosmicdrift/kumiko-types": "0.364.0",
22
+ "@cosmicdrift/kumiko-bundled-features": "0.365.0",
23
+ "@cosmicdrift/kumiko-framework": "0.365.0",
24
+ "@cosmicdrift/kumiko-headless": "0.365.0",
25
+ "@cosmicdrift/kumiko-types": "0.365.0",
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.364.0",
31
+ "@cosmicdrift/kumiko-testing": "0.365.0",
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",