@cosmicdrift/kumiko-renderer-mail-html 0.387.0 → 0.389.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.
@@ -10,6 +10,7 @@ import { TENANT_TIMEZONE_CONFIG_KEY } from "@cosmicdrift/kumiko-framework/pipeli
10
10
  import { isValidIanaTimeZone } from "@cosmicdrift/kumiko-framework/time";
11
11
  import { DOCUMENT_FALLBACK_CURRENCY, formatDocumentDate, formatDocumentMoney, formatDocumentPercent, } from "@cosmicdrift/kumiko-renderer/headless";
12
12
  import Handlebars from "handlebars";
13
+ import { convert as htmlToText } from "html-to-text";
13
14
  // Handlebars instance, template resolution and resource lookup shared by the
14
15
  // mail and PDF renderers, so both format money/dates and find images the same way.
15
16
  const documentKitLogger = createFallbackLogger("renderer-mail-html");
@@ -37,6 +38,15 @@ export function createDocumentHandlebars(format) {
37
38
  });
38
39
  return value;
39
40
  });
41
+ hbs.registerHelper("richTextPlain", (value) => typeof value === "string"
42
+ ? htmlToText(value, {
43
+ wordwrap: false,
44
+ selectors: [
45
+ { selector: "a", options: { ignoreHref: true } },
46
+ { selector: "img", format: "skip" },
47
+ ],
48
+ })
49
+ : "");
40
50
  hbs.registerHelper("link", (value) => typeof value === "string" && SAFE_LINK_PROTOCOL.test(value.trim()) ? value : "#");
