@cosmicdrift/kumiko-renderer-mail-html 0.363.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>
@@ -11,6 +11,10 @@ export declare function compileDocumentSource(hbs: typeof Handlebars, source: st
11
11
  }): HandlebarsTemplateDelegate;
12
12
  export declare function readDocumentFormat(ctx: RendererContext, templateLocale: string): Promise<DocumentFormat>;
13
13
  export declare function readTenantName(ctx: RendererContext): Promise<string>;
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";
14
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>;
15
19
  export declare function collectRegionResourceKeys(regions: LayoutRegions): readonly string[];
16
20
  export declare function mergeResourceFileRefs(bodyRefs: ReadonlyMap<string, string>, layoutRefs: ReadonlyMap<string, string>): ReadonlyMap<string, string>;
@@ -1,16 +1,19 @@
1
1
  import { BRANDING_QN } from "@cosmicdrift/kumiko-bundled-features/managed-pages";
2
- import { collectDocumentResourceKeys, TemplateNotFoundError, } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
2
+ import { collectDocumentResourceKeys, isHexColor, sanitizeDocumentHtml, TemplateNotFoundError, } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
3
3
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
4
4
  import { isUuid, SYSTEM_TENANT_ID, TENANT_CURRENCY_CONFIG_KEY, } from "@cosmicdrift/kumiko-framework/engine";
5
5
  import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
6
+ import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
6
7
  import { formatMoney, resolveSafeMoneyLocale } from "@cosmicdrift/kumiko-headless";
7
8
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
8
9
  import Handlebars from "handlebars";
9
10
  // Handlebars instance, template resolution and resource lookup shared by the
10
11
  // mail and PDF renderers, so both format money/dates and find images the same way.
12
+ const documentKitLogger = createFallbackLogger("renderer-mail-html");
11
13
  const FALLBACK_CURRENCY = "EUR";
12
14
  const SAFE_LINK_PROTOCOL = /^(https?:|mailto:)/i;
13
15
  const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
16
+ const DATE_TIME_WITH_OFFSET = /^\d{4}-\d{2}-\d{2}T.*[+-]\d{2}(:?\d{2})?$/;
14
17
  function isPlainRecord(value) {
15
18
  return typeof value === "object" && value !== null && !Array.isArray(value);
16
19
  }
@@ -39,6 +42,13 @@ export function createDocumentHandlebars(format) {
39
42
  if (typeof value === "string" && DATE_ONLY.test(value)) {
40
43
  return Temporal.PlainDate.from(value).toLocaleString(locale, { dateStyle: "long" });
41
44
  }
45
+ // An explicit offset names the sender's zone: the calendar date is the wall-clock
46
+ // date of the string, not the date at UTC.
47
+ if (typeof value === "string" && DATE_TIME_WITH_OFFSET.test(value)) {
48
+ return Temporal.PlainDateTime.from(value)
49
+ .toPlainDate()
50
+ .toLocaleString(locale, { dateStyle: "long" });
51
+ }
42
52
  const instant = typeof value === "number"
43
53
  ? Temporal.Instant.fromEpochMilliseconds(value)
44
54
  : Temporal.Instant.from(value);
@@ -48,10 +58,44 @@ export function createDocumentHandlebars(format) {
48
58
  return String(value);
49
59
  }
50
60
  });
61
+ hbs.registerHelper("richText", (value) => {
62
+ if (typeof value !== "string")
63
+ return "";
64
+ const sanitized = sanitizeDocumentHtml(value);
65
+ if (sanitized.ok)
66
+ return new hbs.SafeString(sanitized.html);
67
+ // Rejected markup renders as visible text (escaped by Handlebars) instead of vanishing.
68
+ documentKitLogger.warn("richText value rejected by sanitizer, rendered as escaped text", {
69
+ violations: sanitized.violations.map((item) => item.code),
70
+ });
71
+ return value;
72
+ });
51
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
+ });
52
77
  return hbs;
53
78
  }
