@cosmicdrift/kumiko-renderer-mail-html 0.363.0 → 0.364.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.
@@ -11,6 +11,7 @@ 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>;
14
15
  export declare function resolveResourceFileRefs(tplApi: TemplateResolverApi, ctx: RendererContext, template: TemplateResource, keys: readonly string[]): Promise<ReadonlyMap<string, string>>;
15
16
  export declare function collectRegionResourceKeys(regions: LayoutRegions): readonly string[];
16
17
  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,29 @@ 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 : "#");
52
74
  return hbs;
53
75
  }
54
- const KNOWN_HELPERS = { each: true, if: true, money: true, date: true, link: true };
76
+ const KNOWN_HELPERS = {
77
+ each: true,
78
+ if: true,
79
+ money: true,
80
+ date: true,
81
+ link: true,
82
+ richText: true,
83
+ };
55
84
  export function compileDocumentSource(hbs, source, options = {}) {
56
85
  return hbs.compile(source, {
57
86
  knownHelpers: KNOWN_HELPERS,
@@ -78,6 +107,10 @@ export async function readDocumentFormat(ctx, templateLocale) {
78
107
  export async function readTenantName(ctx) {
79
108
  return (await readConfigText(ctx, BRANDING_QN.title)) ?? "";
80
109
  }
110
+ export async function readBrandingAccentColor(ctx) {
111
+ const value = await readConfigText(ctx, BRANDING_QN.accentColor);
112
+ return value !== undefined && isHexColor(value) ? value : undefined;
113
+ }
81
114
  const LOGO_RESOURCE_KEY = "logo";
82
115
  // Lookup order: the template's own link, the system template it overrides, and for
83
116
  // "logo" the tenant's branding logo. The system lookup is skipped for system templates.
@@ -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
+ }
package/dist/render.js CHANGED
@@ -10,14 +10,14 @@
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
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";
18
+ import { collectRegionResourceKeys, compileDocumentSource, createDocumentHandlebars, mergeResourceFileRefs, readBrandingAccentColor, readDocumentFormat, readTenantName, resolveResourceFileRefs, } from "./document-kit.js";
19
+ import { buildRenderedPdfAttachments, combineMailAttachments, mergeInheritedAttachments, } from "./mail-attachments.js";
20
+ import { MAIL_IMAGE_MAX_WIDTH_PX, renderStructuredLayoutMjml } from "./structured-layout.js";
21
21
  const LINE_BREAKS = /[\r\n]+/g;
22
22
  function describe(e) {
23
23
  return e instanceof Error ? e.message : String(e);
@@ -53,7 +53,7 @@ async function resolveLayout(tplApi, ctx, meta, locale) {
53
53
  useSystemLayout = true;
54
54
  }
55
55
  else {
56
- useSystemLayout = layout.contentFormat === "mjml";
56
+ useSystemLayout = true;
57
57
  }
58
58
  }
59
59
  if (useSystemLayout) {
@@ -160,12 +160,16 @@ async function resolveMailTemplate(tplApi, ctx, slug, locale, draft) {
160
160
  }
161
161
  // Each data-resource image is referenced by cid; the bytes are read at send time, so
162
162
  // the render result carries only file ref ids.
163
- async function renderMailBody(tplApi, ctx, template, hbs, context) {
163
+ async function renderMailBody(tplApi, ctx, template, hbs, context, buttonInlineStyle) {
164
164
  try {
165
165
  const resourceFileRefs = await resolveResourceFileRefs(tplApi, ctx, template, collectDocumentResourceKeys(template.content));
166
166
  const handlebarsSource = compileDocumentBodyToHandlebars(template.content, resolveTemplateVariables(ctx.registry, template.slug, template.variableSchema), {
167
167
  resourceSrc: (key) => (resourceFileRefs.has(key) ? `cid:${key}` : undefined),
168
+ resourceImageMaxWidthPx: MAIL_IMAGE_MAX_WIDTH_PX,
168
169
  alignmentAsInlineStyle: true,
170
+ buttonAsInlineStyle: true,
171
+ tableAsInlineStyle: true,
172
+ ...(buttonInlineStyle !== undefined && { buttonInlineStyle }),
169
173
  });
170
174
  return {
171
175
  contentHtml: compileDocumentSource(hbs, handlebarsSource)(context),
@@ -200,17 +204,104 @@ function renderSubjectLines(hbs, meta, slug, context) {
200
204
  throw new RendererError(`renderer-mail-html: rendering subject or preview text of "${slug}" failed — ${describe(e)}`, "other");
201
205
  }
202
206
  }
203
- function assembleMailAttachments(args) {
204
- const { slug, template, resourceFileRefs, variables, locale, renderFileName } = args;
207
+ // Same fallback as for images (resolveResourceFileRefs): a tenant override of a system mail
208
+ // keeps the attachments the system mail carries.
209
+ async function resolveSystemAttachments(tplApi, template) {
210
+ if (template.scope !== "tenant")
211
+ return {};
212
+ try {
213
+ const system = await tplApi.resolveTemplate({
214
+ tenantId: SYSTEM_TENANT_ID,
215
+ slug: template.slug,
216
+ kind: template.kind,
217
+ locale: template.locale,
218
+ });
219
+ return {
220
+ fileAttachments: system.fileAttachments ?? [],
221
+ pdfAttachmentLinks: system.pdfAttachmentLinks ?? [],
222
+ };
223
+ }
224
+ catch (e) {
225
+ if (e instanceof TemplateNotFoundError)
226
+ return {};
227
+ throw new RendererError(`renderer-mail-html: resolving the system attachments of "${template.slug}" failed — ${describe(e)}`, "other");
228
+ }
229
+ }
230
+ async function assembleMailAttachments(args) {
231
+ const { tplApi, slug, template, resourceFileRefs, variables, locale, renderFileName } = args;
232
+ const inherited = await resolveSystemAttachments(tplApi, template);
205
233
  const inlineAttachments = [...resourceFileRefs].map(([cid, fileRefId]) => ({ kind: "inline", cid, fileRefId }));
206
234
  const renderedPdfs = buildRenderedPdfAttachments({
207
235
  slug,
208
- links: template.pdfAttachmentLinks ?? [],
236
+ links: mergeInheritedAttachments(template.pdfAttachmentLinks ?? [], inherited.pdfAttachmentLinks ?? [], (link) => link.fileName),
209
237
  variables,
210
238
  locale,
211
239
  renderFileName,
212
240
  });
213
- return combineMailAttachments(slug, inlineAttachments, template.fileAttachments ?? [], renderedPdfs);
241
+ return combineMailAttachments(slug, inlineAttachments, mergeInheritedAttachments(template.fileAttachments ?? [], inherited.fileAttachments ?? [], (file) => file.fileName), renderedPdfs);
242
+ }
243
+ function compileLayoutMjml(args) {
244
+ const { layout, hbs, contentHtml, appearance, accentColor, resourceFileRefs, layoutContext } = args;
245
+ try {
246
+ return layout.format === "html"
247
+ ? renderStructuredLayoutMjml({
248
+ layoutHtml: layout.content,
249
+ contentHtml,
250
+ context: layoutContext,
251
+ compile: (source) => compileDocumentSource(hbs, source),
252
+ resourceSrc: (key) => (resourceFileRefs.has(key) ? `cid:${key}` : undefined),
253
+ ...(appearance !== undefined && { appearance }),
254
+ ...(accentColor !== undefined && { accentColor }),
255
+ })
256
+ : compileDocumentSource(hbs, layout.content)({ ...layoutContext, content: contentHtml });
257
+ }
258
+ catch (e) {
259
+ // "other": the caller's payload is fine, the layout template is broken.
260
+ throw new RendererError(`renderer-mail-html: Handlebars compile failed for layout — ${describe(e)}`, "other");
261
+ }
262
+ }
263
+ async function compileMjmlToHtml(mjmlSource) {
264
+ // mjml@5 is async
265
+ const mjmlResult = await mjml2html(mjmlSource, { validationLevel: "soft" });
266
+ const errors = mjmlResult.errors ?? [];
267
+ if (errors.length > 0) {
268
+ const firstErrors = errors
269
+ .slice(0, 3)
270
+ .map((e) => `line ${e.line}: ${e.message}`)
271
+ .join("; ");
272
+ throw new RendererError(`renderer-mail-html: MJML compile produced ${errors.length} error(s) — ${firstErrors}`, "other");
273
+ }
274
+ return mjmlResult.html;
275
+ }
276
+ function htmlToPlainText(html) {
277
+ return htmlToText(html, {
278
+ wordwrap: 80,
279
+ selectors: [
280
+ { selector: "a", options: { hideLinkHrefIfSameAsText: true } },
281
+ { selector: "img", format: "skip" },
282
+ ],
283
+ });
284
+ }
285
+ function buildMailRenderResponse(args) {
286
+ const { subject, previewText, html, text, meta, attachments, template, layout, isDraft } = args;
287
+ return {
288
+ kind: "mail",
289
+ subject,
290
+ html,
291
+ text,
292
+ ...(previewText === undefined ? {} : { previewText }),
293
+ ...(meta.senderName !== undefined && { fromName: meta.senderName }),
294
+ ...(meta.replyTo !== undefined && { replyTo: meta.replyTo }),
295
+ ...(attachments.length > 0 && { attachments }),
296
+ ...(!isDraft && {
297
+ template: {
298
+ templateId: template.id,
299
+ templateVersion: template.version,
300
+ scope: template.scope,
301
+ },
302
+ }),
303
+ ...(layout.ref === null ? {} : { layout: layout.ref }),
304
+ };
214
305
  }
215
306
  export async function mailHtmlRender(req, ctx) {
216
307
  if (req.kind !== "mail") {
@@ -233,11 +324,14 @@ export async function mailHtmlRender(req, ctx) {
233
324
  imprint: "",
234
325
  unsubscribeUrl: unsubscribeUrl ?? "",
235
326
  };
236
- const body = await renderMailBody(tplApi, ctx, template, hbs, context);
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);
237
330
  const { contentHtml } = body;
238
331
  const layoutFileRefs = await resolveLayoutFileRefs(tplApi, ctx, layout);
239
332
  const resourceFileRefs = mergeResourceFileRefs(body.resourceFileRefs, layoutFileRefs);
240
- const attachments = assembleMailAttachments({
333
+ const attachments = await assembleMailAttachments({
334
+ tplApi,
241
335
  slug,
242
336
  template,
243
337
  resourceFileRefs,
@@ -246,62 +340,33 @@ export async function mailHtmlRender(req, ctx) {
246
340
  renderFileName: (pattern) => renderFileNamePattern(hbs, pattern, slug, context),
247
341
  });
248
342
  const { subject, previewText } = renderSubjectLines(hbs, meta, slug, context);
249
- let mjmlSource;
250
- try {
251
- const layoutContext = {
343
+ const accentColor = appearance?.accentLine === true ? await readBrandingAccentColor(ctx) : undefined;
344
+ const mjmlSource = compileLayoutMjml({
345
+ layout,
346
+ hbs,
347
+ contentHtml,
348
+ appearance,
349
+ accentColor,
350
+ resourceFileRefs,
351
+ layoutContext: {
252
352
  subject,
253
353
  previewText: previewText ?? "",
254
354
  tenantName,
255
355
  signature: "",
256
356
  imprint: "",
257
357
  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 });
269
- }
270
- catch (e) {
271
- // "other": the caller's payload is fine, the layout template is broken.
272
- throw new RendererError(`renderer-mail-html: Handlebars compile failed for layout — ${describe(e)}`, "other");
273
- }
274
- // mjml@5 is async
275
- const mjmlResult = await mjml2html(mjmlSource, { validationLevel: "soft" });
276
- const errors = mjmlResult.errors ?? [];
277
- if (errors.length > 0) {
278
- const firstErrors = errors
279
- .slice(0, 3)
280
- .map((e) => `line ${e.line}: ${e.message}`)
281
- .join("; ");
282
- throw new RendererError(`renderer-mail-html: MJML compile produced ${errors.length} error(s) — ${firstErrors}`, "other");
283
- }
284
- const text = htmlToText(mjmlResult.html, {
285
- wordwrap: 80,
286
- selectors: [
287
- { selector: "a", options: { hideLinkHrefIfSameAsText: true } },
288
- { selector: "img", format: "skip" },
289
- ],
358
+ },
290
359
  });
291
- return {
292
- kind: "mail",
360
+ const html = await compileMjmlToHtml(mjmlSource);
361
+ return buildMailRenderResponse({
293
362
  subject,
294
- html: mjmlResult.html,
295
- text,
296
- ...(previewText === undefined ? {} : { previewText }),
297
- ...(attachments.length > 0 && { attachments }),
298
- ...(draft === undefined && {
299
- template: {
300
- templateId: template.id,
301
- templateVersion: template.version,
302
- scope: template.scope,
303
- },
304
- }),
305
- ...(layout.ref === null ? {} : { layout: layout.ref }),
306
- };
363
+ previewText,
364
+ html,
365
+ text: htmlToPlainText(html),
366
+ meta,
367
+ attachments,
368
+ template,
369
+ layout,
370
+ isDraft: draft !== undefined,
371
+ });
307
372
  }
@@ -1,3 +1,5 @@
1
+ import { type MailLayoutAppearance } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
2
+ export declare const MAIL_IMAGE_MAX_WIDTH_PX = 180;
1
3
  export type StructuredLayoutInput = {
2
4
  readonly layoutHtml: string;
3
5
  readonly contentHtml: string;
@@ -7,5 +9,9 @@ export type StructuredLayoutInput = {
7
9
  readonly compile: (source: string) => (context: Readonly<Record<string, unknown>>) => string;
8
10
  /** src for `img[data-resource]` in the regions; without it layout images are dropped. */
9
11
  readonly resourceSrc?: (key: string) => string | undefined;
12
+ /** Parsed layout appearance; defaults apply when missing. */
13
+ readonly appearance?: MailLayoutAppearance;
14
+ /** Tenant branding accent color for the accent line; ignored unless it is a #rrggbb hex. */
15
+ readonly accentColor?: string;
10
16
  };
11
17
  export declare function renderStructuredLayoutMjml(input: StructuredLayoutInput): string;
@@ -1,16 +1,30 @@
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
+ // The logo of a mail layout and of a mail body is capped here; the editors show the same width.
6
+ export const MAIL_IMAGE_MAX_WIDTH_PX = 180;
7
+ // Appearance values are enum members or a strict hex (see mailLayoutAppearance / isHexColor),
8
+ // 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">
12
+ <mj-column padding="0">
13
+ <mj-spacer height="4px" padding="0" />
14
+ </mj-column>
15
+ </mj-section>`
16
+ : "";
17
+ return `<mjml>
5
18
  <mj-head>
6
19
  <mj-title>{{subject}}</mj-title>
7
20
  <mj-preview>{{previewText}}</mj-preview>
8
21
  <mj-attributes>
9
- <mj-all font-family="Helvetica, Arial, sans-serif" />
22
+ <mj-all font-family="${escapeHtmlAttr(appearance.fontFamily)}" />
10
23
  <mj-text font-size="14px" color="#333" line-height="1.6" />
11
24
  </mj-attributes>
12
25
  </mj-head>
13
- <mj-body background-color="#f5f5f5">
26
+ <mj-body width="${appearance.width}px" background-color="${escapeHtmlAttr(appearance.outerBackground)}">
27
+ ${accentLine}
14
28
  {{#if regionHeader}}
15
29
  <mj-section background-color="#ffffff" padding="20px">
16
30
  <mj-column>
@@ -20,7 +34,7 @@ const STRUCTURED_LAYOUT_FRAME = `<mjml>
20
34
  {{/if}}
21
35
  <mj-section background-color="#ffffff" padding="20px">
22
36
  <mj-column>
23
- <mj-raw>{{{content}}}</mj-raw>
37
+ <mj-text>{{{content}}}</mj-text>
24
38
  </mj-column>
25
39
  </mj-section>
26
40
  {{#if regionSignature}}
@@ -31,7 +45,7 @@ const STRUCTURED_LAYOUT_FRAME = `<mjml>
31
45
  </mj-section>
32
46
  {{/if}}
33
47
  {{#if regionFooter}}
34
- <mj-section background-color="#f5f5f5" padding="20px">
48
+ <mj-section background-color="${escapeHtmlAttr(appearance.outerBackground)}" padding="20px">
35
49
  <mj-column>
36
50
  <mj-text font-size="11px" color="#888" align="center">{{{regionFooter}}}</mj-text>
37
51
  </mj-column>
@@ -39,12 +53,17 @@ const STRUCTURED_LAYOUT_FRAME = `<mjml>
39
53
  {{/if}}
40
54
  </mj-body>
41
55
  </mjml>`;
56
+ }
42
57
  export function renderStructuredLayoutMjml(input) {
43
58
  const regions = readLayoutRegions(input.layoutHtml);
44
59
  const renderRegion = (region) => region.trim() === ""
45
60
  ? ""
46
- : input.compile(compileDocumentBodyToHandlebars(region, {}, input.resourceSrc && { resourceSrc: input.resourceSrc }))(input.context);
47
- return input.compile(STRUCTURED_LAYOUT_FRAME)({
61
+ : input.compile(compileDocumentBodyToHandlebars(region, {}, {
62
+ ...(input.resourceSrc && { resourceSrc: input.resourceSrc }),
63
+ resourceImageMaxWidthPx: MAIL_IMAGE_MAX_WIDTH_PX,
64
+ }))(input.context);
65
+ const frame = structuredLayoutFrame(input.appearance ?? mailLayoutAppearance({}), input.accentColor);
66
+ return input.compile(frame)({
48
67
  ...input.context,
49
68
  content: input.contentHtml,
50
69
  regionHeader: renderRegion(regions.header),
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.364.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.364.0",
23
+ "@cosmicdrift/kumiko-framework": "0.364.0",
24
+ "@cosmicdrift/kumiko-headless": "0.364.0",
25
+ "@cosmicdrift/kumiko-types": "0.364.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.364.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,16 @@
1
1
  [
2
+ {
3
+ "version": "0.364.0",
4
+ "type": "fix",
5
+ "title": "Structured mail layouts show the mail body",
6
+ "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."
7
+ },
8
+ {
9
+ "version": "0.364.0",
10
+ "type": "fix",
11
+ "title": "Mail renderer keeps wall-clock dates and system attachments",
12
+ "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."
13
+ },
2
14
  {
3
15
  "version": "0.363.0",
4
16
  "type": "breaking",