41
51
  hbs.registerHelper("stepIs", function stepIs(value, index, keys, state, options) {
42
52
  return isStepInState(value, index, keys, state) ? options.fn(this) : options.inverse(this);
@@ -63,6 +73,7 @@ const KNOWN_HELPERS = {
63
73
  richText: true,
64
74
  stepIs: true,
65
75
  percent: true,
76
+ richTextPlain: true,
66
77
  };
67
78
  export function compileDocumentSource(hbs, source, options = {}) {
68
79
  return hbs.compile(source, {
@@ -86,6 +97,13 @@ async function readConfigText(ctx, key) {
86
97
  export async function readDocumentFormat(ctx, templateLocale) {
87
98
  const currency = await readConfigText(ctx, TENANT_CURRENCY_CONFIG_KEY);
88
99
  const zone = await readConfigText(ctx, TENANT_TIMEZONE_CONFIG_KEY);
100
+ if (zone !== undefined && !isValidIanaTimeZone(zone)) {
101
+ documentKitLogger.warn("tenant time zone is not a valid IANA zone, documents print UTC times", {
102
+ tenantId: ctx.tenantId,
103
+ configKey: TENANT_TIMEZONE_CONFIG_KEY,
104
+ zone,
105
+ });
106
+ }
89
107
  return {
90
108
  locale: templateLocale,
91
109
  currency: currency ?? DOCUMENT_FALLBACK_CURRENCY,
@@ -80,5 +80,5 @@ export function buildMailFooterHtml(input) {
80
80
  return lines.join("<br>");
81
81
  }
82
82
  export function layoutLinksToPlaceholder(layoutHtml, name) {
83
- return new RegExp(`href="\\{\\{\\s*${name}\\s*\\}\\}"`).test(layoutHtml);
83
+ return new RegExp(`<a\\b[^>]*href="\\{\\{\\s*${name}\\s*\\}\\}"[^>]*>\\s*[^<\\s]`).test(layoutHtml);
84
84
  }
package/dist/render.js CHANGED
@@ -23,7 +23,13 @@ import { mailChromeText } from "./mail-strings.js";
23
23
  import { MAIL_IMAGE_MAX_WIDTH_PX, renderStructuredLayoutMjml } from "./structured-layout.js";
24
24
  const LINE_BREAKS = /[\r\n]+/g;
25
25
  const PLAIN_REFERENCE = /\{\{\s*([\w.]+)\s*\}\}/g;
26
- const FORMATTED_VARIABLE_TYPES = new Set(["money", "date", "percent"]);
26
+ // richText becomes plain text: meta lines render unescaped into subject and layout rows.
27
+ const META_FORMAT_HELPER_BY_TYPE = {
28
+ money: "money",
29
+ date: "date",
30
+ percent: "percent",
31
+ richText: "richTextPlain",
32
+ };
27
33
  function describe(e) {
28
34
  return e instanceof Error ? e.message : String(e);
29
35
  }
@@ -218,9 +224,10 @@ async function renderMailBody(tplApi, ctx, template, hbs, context, buttonInlineS
218
224
  function withFormatHelpers(source, variables) {
219
225
  return source.replace(PLAIN_REFERENCE, (reference, name) => {
220
226
  const type = variables[name]?.type;
221
- return type !== undefined && FORMATTED_VARIABLE_TYPES.has(type)
222
- ? `{{${type} ${name}}}`
223
- : reference;
227
+ const helper = type !== undefined && Object.hasOwn(META_FORMAT_HELPER_BY_TYPE, type)
228
+ ? META_FORMAT_HELPER_BY_TYPE[type]
229
+ : undefined;
230
+ return helper === undefined ? reference : `{{${helper} ${name}}}`;
224
231
  });
225
232
  }
226
233
  function renderSubjectLine(hbs, source, context, variables) {
@@ -302,6 +309,17 @@ async function assembleMailAttachments(args) {
302
309
  });
303
310
  return combineMailAttachments(slug, inlineAttachments, mergeInheritedAttachments(template.fileAttachments ?? [], inherited.fileAttachments ?? [], (file) => file.fileName), renderedPdfs);
304
311
  }
312
+ const MJML_FOOTER_SECTION = '<mj-section><mj-column><mj-text font-size="12px" color="#5A6170" align="center">{{{mailFooterHtml}}}</mj-text></mj-column></mj-section>';
313
+ // The platform footer (imprint, privacy, unsubscribe) must not depend on the layout author
314
+ // remembering the placeholder.
315
+ function withPlatformFooter(mjmlSource) {
316
+ if (mjmlSource.includes("mailFooterHtml"))
317
+ return mjmlSource;
318
+ const bodyEnd = mjmlSource.lastIndexOf("</mj-body>");
319
+ return bodyEnd === -1
320
+ ? mjmlSource
321
+ : `${mjmlSource.slice(0, bodyEnd)}{{#if mailFooterHtml}}${MJML_FOOTER_SECTION}{{/if}}${mjmlSource.slice(bodyEnd)}`;
322
+ }
305
323
  function compileLayoutMjml(args) {
306
324
  const { layout, hbs, contentHtml, appearance, accentColor, resourceFileRefs, layoutContext } = args;
307
325
  try {
@@ -321,7 +339,7 @@ function compileLayoutMjml(args) {
321
339
  bandLinksHtml: args.bandLinksHtml,
322
340
  footerHtml: args.footerHtml,
323
341
  })
324
- : compileDocumentSource(hbs, layout.content)({
342
+ : compileDocumentSource(hbs, withPlatformFooter(layout.content))({
325
343
  ...layoutContext,
326
344
  content: contentHtml,
327
345
  mailFooterHtml: args.footerHtml,
@@ -421,17 +439,23 @@ async function resolveMailSiteUrl(options, ctx, locale) {
421
439
  throw new RendererError(`renderer-mail-html: the site URL resolver failed — ${describe(e)}`, "other");
422
440
  }
423
441
  }
442
+ // Only header, signature and footer reach the mail; a link elsewhere in the layout is never shown.
443
+ function layoutRegionsSource(layoutHtml) {
444
+ const { header, signature, footer } = readLayoutRegions(layoutHtml);
445
+ return [header, signature, footer].join("");
446
+ }
424
447
  // A structured layout shows the site and unsubscribe links inside the card and only the
425
448
  // platform line below it; an mjml layout gets everything in one line.
426
449
  function renderFooterParts(args) {
427
450
  const { layout, footer, unsubscribeUrl, locale } = args;
428
451
  const structured = layout.format === "html";
429
- const layoutLinksUnsubscribe = structured && layoutLinksToPlaceholder(layout.content, "unsubscribeUrl");
452
+ const linkedRegions = structured ? layoutRegionsSource(layout.content) : "";
453
+ const layoutLinksUnsubscribe = layoutLinksToPlaceholder(linkedRegions, "unsubscribeUrl");
430
454
  const footerHtml = buildMailFooterHtml({
431
455
  footer,
432
456
  unsubscribeUrl: structured ? "" : unsubscribeUrl,
433
457
  locale,
434
- layoutLinksImprint: structured && layoutLinksToPlaceholder(layout.content, "imprint"),
458
+ layoutLinksImprint: layoutLinksToPlaceholder(linkedRegions, "imprint"),
435
459
  layoutLinksUnsubscribe,
436
460
  });
437
461
  return {
package/dist/seeding.js CHANGED
@@ -2,9 +2,10 @@
2
2
  // system layout (slug "default", kind "mail-layout", tenantId=SYSTEM_TENANT_ID)
3
3
  // into the template-resource table.
4
4
  //
5
- // **When to use?** When the app author wants the default layout editable in the
6
- // tenant admin UI. Without seeding the renderer falls back to the in-code
7
- // DEFAULT_MAIL_LAYOUT_MJML, which renders identically but is not editable.
5
+ // **When to use?** When the app author wants an editable layout in the tenant admin
6
+ // UI. Without seeding the renderer falls back to the structured in-code layout with
7
+ // the tenant branding. A seeded MJML layout has its own look and must contain
8
+ // {{{mailFooterHtml}}}, or the platform footer is appended after its body.
8
9
  //
9
10
  // Idempotent: a second call with unchanged content writes no event, a changed
10
11
  // layout updates the existing row.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer-mail-html",
3
- "version": "0.387.0",
3
+ "version": "0.389.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": "MIT",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -19,19 +19,19 @@
19
19
  }
20
20
  },
21
21
  "dependencies": {
22
- "@cosmicdrift/kumiko-bundled-features": "0.387.0",
23
- "@cosmicdrift/kumiko-framework": "0.387.0",
24
- "@cosmicdrift/kumiko-renderer": "0.387.0",
25
- "@cosmicdrift/kumiko-types": "0.387.0",
22
+ "@cosmicdrift/kumiko-bundled-features": "0.389.0",
23
+ "@cosmicdrift/kumiko-framework": "0.389.0",
24
+ "@cosmicdrift/kumiko-renderer": "0.389.0",
25
+ "@cosmicdrift/kumiko-types": "0.389.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-locale-de": "0.387.0",
32
- "@cosmicdrift/kumiko-testing": "0.387.0",
31
+ "@cosmicdrift/kumiko-locale-de": "0.389.0",
32
+ "@cosmicdrift/kumiko-testing": "0.389.0",
33
33
  "@types/html-to-text": "^9.0.4",
34
- "@types/mjml": "^5.0.0"
34
+ "@types/mjml": "5.0.0"
35
35
  },
36
36
  "repository": {
37
37
  "type": "git",
package/src/changes.json CHANGED
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.388.0",
4
+ "type": "fix",
5
+ "title": "Mail footer cannot be dropped, richText in subject is plain text",
6
+ "detail": "An mjml layout without the mailFooterHtml placeholder now gets the platform footer appended before the end of the body. Layout links to the imprint or unsubscribe URL only count when they sit in the header, signature or footer region and have link text. richText variables in subject, preview and other meta lines render as plain text instead of raw HTML. An invalid tenant time zone is logged as a warning."
7
+ },
2
8
  {
3
9
  "version": "0.378.0",
4
10
  "type": "fix",