54
- const KNOWN_HELPERS = { each: true, if: true, money: true, date: true, link: true };
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
+ }
90
+ const KNOWN_HELPERS = {
91
+ each: true,
92
+ if: true,
93
+ money: true,
94
+ date: true,
95
+ link: true,
96
+ richText: true,
97
+ stepIs: true,
98
+ };
55
99
  export function compileDocumentSource(hbs, source, options = {}) {
56
100
  return hbs.compile(source, {
57
101
  knownHelpers: KNOWN_HELPERS,
@@ -78,7 +122,14 @@ export async function readDocumentFormat(ctx, templateLocale) {
78
122
  export async function readTenantName(ctx) {
79
123
  return (await readConfigText(ctx, BRANDING_QN.title)) ?? "";
80
124
  }
81
- const LOGO_RESOURCE_KEY = "logo";
125
+ export async function readBrandingAccentColor(ctx) {
126
+ const value = await readConfigText(ctx, BRANDING_QN.accentColor);
127
+ return value !== undefined && isHexColor(value) ? value : undefined;
128
+ }
129
+ export async function readBrandingSiteUrl(ctx) {
130
+ return readConfigText(ctx, BRANDING_QN.siteUrl);
131
+ }
132
+ export const LOGO_RESOURCE_KEY = "logo";
82
133
  // Lookup order: the template's own link, the system template it overrides, and for
83
134
  // "logo" the tenant's branding logo. The system lookup is skipped for system templates.
84
135
  export async function resolveResourceFileRefs(tplApi, ctx, template, keys) {
@@ -112,7 +163,7 @@ export async function resolveResourceFileRefs(tplApi, ctx, template, keys) {
112
163
  }
113
164
  // The token is a free field of the tenant admin and the send reader also reaches the system
114
165
  // tenant's files, so only a file of the rendering tenant itself counts.
115
- async function readBrandingLogo(ctx) {
166
+ export async function readBrandingLogo(ctx) {
116
167
  const fileRefId = await readConfigText(ctx, BRANDING_QN.logoFile);
117
168
  if (fileRefId === undefined || !isUuid(fileRefId))
118
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";
@@ -8,3 +8,4 @@ export declare function buildRenderedPdfAttachments(args: {
8
8
  readonly renderFileName: (pattern: string) => string;
9
9
  }): readonly RenderedPdfAttachmentRef[];
10
10
  export declare function combineMailAttachments(slug: string, inline: readonly InlineAttachmentRef[], files: readonly FileAttachmentRef[], renderedPdfs: readonly RenderedPdfAttachmentRef[]): readonly MailAttachmentRef[];
11
+ export declare function mergeInheritedAttachments<T>(own: readonly T[], inherited: readonly T[], fileNameOf: (attachment: T) => string): readonly T[];
@@ -1,9 +1,10 @@
1
- import { MAX_EMAIL_ATTACHMENTS, MAX_RENDERED_PDF_VARIABLES_BYTES, sanitizePdfAttachmentFileName, } from "@cosmicdrift/kumiko-bundled-features/delivery";
1
+ import { MAX_EMAIL_ATTACHMENTS, MAX_RENDERED_PDF_VARIABLES_BYTES, sanitizePdfAttachmentFileName, withoutDeliveryEnvelopeKeys, } from "@cosmicdrift/kumiko-bundled-features/delivery";
2
2
  import { RendererError, } from "@cosmicdrift/kumiko-bundled-features/renderer-foundation";
3
3
  export function buildRenderedPdfAttachments(args) {
4
- const { slug, links, variables, locale, renderFileName } = args;
4
+ const { slug, links, locale, renderFileName } = args;
5
5
  if (links.length === 0)
6
6
  return [];
7
+ const variables = withoutDeliveryEnvelopeKeys(args.variables);
7
8
  if (Buffer.byteLength(JSON.stringify(variables)) > MAX_RENDERED_PDF_VARIABLES_BYTES) {
8
9
  throw new RendererError(`renderer-mail-html: the variables of mail "${slug}" exceed ${MAX_RENDERED_PDF_VARIABLES_BYTES} bytes as JSON, too large to hand to the attached PDF`, "invalid_payload");
9
10
  }
@@ -24,3 +25,13 @@ export function combineMailAttachments(slug, inline, files, renderedPdfs) {
24
25
  }
25
26
  return attachments;
26
27
  }
28
+ // A tenant override carries its own links only; what the overridden system mail attaches
29
+ // must not silently vanish. Attachments are slot-less here, so an own attachment with the
30
+ // same file name replaces the inherited one.
31
+ export function mergeInheritedAttachments(own, inherited, fileNameOf) {
32
+ const ownNames = new Set(own.map((attachment) => fileNameOf(attachment).toLowerCase()));
33
+ return [
34
+ ...inherited.filter((attachment) => !ownNames.has(fileNameOf(attachment).toLowerCase())),
35
+ ...own,
36
+ ];
37
+ }
@@ -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.
@@ -10,24 +10,39 @@
10
10
  // 4. mjml -> email HTML, html-to-text -> plain-text alternative.
11
11
  // Handlebars runs in an isolated instance with a fixed helper set.
12
12
  import { RendererError, } from "@cosmicdrift/kumiko-bundled-features/renderer-foundation";
13
- import { collectDocumentResourceKeys, compileDocumentBodyToHandlebars, createTemplateResolverApi, fileAttachmentsOf, imageLinksOf, isMailTemplateMeta, parseTemplateMeta, pdfAttachmentLinksOf, readLayoutRegions, resolveTemplateVariables, sanitizeDocumentHtml, sanitizeLayoutHtml, TemplateNotFoundError, } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
13
+ import { collectDocumentResourceKeys, compileDocumentBodyToHandlebars, createTemplateResolverApi, fileAttachmentsOf, imageLinksOf, isMailTemplateMeta, mailLayoutAppearance, parseTemplateMeta, pdfAttachmentLinksOf, readLayoutRegions, resolveTemplateVariables, sanitizeDocumentHtml, sanitizeLayoutHtml, TemplateNotFoundError, } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
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, readDocumentFormat, readTenantName, resolveResourceFileRefs, } from "./document-kit.js";
19
- import { buildRenderedPdfAttachments, combineMailAttachments } from "./mail-attachments.js";
20
- import { renderStructuredLayoutMjml } from "./structured-layout.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
+ 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";
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.
@@ -53,7 +68,7 @@ async function resolveLayout(tplApi, ctx, meta, locale) {
53
68
  useSystemLayout = true;
54
69
  }
55
70
  else {
56
- useSystemLayout = layout.contentFormat === "mjml";
71
+ useSystemLayout = true;
57
72
  }
58
73
  }
59
74
  if (useSystemLayout) {
@@ -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,12 +178,18 @@ 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) {
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), {
167
185
  resourceSrc: (key) => (resourceFileRefs.has(key) ? `cid:${key}` : undefined),
186
+ resourceImageMaxWidthPx: MAIL_IMAGE_MAX_WIDTH_PX,
168
187
  alignmentAsInlineStyle: true,
188
+ buttonAsInlineStyle: true,
189
+ tableAsInlineStyle: true,
190
+ headingAsInlineStyle: true,
191
+ stepsColor,
192
+ ...(buttonInlineStyle !== undefined && { buttonInlineStyle }),
169
193
  });
170
194
  return {
171
195
  contentHtml: compileDocumentSource(hbs, handlebarsSource)(context),
@@ -200,77 +224,91 @@ function renderSubjectLines(hbs, meta, slug, context) {
200
224
  throw new RendererError(`renderer-mail-html: rendering subject or preview text of "${slug}" failed — ${describe(e)}`, "other");
201
225
  }
202
226
  }
203
- function assembleMailAttachments(args) {
204
- const { slug, template, resourceFileRefs, variables, locale, renderFileName } = args;
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
+ }
245
+ // Same fallback as for images (resolveResourceFileRefs): a tenant override of a system mail
246
+ // keeps the attachments the system mail carries.
247
+ async function resolveSystemAttachments(tplApi, template) {
248
+ if (template.scope !== "tenant")
249
+ return {};
250
+ try {
251
+ const system = await tplApi.resolveTemplate({
252
+ tenantId: SYSTEM_TENANT_ID,
253
+ slug: template.slug,
254
+ kind: template.kind,
255
+ locale: template.locale,
256
+ });
257
+ return {
258
+ fileAttachments: system.fileAttachments ?? [],
259
+ pdfAttachmentLinks: system.pdfAttachmentLinks ?? [],
260
+ };
261
+ }
262
+ catch (e) {
263
+ if (e instanceof TemplateNotFoundError)
264
+ return {};
265
+ throw new RendererError(`renderer-mail-html: resolving the system attachments of "${template.slug}" failed — ${describe(e)}`, "other");
266
+ }
267
+ }
268
+ async function assembleMailAttachments(args) {
269
+ const { tplApi, slug, template, resourceFileRefs, variables, locale, renderFileName } = args;
270
+ const inherited = await resolveSystemAttachments(tplApi, template);
205
271
  const inlineAttachments = [...resourceFileRefs].map(([cid, fileRefId]) => ({ kind: "inline", cid, fileRefId }));
206
272
  const renderedPdfs = buildRenderedPdfAttachments({
207
273
  slug,
208
- links: template.pdfAttachmentLinks ?? [],
274
+ links: mergeInheritedAttachments(template.pdfAttachmentLinks ?? [], inherited.pdfAttachmentLinks ?? [], (link) => link.fileName),
209
275
  variables,
210
276
  locale,
211
277
  renderFileName,
212
278
  });
213
- return combineMailAttachments(slug, inlineAttachments, template.fileAttachments ?? [], renderedPdfs);
279
+ return combineMailAttachments(slug, inlineAttachments, mergeInheritedAttachments(template.fileAttachments ?? [], inherited.fileAttachments ?? [], (file) => file.fileName), renderedPdfs);
214
280
  }
215
- export async function mailHtmlRender(req, ctx) {
216
- if (req.kind !== "mail") {
217
- // Foundation selects plugins only for matching kinds; defensive guard.
218
- throw new RendererError(`renderer-mail-html supports only kind="mail", got "${req.kind}"`, "invalid_payload");
219
- }
220
- const { slug, locale, variables, unsubscribeUrl, draft } = req.payload;
221
- const tplApi = createTemplateResolverApi(ctx.db);
222
- const { template, meta } = await resolveMailTemplate(tplApi, ctx, slug, locale, draft);
223
- const layout = await resolveLayout(tplApi, ctx, meta, locale);
224
- const [format, tenantName] = await Promise.all([
225
- readDocumentFormat(ctx, locale),
226
- readTenantName(ctx),
227
- ]);
228
- const hbs = createDocumentHandlebars(format);
229
- const context = {
230
- ...variables,
231
- tenantName,
232
- signature: "",
233
- imprint: "",
234
- unsubscribeUrl: unsubscribeUrl ?? "",
235
- };
236
- const body = await renderMailBody(tplApi, ctx, template, hbs, context);
237
- const { contentHtml } = body;
238
- const layoutFileRefs = await resolveLayoutFileRefs(tplApi, ctx, layout);
239
- const resourceFileRefs = mergeResourceFileRefs(body.resourceFileRefs, layoutFileRefs);
240
- const attachments = assembleMailAttachments({
241
- slug,
242
- template,
243
- resourceFileRefs,
244
- variables,
245
- locale,
246
- renderFileName: (pattern) => renderFileNamePattern(hbs, pattern, slug, context),
247
- });
248
- const { subject, previewText } = renderSubjectLines(hbs, meta, slug, context);
249
- let mjmlSource;
281
+ function compileLayoutMjml(args) {
282
+ const { layout, hbs, contentHtml, appearance, accentColor, resourceFileRefs, layoutContext } = args;
250
283
  try {
251
- const layoutContext = {
252
- subject,
253
- previewText: previewText ?? "",
254
- tenantName,
255
- signature: "",
256
- imprint: "",
257
- unsubscribeUrl: unsubscribeUrl ?? "",
258
- };
259
- mjmlSource =
260
- layout.format === "html"
261
- ? renderStructuredLayoutMjml({
262
- layoutHtml: layout.content,
263
- contentHtml,
264
- context: layoutContext,
265
- compile: (source) => compileDocumentSource(hbs, source),
266
- resourceSrc: (key) => (resourceFileRefs.has(key) ? `cid:${key}` : undefined),
267
- })
268
- : compileDocumentSource(hbs, layout.content)({ ...layoutContext, content: contentHtml });
284
+ return layout.format === "html"
285
+ ? renderStructuredLayoutMjml({
286
+ layoutHtml: layout.content,
287
+ contentHtml,
288
+ context: layoutContext,
289
+ compile: (source) => compileDocumentSource(hbs, source),
290
+ resourceSrc: (key) => (resourceFileRefs.has(key) ? `cid:${key}` : undefined),
291
+ ...(appearance !== undefined && { appearance }),
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,
299
+ })
300
+ : compileDocumentSource(hbs, layout.content)({
301
+ ...layoutContext,
302
+ content: contentHtml,
303
+ mailFooterHtml: args.footerHtml,
304
+ });
269
305
  }
270
306
  catch (e) {
271
307
  // "other": the caller's payload is fine, the layout template is broken.
272
308
  throw new RendererError(`renderer-mail-html: Handlebars compile failed for layout — ${describe(e)}`, "other");
273
309
  }
310
+ }
311
+ async function compileMjmlToHtml(mjmlSource) {
274
312
  // mjml@5 is async
275
313
  const mjmlResult = await mjml2html(mjmlSource, { validationLevel: "soft" });
276
314
  const errors = mjmlResult.errors ?? [];
@@ -281,21 +319,29 @@ export async function mailHtmlRender(req, ctx) {
281
319
  .join("; ");
282
320
  throw new RendererError(`renderer-mail-html: MJML compile produced ${errors.length} error(s) — ${firstErrors}`, "other");
283
321
  }
284
- const text = htmlToText(mjmlResult.html, {
322
+ return mjmlResult.html;
323
+ }
324
+ function htmlToPlainText(html) {
325
+ return htmlToText(html, {
285
326
  wordwrap: 80,
286
327
  selectors: [
287
328
  { selector: "a", options: { hideLinkHrefIfSameAsText: true } },
288
329
  { selector: "img", format: "skip" },
289
330
  ],
290
331
  });
332
+ }
333
+ function buildMailRenderResponse(args) {
334
+ const { subject, previewText, html, text, meta, attachments, template, layout, isDraft } = args;
291
335
  return {
292
336
  kind: "mail",
293
337
  subject,
294
- html: mjmlResult.html,
338
+ html,
295
339
  text,
296
340
  ...(previewText === undefined ? {} : { previewText }),
341
+ ...(meta.senderName !== undefined && { fromName: meta.senderName }),
342
+ ...(meta.replyTo !== undefined && { replyTo: meta.replyTo }),
297
343
  ...(attachments.length > 0 && { attachments }),
298
- ...(draft === undefined && {
344
+ ...(!isDraft && {
299
345
  template: {
300
346
  templateId: template.id,
301
347
  templateVersion: template.version,
@@ -305,3 +351,146 @@ export async function mailHtmlRender(req, ctx) {
305
351
  ...(layout.ref === null ? {} : { layout: layout.ref }),
306
352
  };
307
353
  }
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 = {}) {
415
+ if (req.kind !== "mail") {
416
+ // Foundation selects plugins only for matching kinds; defensive guard.
417
+ throw new RendererError(`renderer-mail-html supports only kind="mail", got "${req.kind}"`, "invalid_payload");
418
+ }
419
+ const { slug, locale, variables, unsubscribeUrl, draft } = req.payload;
420
+ const tplApi = createTemplateResolverApi(ctx.db);
421
+ const { template, meta } = await resolveMailTemplate(tplApi, ctx, slug, locale, draft);
422
+ const [format, tenantName, footer, siteUrl, brandSuffix] = await Promise.all([
423
+ readDocumentFormat(ctx, locale),
424
+ readTenantName(ctx),
425
+ resolveMailFooter(options, ctx, locale),
426
+ readBrandingSiteUrl(ctx),
427
+ resolveBrandSuffix(options, ctx, locale),
428
+ ]);
429
+ const layout = (await resolveLayout(tplApi, ctx, meta, locale)) ?? (await inCodeLayout(ctx));
430
+ const hbs = createDocumentHandlebars(format);
431
+ const systemPlaceholders = {
432
+ tenantName,
433
+ signature: "",
434
+ imprint: safeFooterUrl(footer?.imprint),
435
+ unsubscribeUrl: unsubscribeUrl ?? "",
436
+ };
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);
441
+ const { contentHtml } = body;
442
+ const layoutFileRefs = await resolveLayoutFileRefs(tplApi, ctx, layout);
443
+ const resourceFileRefs = mergeResourceFileRefs(body.resourceFileRefs, layoutFileRefs);
444
+ const attachments = await assembleMailAttachments({
445
+ tplApi,
446
+ slug,
447
+ template,
448
+ resourceFileRefs,
449
+ variables,
450
+ locale,
451
+ renderFileName: (pattern) => renderFileNamePattern(hbs, pattern, slug, context),
452
+ });
453
+ const { subject, previewText } = renderSubjectLines(hbs, meta, slug, context);
454
+ const mjmlSource = compileLayoutMjml({
455
+ layout,
456
+ hbs,
457
+ contentHtml,
458
+ appearance,
459
+ accentColor,
460
+ resourceFileRefs,
461
+ layoutContext: {
462
+ ...systemPlaceholders,
463
+ lang: locale,
464
+ subject,
465
+ previewText: previewText ?? "",
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
+ }),
483
+ });
484
+ const html = await compileMjmlToHtml(mjmlSource);
485
+ return buildMailRenderResponse({
486
+ subject,
487
+ previewText,
488
+ html,
489
+ text: htmlToPlainText(html),
490
+ meta,
491
+ attachments,
492
+ template,
493
+ layout,
494
+ isDraft: draft !== undefined,
495
+ });
496
+ }
@@ -1,11 +1,30 @@
1
+ import { type MailLayoutAppearance } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
2
+ import { type ToneColors } from "./mail-chrome.js";
3
+ export declare const MAIL_IMAGE_MAX_WIDTH_PX = 180;
1
4
  export type StructuredLayoutInput = {
2
5
  readonly layoutHtml: string;
3
6
  readonly contentHtml: string;
4
- /** Layout context (tenantName, subject, previewText, signature, imprint, unsubscribeUrl). */
7
+ /** Layout context (lang, tenantName, subject, previewText, signature, imprint, unsubscribeUrl). */
5
8
  readonly context: Readonly<Record<string, unknown>>;
6
9
  /** Compiles Handlebars source with the renderer's isolated instance and helper set. */
7
10
  readonly compile: (source: string) => (context: Readonly<Record<string, unknown>>) => string;
8
11
  /** src for `img[data-resource]` in the regions; without it layout images are dropped. */
9
12
  readonly resourceSrc?: (key: string) => string | undefined;
13
+ /** Parsed layout appearance; defaults apply when missing. */
14
+ readonly appearance?: MailLayoutAppearance;
15
+ /** Tenant branding accent color for the accent line; ignored unless it is a #rrggbb hex. */
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;
10
29
  };
11
30
  export declare function renderStructuredLayoutMjml(input: StructuredLayoutInput): string;
@@ -1,54 +1,108 @@
1
1
  // Renders a structured (html) mail layout: the tenant-editable regions go into
2
2
  // a fixed in-code MJML frame, so a tenant layout never contributes MJML source.
3
- import { compileDocumentBodyToHandlebars, readLayoutRegions, } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
4
- const STRUCTURED_LAYOUT_FRAME = `<mjml>
3
+ import { compileDocumentBodyToHandlebars, isHexColor, mailLayoutAppearance, readLayoutRegions, } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
4
+ import { escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
5
+ import { headerRowHtml } from "./mail-chrome.js";
6
+ // The logo of a mail layout and of a mail body is capped here; the editors show the same width.
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";
12
+ // Appearance values are enum members or a strict hex (see mailLayoutAppearance / isHexColor),
13
+ // so interpolating them into the frame cannot inject MJML; the escaping keeps that if the schema widens.
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">
17
+ <mj-column padding="0">
18
+ <mj-spacer height="4px" padding="0" />
19
+ </mj-column>
20
+ </mj-section>`
21
+ : "";
22
+ return `<mjml lang="{{lang}}">
5
23
  <mj-head>
6
24
  <mj-title>{{subject}}</mj-title>
7
25
  <mj-preview>{{previewText}}</mj-preview>
8
26
  <mj-attributes>
9
- <mj-all font-family="Helvetica, Arial, sans-serif" />
10
- <mj-text font-size="14px" color="#333" line-height="1.6" />
27
+ <mj-all font-family="${escapeHtmlAttr(appearance.fontFamily)}" />
28
+ <mj-text font-size="16px" color="#2A2F3A" line-height="1.65" padding="0" />
11
29
  </mj-attributes>
12
30
  </mj-head>
13
- <mj-body background-color="#f5f5f5">
14
- {{#if regionHeader}}
15
- <mj-section background-color="#ffffff" padding="20px">
31
+ <mj-body width="${appearance.width}px" background-color="${escapeHtmlAttr(appearance.outerBackground)}">
32
+ <mj-section padding="0">
16
33
  <mj-column>
17
- <mj-text>{{{regionHeader}}}</mj-text>
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">
38
+ ${accentLine}
39
+ {{#if headerRowHtml}}
40
+ <mj-section padding="36px 40px 0 40px">
41
+ <mj-column>
42
+ <mj-text>{{{headerRowHtml}}}</mj-text>
18
43
  </mj-column>
19
44
  </mj-section>
20
45
  {{/if}}
21
- <mj-section background-color="#ffffff" padding="20px">
46
+ <mj-section padding="24px 40px 32px 40px">
22
47
  <mj-column>
23
- <mj-raw>{{{content}}}</mj-raw>
48
+ <mj-text>{{{content}}}</mj-text>
24
49
  </mj-column>
25
50
  </mj-section>
26
51
  {{#if regionSignature}}
27
- <mj-section background-color="#ffffff" padding="20px">
52
+ <mj-section padding="0 40px 32px 40px">
28
53
  <mj-column>
29
54
  <mj-text>{{{regionSignature}}}</mj-text>
30
55
  </mj-column>
31
56
  </mj-section>
32
57
  {{/if}}
33
- {{#if regionFooter}}
34
- <mj-section background-color="#f5f5f5" 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">
35
60
  <mj-column>
36
- <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>
37
62
  </mj-column>
38
63
  </mj-section>
39
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>
40
71
  </mj-body>
41
72
  </mjml>`;
73
+ }
74
+ function topLineColor(input, appearance) {
75
+ if (input.tone !== undefined)
76
+ return input.tone.bar;
77
+ return appearance.accentLine ? input.accentColor : undefined;
78
+ }
42
79
  export function renderStructuredLayoutMjml(input) {
43
80
  const regions = readLayoutRegions(input.layoutHtml);
44
- const renderRegion = (region) => region.trim() === ""
81
+ const renderRegion = (region, maxHeightPx) => region.trim() === ""
45
82
  ? ""
46
- : input.compile(compileDocumentBodyToHandlebars(region, {}, input.resourceSrc && { resourceSrc: input.resourceSrc }))(input.context);
47
- return input.compile(STRUCTURED_LAYOUT_FRAME)({
83
+ : input.compile(compileDocumentBodyToHandlebars(region, {}, {
84
+ ...(input.resourceSrc && { resourceSrc: input.resourceSrc }),
85
+ resourceImageMaxWidthPx: MAIL_IMAGE_MAX_WIDTH_PX,
86
+ ...(maxHeightPx !== undefined && { resourceImageMaxHeightPx: maxHeightPx }),
87
+ }))(input.context);
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 ?? "";
95
+ return input.compile(frame)({
48
96
  ...input.context,
49
97
  content: input.contentHtml,
50
- regionHeader: renderRegion(regions.header),
98
+ headerRowHtml: input.tone === undefined || chip.trim() === ""
99
+ ? headerLeft
100
+ : headerRowHtml(headerLeft, chip, input.tone),
51
101
  regionSignature: renderRegion(regions.signature),
52
- regionFooter: renderRegion(regions.footer),
102
+ regionFooter,
103
+ footerNote,
104
+ bandLinksHtml,
105
+ mailFooterHtml: input.footerHtml ?? "",
106
+ hasBand: regionFooter !== "" || footerNote !== "" || bandLinksHtml !== "",
53
107
  });
54
108
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer-mail-html",
3
- "version": "0.363.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.363.0",
23
- "@cosmicdrift/kumiko-framework": "0.363.0",
24
- "@cosmicdrift/kumiko-headless": "0.363.0",
25
- "@cosmicdrift/kumiko-types": "0.363.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.363.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,40 @@
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
+ },
26
+ {
27
+ "version": "0.364.0",
28
+ "type": "fix",
29
+ "title": "Structured mail layouts show the mail body",
30
+ "detail": "The structured layout frame wraps the body in mj-text instead of mj-raw. With mj-raw the body HTML sat directly in the column table, browsers moved it into the column wrapper with font-size 0, and every mail on a tenant layout had an invisible body."
31
+ },
32
+ {
33
+ "version": "0.364.0",
34
+ "type": "fix",
35
+ "title": "Mail renderer keeps wall-clock dates and system attachments",
36
+ "detail": "The date helper keeps the calendar date of offset datetimes, tenant overrides of system mails keep inherited attachments, markdown templates use the system layout and delivery envelope keys are not exposed as variables."
37
+ },
2
38
  {
3
39
  "version": "0.363.0",
4
40
  "type": "breaking",