@cosmicdrift/kumiko-renderer-mail-html 0.366.0 → 0.368.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 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>";
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 mailSentBy}}<p>{{mailSentBy}}</p>{{/if}}{{#if mailFooterHtml}}<p>{{{mailFooterHtml}}}</p>{{/if}}\n </mj-text>\n </mj-column>\n </mj-section>\n </mj-body>\n</mjml>";
@@ -3,7 +3,8 @@
3
3
  // structured layout instead, which also shows the branding logo and accent.
4
4
  //
5
5
  // **Variables the layout receives (nothing else):**
6
- // - {{tenantName}} - header + footer
6
+ // - {{tenantName}} - header
7
+ // - {{mailSentBy}} - "Sent by <tenant>" in the mail locale, empty without a tenant name
7
8
  // - {{{content}}} - the rendered mail body (escaped values), triple-stash.
8
9
  // It sits in mj-text: mj-raw would put block HTML straight into
9
10
  // the column tbody, where browsers move it out of the table into
@@ -44,7 +45,7 @@ export const DEFAULT_MAIL_LAYOUT_MJML = `<mjml lang="{{lang}}">
44
45
  <mj-section background-color="#f5f5f5" padding="20px">
45
46
  <mj-column>
46
47
  <mj-text font-size="11px" color="#888" align="center">
47
- {{#if tenantName}}<p>Sent by {{tenantName}}</p>{{/if}}{{#if mailFooterHtml}}<p>{{{mailFooterHtml}}}</p>{{/if}}
48
+ {{#if mailSentBy}}<p>{{mailSentBy}}</p>{{/if}}{{#if mailFooterHtml}}<p>{{{mailFooterHtml}}}</p>{{/if}}
48
49
  </mj-text>
49
50
  </mj-column>
50
51
  </mj-section>
@@ -16,5 +16,6 @@ export declare function readBrandingSiteUrl(ctx: RendererContext): Promise<strin
16
16
  export declare const LOGO_RESOURCE_KEY = "logo";
17
17
  export declare function resolveResourceFileRefs(tplApi: TemplateResolverApi, ctx: RendererContext, template: TemplateResource, keys: readonly string[]): Promise<ReadonlyMap<string, string>>;
18
18
  export declare function readBrandingLogo(ctx: RendererContext): Promise<string | undefined>;
19
+ export declare function readBrandingLogoUrl(ctx: RendererContext): Promise<string | undefined>;
19
20
  export declare function collectRegionResourceKeys(regions: LayoutRegions): readonly string[];
20
21
  export declare function mergeResourceFileRefs(bodyRefs: ReadonlyMap<string, string>, layoutRefs: ReadonlyMap<string, string>): ReadonlyMap<string, string>;
@@ -1,6 +1,7 @@
1
1
  import { BRANDING_QN } from "@cosmicdrift/kumiko-bundled-features/managed-pages";
2
- import { collectDocumentResourceKeys, isHexColor, sanitizeDocumentHtml, TemplateNotFoundError, } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
3
- import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import { isSafeHttpsUrl } from "@cosmicdrift/kumiko-bundled-features/page-render";
3
+ import { collectDocumentResourceKeys, flattenParagraphsToLineBreaks, isHexColor, sanitizeDocumentHtml, TemplateNotFoundError, } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
4
+ import { selectMany } from "@cosmicdrift/kumiko-framework/db";
4
5
  import { isUuid, SYSTEM_TENANT_ID, TENANT_CURRENCY_CONFIG_KEY, } from "@cosmicdrift/kumiko-framework/engine";
5
6
  import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
6
7
  import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
@@ -58,12 +59,24 @@ export function createDocumentHandlebars(format) {
58
59
  return String(value);
59
60
  }
60
61
  });
61
- hbs.registerHelper("richText", (value) => {
62
+ hbs.registerHelper("percent", (value) => {
63
+ if (typeof value === "number" && Number.isFinite(value)) {
64
+ return new Intl.NumberFormat(resolveSafeMoneyLocale(format.locale), {
65
+ style: "percent",
66
+ maximumFractionDigits: 2,
67
+ }).format(value / 100);
68
+ }
69
+ return value === undefined || value === null ? "" : String(value);
70
+ });
71
+ hbs.registerHelper("richText", (value, options) => {
62
72
  if (typeof value !== "string")
63
73
  return "";
64
74
  const sanitized = sanitizeDocumentHtml(value);
65
- if (sanitized.ok)
66
- return new hbs.SafeString(sanitized.html);
75
+ if (sanitized.ok) {
76
+ return new hbs.SafeString(options.hash["inline"] === true
77
+ ? flattenParagraphsToLineBreaks(sanitized.html)
78
+ : sanitized.html);
79
+ }
67
80
  // Rejected markup renders as visible text (escaped by Handlebars) instead of vanishing.
68
81
  documentKitLogger.warn("richText value rejected by sanitizer, rendered as escaped text", {
69
82
  violations: sanitized.violations.map((item) => item.code),
@@ -95,6 +108,7 @@ const KNOWN_HELPERS = {
95
108
  link: true,
96
109
  richText: true,
97
110
  stepIs: true,
111
+ percent: true,
98
112
  };
99
113
  export function compileDocumentSource(hbs, source, options = {}) {
100
114
  return hbs.compile(source, {
@@ -174,6 +188,12 @@ export async function readBrandingLogo(ctx) {
174
188
  });
175
189
  return row === undefined ? undefined : fileRefId;
176
190
  }
191
+ // The branding mask also stores a logo address instead of an upload. Mail clients load it
192
+ // themselves; the PDF renderer must not, since the server would fetch an arbitrary URL.
193
+ export async function readBrandingLogoUrl(ctx) {
194
+ const value = await readConfigText(ctx, BRANDING_QN.logoUrl);
195
+ return value !== undefined && isSafeHttpsUrl(value) ? value : undefined;
196
+ }
177
197
  function ownLink(links, key) {
178
198
  return Object.hasOwn(links, key) && links[key] !== "" ? links[key] : undefined;
179
199
  }
package/dist/feature.js CHANGED
@@ -19,7 +19,7 @@ import { mailHtmlRender } from "./render.js";
19
19
  // the tenant-editable footer region, so a tenant cannot remove it.
20
20
  export function createRendererMailHtmlFeature(options = {}) {
21
21
  return defineFeature("renderer-mail-html", (r) => {
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`.');
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, an optional `brandSuffix` resolver adds a word after the tenant name in the brand row of the in-code layout, and an optional `siteUrl` resolver supplies the site link of the card when the tenant branding has no site URL (without either, the link is left out). The brand row and `data-resource="logo"` images use the branding logo upload, or the branding logo https address when no file is uploaded. 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`.');
23
23
  r.requires({ features: ["renderer-foundation"] });
24
24
  r.requires({ features: ["template-resolver"] });
25
25
  r.useExtension("renderer", "mail-html", {
package/dist/index.d.ts CHANGED
@@ -2,4 +2,5 @@ export { DEFAULT_MAIL_LAYOUT_MJML } from "./default-layout.js";
2
2
  export { collectRegionResourceKeys, compileDocumentSource, createDocumentHandlebars, type DocumentFormat, mergeResourceFileRefs, readBrandingAccentColor, readDocumentFormat, readTenantName, resolveResourceFileRefs, } from "./document-kit.js";
3
3
  export { createRendererMailHtmlFeature } from "./feature.js";
4
4
  export type { MailFooter, MailFooterLink, MailFooterResolver } from "./mail-footer.js";
5
- export { type MailBrandSuffixResolver, type MailHtmlRenderOptions, mailHtmlRender, } from "./render.js";
5
+ export { MAIL_CHROME_EN } from "./mail-strings.js";
6
+ export { type MailBrandSuffixResolver, type MailHtmlRenderOptions, type MailSiteUrlResolver, mailHtmlRender, } from "./render.js";
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export { DEFAULT_MAIL_LAYOUT_MJML } from "./default-layout.js";
2
2
  export { collectRegionResourceKeys, compileDocumentSource, createDocumentHandlebars, mergeResourceFileRefs, readBrandingAccentColor, readDocumentFormat, readTenantName, resolveResourceFileRefs, } from "./document-kit.js";
3
3
  export { createRendererMailHtmlFeature } from "./feature.js";
4
+ export { MAIL_CHROME_EN } from "./mail-strings.js";
4
5
  export { mailHtmlRender, } from "./render.js";
@@ -1,6 +1,7 @@
1
1
  import type { RendererContext } from "@cosmicdrift/kumiko-bundled-features/renderer-foundation";
2
+ /** Without a label the imprint and privacy links use the framework's text in the mail locale. */
2
3
  export type MailFooterLink = {
3
- readonly label: string;
4
+ readonly label?: string;
4
5
  readonly url: string;
5
6
  };
6
7
  export type MailFooter = {
@@ -1,15 +1,7 @@
1
1
  import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
2
+ import { mailChromeText } from "./mail-strings.js";
2
3
  const SAFE_FOOTER_URL = /^(https?:|mailto:)/i;
3
4
  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
5
  function linkHtml(link, color = "#888") {
14
6
  const url = link.url.trim();
15
7
  if (!SAFE_FOOTER_URL.test(url) || link.label.trim() === "")
@@ -34,7 +26,10 @@ export function buildMailBandLinksHtml(input) {
34
26
  : linkHtml({ label: host, url: input.siteUrl }, BAND_LINK_COLOR),
35
27
  input.unsubscribeUrl === "" || input.layoutLinksUnsubscribe
36
28
  ? null
37
- : linkHtml({ label: unsubscribeLabel(input.locale), url: input.unsubscribeUrl }, BAND_LINK_COLOR),
29
+ : linkHtml({
30
+ label: mailChromeText(input.locale, "mail.footer.unsubscribe"),
31
+ url: input.unsubscribeUrl,
32
+ }, BAND_LINK_COLOR),
38
33
  ];
39
34
  return links.filter((link) => link !== null).join(" · ");
40
35
  }
@@ -47,17 +42,26 @@ export function buildMailFooterHtml(input) {
47
42
  const { footer, unsubscribeUrl, locale } = input;
48
43
  const links = [];
49
44
  if (footer?.imprint !== undefined && !input.layoutLinksImprint) {
50
- const html = linkHtml(footer.imprint);
45
+ const html = linkHtml({
46
+ label: footer.imprint.label ?? mailChromeText(locale, "mail.footer.imprint"),
47
+ url: footer.imprint.url,
48
+ });
51
49
  if (html !== null)
52
50
  links.push(html);
53
51
  }
54
52
  if (footer?.privacy !== undefined) {
55
- const html = linkHtml(footer.privacy);
53
+ const html = linkHtml({
54
+ label: footer.privacy.label ?? mailChromeText(locale, "mail.footer.privacy"),
55
+ url: footer.privacy.url,
56
+ });
56
57
  if (html !== null)
57
58
  links.push(html);
58
59
  }
59
60
  if (unsubscribeUrl !== "" && !input.layoutLinksUnsubscribe) {
60
- const html = linkHtml({ label: unsubscribeLabel(locale), url: unsubscribeUrl });
61
+ const html = linkHtml({
62
+ label: mailChromeText(locale, "mail.footer.unsubscribe"),
63
+ url: unsubscribeUrl,
64
+ });
61
65
  if (html !== null)
62
66
  links.push(html);
63
67
  }
@@ -0,0 +1,8 @@
1
+ export declare const MAIL_CHROME_EN: {
2
+ readonly "mail.footer.sentBy": "Sent by {sender}";
3
+ readonly "mail.footer.unsubscribe": "Unsubscribe";
4
+ readonly "mail.footer.imprint": "Imprint";
5
+ readonly "mail.footer.privacy": "Privacy policy";
6
+ };
7
+ export type MailChromeKey = keyof typeof MAIL_CHROME_EN;
8
+ export declare function mailChromeText(locale: string, key: MailChromeKey, params?: Readonly<Record<string, string>>): string;
@@ -0,0 +1,13 @@
1
+ import { mailT, registerMailTranslations } from "@cosmicdrift/kumiko-framework/i18n";
2
+ // English ships here; other languages come from the locale packages, and an
3
+ // unknown locale falls back through its root language to English (mailT).
4
+ export const MAIL_CHROME_EN = {
5
+ "mail.footer.sentBy": "Sent by {sender}",
6
+ "mail.footer.unsubscribe": "Unsubscribe",
7
+ "mail.footer.imprint": "Imprint",
8
+ "mail.footer.privacy": "Privacy policy",
9
+ };
10
+ registerMailTranslations("en", MAIL_CHROME_EN);
11
+ export function mailChromeText(locale, key, params) {
12
+ return mailT(locale, key, params);
13
+ }
package/dist/render.d.ts CHANGED
@@ -4,9 +4,15 @@ export type MailBrandSuffixResolver = (input: {
4
4
  readonly locale: string;
5
5
  readonly ctx: RendererContext;
6
6
  }) => string | null | Promise<string | null>;
7
+ export type MailSiteUrlResolver = (input: {
8
+ readonly locale: string;
9
+ readonly ctx: RendererContext;
10
+ }) => string | null | Promise<string | null>;
7
11
  export type MailHtmlRenderOptions = {
8
12
  readonly footer?: MailFooterResolver;
9
13
  /** Text after the tenant name in the in-code layout's brand row, e.g. "Status". */
10
14
  readonly brandSuffix?: MailBrandSuffixResolver;
15
+ /** Site link in the mail band when the tenant's branding has no site URL. */
16
+ readonly siteUrl?: MailSiteUrlResolver;
11
17
  };
12
18
  export declare function mailHtmlRender(req: RenderRequest, ctx: RendererContext, options?: MailHtmlRenderOptions): Promise<RenderResponse>;
package/dist/render.js CHANGED
@@ -10,15 +10,16 @@
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, mailLayoutAppearance, parseTemplateMeta, pdfAttachmentLinksOf, readLayoutRegions, resolveTemplateVariables, sanitizeDocumentHtml, sanitizeLayoutHtml, TemplateNotFoundError, } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
13
+ import { collectDocumentResourceKeys, compileDocumentBodyToHandlebars, createTemplateResolverApi, fileAttachmentsOf, imageLinksOf, isMailTemplateMeta, isSystemOnlyTemplate, 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_SLUG } from "./default-layout.js";
18
- import { collectRegionResourceKeys, compileDocumentSource, createDocumentHandlebars, LOGO_RESOURCE_KEY, mergeResourceFileRefs, readBrandingAccentColor, readBrandingLogo, readBrandingSiteUrl, readDocumentFormat, readTenantName, resolveResourceFileRefs, } from "./document-kit.js";
18
+ import { collectRegionResourceKeys, compileDocumentSource, createDocumentHandlebars, LOGO_RESOURCE_KEY, mergeResourceFileRefs, readBrandingAccentColor, readBrandingLogo, readBrandingLogoUrl, readBrandingSiteUrl, readDocumentFormat, readTenantName, resolveResourceFileRefs, } from "./document-kit.js";
19
19
  import { buildRenderedPdfAttachments, combineMailAttachments, mergeInheritedAttachments, } from "./mail-attachments.js";
20
20
  import { brandHeaderHtml, DEFAULT_ACCENT_COLOR, readMailTone, toneColors, } from "./mail-chrome.js";
21
21
  import { buildMailBandLinksHtml, buildMailFooterHtml, layoutLinksToPlaceholder, safeFooterUrl, } from "./mail-footer.js";
22
+ import { mailChromeText } from "./mail-strings.js";
22
23
  import { MAIL_IMAGE_MAX_WIDTH_PX, renderStructuredLayoutMjml } from "./structured-layout.js";
23
24
  const LINE_BREAKS = /[\r\n]+/g;
24
25
  function describe(e) {
@@ -31,6 +32,13 @@ const IN_CODE_LAYOUT_META = {
31
32
  width: 640,
32
33
  outerBackground: "#ecedea",
33
34
  };
35
+ function mailResourceSrc(resourceFileRefs, brandingLogoUrl) {
36
+ return (key) => resourceFileRefs.has(key)
37
+ ? `cid:${key}`
38
+ : key === LOGO_RESOURCE_KEY
39
+ ? brandingLogoUrl
40
+ : undefined;
41
+ }
34
42
  async function inCodeLayout(ctx) {
35
43
  const logoFileRefId = await readBrandingLogo(ctx);
36
44
  return {
@@ -99,7 +107,10 @@ async function resolveLayout(tplApi, ctx, meta, locale) {
99
107
  }
100
108
  async function findStoredMailTemplate(tplApi, ctx, slug, locale) {
101
109
  try {
102
- return await tplApi.resolveTemplate({ tenantId: ctx.tenantId, slug, kind: "mail", locale });
110
+ const tenantId = isSystemOnlyTemplate(ctx.registry, slug, "mail")
111
+ ? SYSTEM_TENANT_ID
112
+ : ctx.tenantId;
113
+ return await tplApi.resolveTemplate({ tenantId, slug, kind: "mail", locale });
103
114
  }
104
115
  catch (e) {
105
116
  if (e instanceof TemplateNotFoundError)
@@ -178,11 +189,11 @@ async function resolveMailTemplate(tplApi, ctx, slug, locale, draft) {
178
189
  }
179
190
  // Each data-resource image is referenced by cid; the bytes are read at send time, so
180
191
  // the render result carries only file ref ids.
181
- async function renderMailBody(tplApi, ctx, template, hbs, context, buttonInlineStyle, stepsColor) {
192
+ async function renderMailBody(tplApi, ctx, template, hbs, context, buttonInlineStyle, stepsColor, brandingLogoUrl) {
182
193
  try {
183
194
  const resourceFileRefs = await resolveResourceFileRefs(tplApi, ctx, template, collectDocumentResourceKeys(template.content));
184
195
  const handlebarsSource = compileDocumentBodyToHandlebars(template.content, resolveTemplateVariables(ctx.registry, template.slug, template.variableSchema), {
185
- resourceSrc: (key) => (resourceFileRefs.has(key) ? `cid:${key}` : undefined),
196
+ resourceSrc: mailResourceSrc(resourceFileRefs, brandingLogoUrl),
186
197
  resourceImageMaxWidthPx: MAIL_IMAGE_MAX_WIDTH_PX,
187
198
  alignmentAsInlineStyle: true,
188
199
  buttonAsInlineStyle: true,
@@ -287,7 +298,7 @@ function compileLayoutMjml(args) {
287
298
  contentHtml,
288
299
  context: layoutContext,
289
300
  compile: (source) => compileDocumentSource(hbs, source),
290
- resourceSrc: (key) => (resourceFileRefs.has(key) ? `cid:${key}` : undefined),
301
+ resourceSrc: mailResourceSrc(resourceFileRefs, args.brandingLogoUrl),
291
302
  ...(appearance !== undefined && { appearance }),
292
303
  ...(accentColor !== undefined && { accentColor }),
293
304
  ...(args.chrome.tone !== undefined && { tone: args.chrome.tone }),
@@ -386,6 +397,17 @@ async function resolveBrandSuffix(options, ctx, locale) {
386
397
  throw new RendererError(`renderer-mail-html: the brand suffix resolver failed — ${describe(e)}`, "other");
387
398
  }
388
399
  }
400
+ async function resolveMailSiteUrl(options, ctx, locale) {
401
+ const brandingSiteUrl = await readBrandingSiteUrl(ctx);
402
+ if (brandingSiteUrl !== undefined || options.siteUrl === undefined)
403
+ return brandingSiteUrl;
404
+ try {
405
+ return (await options.siteUrl({ locale, ctx })) ?? undefined;
406
+ }
407
+ catch (e) {
408
+ throw new RendererError(`renderer-mail-html: the site URL resolver failed — ${describe(e)}`, "other");
409
+ }
410
+ }
389
411
  // A structured layout shows the site and unsubscribe links inside the card and only the
390
412
  // platform line below it; an mjml layout gets everything in one line.
391
413
  function renderFooterParts(args) {
@@ -419,12 +441,13 @@ export async function mailHtmlRender(req, ctx, options = {}) {
419
441
  const { slug, locale, variables, unsubscribeUrl, draft } = req.payload;
420
442
  const tplApi = createTemplateResolverApi(ctx.db);
421
443
  const { template, meta } = await resolveMailTemplate(tplApi, ctx, slug, locale, draft);
422
- const [format, tenantName, footer, siteUrl, brandSuffix] = await Promise.all([
444
+ const [format, tenantName, footer, siteUrl, brandSuffix, brandingLogoUrl] = await Promise.all([
423
445
  readDocumentFormat(ctx, locale),
424
446
  readTenantName(ctx),
425
447
  resolveMailFooter(options, ctx, locale),
426
- readBrandingSiteUrl(ctx),
448
+ resolveMailSiteUrl(options, ctx, locale),
427
449
  resolveBrandSuffix(options, ctx, locale),
450
+ readBrandingLogoUrl(ctx),
428
451
  ]);
429
452
  const layout = (await resolveLayout(tplApi, ctx, meta, locale)) ?? (await inCodeLayout(ctx));
430
453
  const hbs = createDocumentHandlebars(format);
@@ -437,7 +460,7 @@ export async function mailHtmlRender(req, ctx, options = {}) {
437
460
  const context = { ...variables, ...systemPlaceholders };
438
461
  const { appearance, accentColor, brandColor } = await resolveMailBranding(ctx, layout);
439
462
  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);
463
+ const body = await renderMailBody(tplApi, ctx, template, hbs, context, appearance?.buttonStyle, chrome.tone?.bar ?? brandColor ?? DEFAULT_ACCENT_COLOR, brandingLogoUrl);
441
464
  const { contentHtml } = body;
442
465
  const layoutFileRefs = await resolveLayoutFileRefs(tplApi, ctx, layout);
443
466
  const resourceFileRefs = mergeResourceFileRefs(body.resourceFileRefs, layoutFileRefs);
@@ -458,8 +481,12 @@ export async function mailHtmlRender(req, ctx, options = {}) {
458
481
  appearance,
459
482
  accentColor,
460
483
  resourceFileRefs,
484
+ brandingLogoUrl,
461
485
  layoutContext: {
462
486
  ...systemPlaceholders,
487
+ mailSentBy: tenantName === ""
488
+ ? ""
489
+ : mailChromeText(locale, "mail.footer.sentBy", { sender: tenantName }),
463
490
  lang: locale,
464
491
  subject,
465
492
  previewText: previewText ?? "",
@@ -470,7 +497,7 @@ export async function mailHtmlRender(req, ctx, options = {}) {
470
497
  : brandHeaderHtml({
471
498
  tenantName,
472
499
  suffix: brandSuffix,
473
- logoSrc: layout.brandHeader.hasLogo ? `cid:${LOGO_RESOURCE_KEY}` : undefined,
500
+ logoSrc: layout.brandHeader.hasLogo ? `cid:${LOGO_RESOURCE_KEY}` : brandingLogoUrl,
474
501
  accentColor: brandColor ?? DEFAULT_ACCENT_COLOR,
475
502
  }),
476
503
  ...renderFooterParts({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer-mail-html",
3
- "version": "0.366.0",
3
+ "version": "0.368.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,17 @@
19
19
  }
20
20
  },
21
21
  "dependencies": {
22
- "@cosmicdrift/kumiko-bundled-features": "0.366.0",
23
- "@cosmicdrift/kumiko-framework": "0.366.0",
24
- "@cosmicdrift/kumiko-headless": "0.366.0",
25
- "@cosmicdrift/kumiko-types": "0.366.0",
22
+ "@cosmicdrift/kumiko-bundled-features": "0.368.0",
23
+ "@cosmicdrift/kumiko-framework": "0.368.0",
24
+ "@cosmicdrift/kumiko-headless": "0.368.0",
25
+ "@cosmicdrift/kumiko-types": "0.368.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.366.0",
31
+ "@cosmicdrift/kumiko-locale-de": "0.368.0",
32
+ "@cosmicdrift/kumiko-testing": "0.368.0",
32
33
  "@types/html-to-text": "^9.0.4",
33
34
  "@types/mjml": "^5.0.0"
34
35
  },
package/src/changes.json CHANGED
@@ -1,4 +1,17 @@
1
1
  [
2
+ {
3
+ "version": "0.367.0",
4
+ "type": "improvement",
5
+ "title": "Mail header shows the branding logo address, band link falls back to an app site URL",
6
+ "detail": "Without an uploaded logo file the mail renderer now uses the https address from branding.logoUrl for the layout brand row (28 px high) and for a logo image in the body; the mail client loads it, the PDF renderer never does. New option siteUrl on createRendererMailHtmlFeature (type MailSiteUrlResolver) supplies the site link of the footer band when the tenant has no branding.siteUrl. Without either value the band shows only the unsubscribe link, never an empty link."
7
+ },
8
+ {
9
+ "version": "0.367.0",
10
+ "type": "breaking",
11
+ "title": "Mail footer texts come from the i18n system",
12
+ "detail": "The \"Sent by\" line of the default mail layout, the unsubscribe label and the imprint and privacy labels now come from the mail translations (mail.footer.sentBy, mail.footer.unsubscribe, mail.footer.imprint, mail.footer.privacy). English ships with the renderer, German and Spanish with locale-de and locale-es; an unknown locale falls back to its root language and then to English. MailFooterLink.label is optional and defaults to the framework text. The default layout renders the sender line through the new mailSentBy placeholder. MAIL_CHROME_EN exports the English source texts for translation tooling.",
13
+ "migration": "Apps that send German or Spanish mails mount localeDe() or localeEs(), otherwise the footer labels are English. A mail layout already stored from the old default keeps its fixed \"Sent by\" text until it is reseeded or edited."
14
+ },
2
15
  {
3
16
  "version": "0.365.0",
4
17
  "type": "improvement",