@cosmicdrift/kumiko-renderer-mail-html 0.374.0 → 0.378.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.
@@ -4,6 +4,8 @@ import Handlebars from "handlebars";
4
4
  export type DocumentFormat = {
5
5
  readonly locale: string;
6
6
  readonly currency: string;
7
+ /** Tenant zone for `{{date x time=true}}`; absent = UTC. */
8
+ readonly timeZone?: string;
7
9
  };
8
10
  export declare function createDocumentHandlebars(format: DocumentFormat): typeof Handlebars;
9
11
  export declare function compileDocumentSource(hbs: typeof Handlebars, source: string, options?: {
@@ -1,11 +1,14 @@
1
1
  import { BRANDING_QN } from "@cosmicdrift/kumiko-bundled-features/managed-pages";
2
2
  import { isSafeHttpsUrl } from "@cosmicdrift/kumiko-bundled-features/page-render";
3
3
  import { collectDocumentResourceKeys, flattenParagraphsToLineBreaks, isHexColor, sanitizeDocumentHtml, TemplateNotFoundError, } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
4
+ import { decryptTenantNames, TENANT_FEATURE, tenantTable, } from "@cosmicdrift/kumiko-bundled-features/tenant";
4
5
  import { selectMany } from "@cosmicdrift/kumiko-framework/db";
5
6
  import { isUuid, SYSTEM_TENANT_ID, TENANT_CURRENCY_CONFIG_KEY, } from "@cosmicdrift/kumiko-framework/engine";
6
7
  import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
7
8
  import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
8
- import { DOCUMENT_FALLBACK_CURRENCY, formatDocumentDate, formatDocumentMoney, formatDocumentPercent, } from "@cosmicdrift/kumiko-headless";
9
+ import { TENANT_TIMEZONE_CONFIG_KEY } from "@cosmicdrift/kumiko-framework/pipeline";
10
+ import { isValidIanaTimeZone } from "@cosmicdrift/kumiko-framework/time";
11
+ import { DOCUMENT_FALLBACK_CURRENCY, formatDocumentDate, formatDocumentMoney, formatDocumentPercent, } from "@cosmicdrift/kumiko-renderer/headless";
9
12
  import Handlebars from "handlebars";
10
13
  // Handlebars instance, template resolution and resource lookup shared by the
11
14
  // mail and PDF renderers, so both format money/dates and find images the same way.
@@ -14,7 +17,10 @@ const SAFE_LINK_PROTOCOL = /^(https?:|mailto:)/i;
14
17
  export function createDocumentHandlebars(format) {
15
18
  const hbs = Handlebars.create();
16
19
  hbs.registerHelper("money", (value) => formatDocumentMoney(value, format.currency, format.locale));
17
- hbs.registerHelper("date", (value) => formatDocumentDate(value, format.locale));
20
+ hbs.registerHelper("date", (value, options) => formatDocumentDate(value, format.locale, {
21
+ time: options?.hash?.["time"] === true,
22
+ ...(format.timeZone !== undefined && { timeZone: format.timeZone }),
23
+ }));
18
24
  hbs.registerHelper("percent", (value) => formatDocumentPercent(value, format.locale));
19
25
  hbs.registerHelper("richText", (value, options) => {
20
26
  if (typeof value !== "string")
@@ -79,10 +85,23 @@ async function readConfigText(ctx, key) {
79
85
  // "Oct 1, 2026". The currency is a tenant fact and still comes from its settings.
80
86
  export async function readDocumentFormat(ctx, templateLocale) {
81
87
  const currency = await readConfigText(ctx, TENANT_CURRENCY_CONFIG_KEY);
82
- return { locale: templateLocale, currency: currency ?? DOCUMENT_FALLBACK_CURRENCY };
88
+ const zone = await readConfigText(ctx, TENANT_TIMEZONE_CONFIG_KEY);
89
+ return {
90
+ locale: templateLocale,
91
+ currency: currency ?? DOCUMENT_FALLBACK_CURRENCY,
92
+ ...(zone !== undefined && isValidIanaTimeZone(zone) && { timeZone: zone }),
93
+ };
83
94
  }
95
+ // Without managed-pages there is no branding title; the tenant record's own name is the fallback.
84
96
  export async function readTenantName(ctx) {
85
- return (await readConfigText(ctx, BRANDING_QN.title)) ?? "";
97
+ const branded = await readConfigText(ctx, BRANDING_QN.title);
98
+ if (branded !== undefined)
99
+ return branded;
100
+ if (ctx.registry.getFeature(TENANT_FEATURE) === undefined)
101
+ return "";
102
+ const rows = await selectMany(ctx.db, tenantTable, { id: ctx.tenantId });
103
+ const [row] = await decryptTenantNames(rows, "renderer-mail-html:tenant-name");
104
+ return typeof row?.name === "string" ? row.name : "";
86
105
  }
87
106
  export async function readBrandingAccentColor(ctx) {
88
107
  const value = await readConfigText(ctx, BRANDING_QN.accentColor);
@@ -1,5 +1,5 @@
1
1
  import { isHexColor, MAIL_TONES, } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
2
- import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
2
+ import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-renderer/headless";
3
3
  import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
4
4
  const FIXED_TONES = {
5
5
  info: { bar: "#7C8DB5", fg: "#3E4C70", soft: "#EEF1F7" },
@@ -1,4 +1,4 @@
1
- import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
1
+ import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-renderer/headless";
2
2
  import { mailChromeText } from "./mail-strings.js";
3
3
  const SAFE_FOOTER_URL = /^(https?:|mailto:)/i;
4
4
  const BAND_LINK_COLOR = "#4A515E";
package/dist/render.js CHANGED
@@ -22,6 +22,8 @@ import { buildMailBandLinksHtml, buildMailFooterHtml, layoutLinksToPlaceholder,
22
22
  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
+ const PLAIN_REFERENCE = /\{\{\s*([\w.]+)\s*\}\}/g;
26
+ const FORMATTED_VARIABLE_TYPES = new Set(["money", "date", "percent"]);
25
27
  function describe(e) {
26
28
  return e instanceof Error ? e.message : String(e);
27
29
  }
@@ -211,24 +213,35 @@ async function renderMailBody(tplApi, ctx, template, hbs, context, buttonInlineS
211
213
  throw new RendererError(`renderer-mail-html: rendering the body of "${template.slug}" failed — ${describe(e)}`, "other");
212
214
  }
213
215
  }
214
- function renderSubjectLine(hbs, source, context) {
215
- return compileDocumentSource(hbs, source, { noEscape: true })(context).replace(LINE_BREAKS, "");
216
+ // Meta lines only hold plain {{name}} references (checked on save); typed values
217
+ // need the same format helpers the body compiler adds, or money renders as "[object Object]".
218
+ function withFormatHelpers(source, variables) {
219
+ return source.replace(PLAIN_REFERENCE, (reference, name) => {
220
+ const type = variables[name]?.type;
221
+ return type !== undefined && FORMATTED_VARIABLE_TYPES.has(type)
222
+ ? `{{${type} ${name}}}`
223
+ : reference;
224
+ });
225
+ }
226
+ function renderSubjectLine(hbs, source, context, variables) {
227
+ return compileDocumentSource(hbs, withFormatHelpers(source, variables), { noEscape: true })(context).replace(LINE_BREAKS, "");
216
228
  }
217
229
  function renderFileNamePattern(hbs, pattern, slug, context) {
218
230
  try {
219
- return renderSubjectLine(hbs, pattern, context);
231
+ // File names keep raw values: a formatted date or amount would put spaces into the name.
232
+ return renderSubjectLine(hbs, pattern, context, {});
220
233
  }
221
234
  catch (e) {
222
235
  throw new RendererError(`renderer-mail-html: rendering the attachment file name "${pattern}" of "${slug}" failed — ${describe(e)}`, "other");
223
236
  }
224
237
  }
225
- function renderSubjectLines(hbs, meta, slug, context) {
238
+ function renderSubjectLines(hbs, meta, slug, context, variables) {
226
239
  try {
227
240
  return {
228
- subject: renderSubjectLine(hbs, meta.subject, context),
241
+ subject: renderSubjectLine(hbs, meta.subject, context, variables),
229
242
  previewText: meta.previewText === undefined
230
243
  ? undefined
231
- : renderSubjectLine(hbs, meta.previewText, context),
244
+ : renderSubjectLine(hbs, meta.previewText, context, variables),
232
245
  };
233
246
  }
234
247
  catch (e) {
@@ -236,8 +249,8 @@ function renderSubjectLines(hbs, meta, slug, context) {
236
249
  }
237
250
  }
238
251
  // Chip and tone are author-chosen per mail; a chip without a known tone takes the accent tone.
239
- function renderMailChrome(hbs, meta, slug, context, brandColor) {
240
- const render = (source) => source === undefined ? "" : renderSubjectLine(hbs, source, context).trim();
252
+ function renderMailChrome(hbs, meta, slug, context, variables, brandColor) {
253
+ const render = (source) => source === undefined ? "" : renderSubjectLine(hbs, source, context, variables).trim();
241
254
  try {
242
255
  const headerChip = render(meta.headerChip);
243
256
  const tone = readMailTone(render(meta.tone));
@@ -483,8 +496,9 @@ export async function mailHtmlRender(req, ctx, options = {}) {
483
496
  unsubscribeUrl: unsubscribeUrl ?? "",
484
497
  };
485
498
  const context = { ...variables, ...systemPlaceholders };
499
+ const variableDefs = resolveTemplateVariables(ctx.registry, template.slug, template.variableSchema);
486
500
  const { appearance, accentColor, brandColor } = await resolveMailBranding(ctx, layout);
487
- const chrome = renderMailChrome(hbs, meta, slug, context, brandColor);
501
+ const chrome = renderMailChrome(hbs, meta, slug, context, variableDefs, brandColor);
488
502
  const body = await renderMailBody(tplApi, ctx, template, hbs, context, appearance?.buttonStyle, chrome.tone?.bar ?? brandColor ?? DEFAULT_ACCENT_COLOR, brandingLogoUrl);
489
503
  const { contentHtml } = body;
490
504
  const layoutFileRefs = await resolveLayoutFileRefs(tplApi, ctx, layout);
@@ -498,7 +512,7 @@ export async function mailHtmlRender(req, ctx, options = {}) {
498
512
  locale,
499
513
  renderFileName: (pattern) => renderFileNamePattern(hbs, pattern, slug, context),
500
514
  });
501
- const { subject, previewText } = renderSubjectLines(hbs, meta, slug, context);
515
+ const { subject, previewText } = renderSubjectLines(hbs, meta, slug, context, variableDefs);
502
516
  const mjmlSource = compileLayoutMjml({
503
517
  layout,
504
518
  hbs,
@@ -1,7 +1,7 @@
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
3
  import { compileDocumentBodyToHandlebars, isHexColor, mailLayoutAppearance, readLayoutRegions, } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
4
- import { escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
4
+ import { escapeHtmlAttr } from "@cosmicdrift/kumiko-renderer/headless";
5
5
  import { headerRowHtml } from "./mail-chrome.js";
6
6
  // The logo of a mail layout and of a mail body is capped here; the editors show the same width.
7
7
  export const MAIL_IMAGE_MAX_WIDTH_PX = 180;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer-mail-html",
3
- "version": "0.374.0",
3
+ "version": "0.378.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,17 +19,17 @@
19
19
  }
20
20
  },
21
21
  "dependencies": {
22
- "@cosmicdrift/kumiko-bundled-features": "0.374.0",
23
- "@cosmicdrift/kumiko-framework": "0.374.0",
24
- "@cosmicdrift/kumiko-headless": "0.374.0",
25
- "@cosmicdrift/kumiko-types": "0.374.0",
22
+ "@cosmicdrift/kumiko-bundled-features": "0.378.0",
23
+ "@cosmicdrift/kumiko-framework": "0.378.0",
24
+ "@cosmicdrift/kumiko-renderer": "0.378.0",
25
+ "@cosmicdrift/kumiko-types": "0.378.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.374.0",
32
- "@cosmicdrift/kumiko-testing": "0.374.0",
31
+ "@cosmicdrift/kumiko-locale-de": "0.378.0",
32
+ "@cosmicdrift/kumiko-testing": "0.378.0",
33
33
  "@types/html-to-text": "^9.0.4",
34
34
  "@types/mjml": "^5.0.0"
35
35
  },
package/src/changes.json CHANGED
@@ -1,4 +1,22 @@
1
1
  [
2
+ {
3
+ "version": "0.378.0",
4
+ "type": "fix",
5
+ "title": "Typed placeholders in mail subject lines are formatted",
6
+ "detail": "A money placeholder in the subject or preview text rendered as \"[object Object]\" and a date as its raw ISO string, because only the body compiler added the format helpers. Subject, preview text, header chip, tone and footer note now use the same money, date and percent formatting as the body. Attachment file names keep the raw values."
7
+ },
8
+ {
9
+ "version": "0.377.0",
10
+ "type": "improvement",
11
+ "title": "Document dates can print the time in the tenant time zone",
12
+ "detail": "The date helper accepts time=true ({{date value time=true}}). Instants print date and time in the tenant's configured IANA zone (tenant timezone config, UTC without one), offset date-times keep their own wall clock, date-only values print no time. Without time=true nothing changes. formatDocumentDate takes an optional { time, timeZone } argument, DocumentFormat gets timeZone, and TENANT_TIMEZONE_CONFIG_KEY is exported from framework/pipeline."
13
+ },
14
+ {
15
+ "version": "0.377.0",
16
+ "type": "fix",
17
+ "title": "tenantName in the document context falls back to the tenant record",
18
+ "detail": "Without managed-pages there is no branding title, so tenantName was empty. It now falls back to the decrypted name of the tenant record when the tenant feature is mounted."
19
+ },
2
20
  {
3
21
  "version": "0.367.0",
4
22
  "type": "improvement",