@cosmicdrift/kumiko-renderer-mail-html 0.366.0 → 0.367.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.
- package/dist/default-layout.d.ts +1 -1
- package/dist/default-layout.js +3 -2
- package/dist/document-kit.d.ts +1 -0
- package/dist/document-kit.js +24 -4
- package/dist/feature.js +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/mail-footer.d.ts +2 -1
- package/dist/mail-footer.js +17 -13
- package/dist/mail-strings.d.ts +8 -0
- package/dist/mail-strings.js +13 -0
- package/dist/render.d.ts +6 -0
- package/dist/render.js +37 -10
- package/package.json +7 -6
- package/src/changes.json +13 -0
package/dist/default-layout.d.ts
CHANGED
|
@@ -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
|
|
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>";
|
package/dist/default-layout.js
CHANGED
|
@@ -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
|
|
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
|
|
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>
|
package/dist/document-kit.d.ts
CHANGED
|
@@ -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>;
|
package/dist/document-kit.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { BRANDING_QN } from "@cosmicdrift/kumiko-bundled-features/managed-pages";
|
|
2
|
-
import {
|
|
2
|
+
import { isSafeHttpsUrl } from "@cosmicdrift/kumiko-bundled-features/page-render";
|
|
3
|
+
import { collectDocumentResourceKeys, flattenParagraphsToLineBreaks, isHexColor, sanitizeDocumentHtml, TemplateNotFoundError, } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
|
|
3
4
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-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";
|
|
@@ -58,12 +59,24 @@ export function createDocumentHandlebars(format) {
|
|
|
58
59
|
return String(value);
|
|
59
60
|
}
|
|
60
61
|
});
|
|
61
|
-
hbs.registerHelper("
|
|
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(
|
|
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,
|
|
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 {
|
|
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";
|
package/dist/mail-footer.d.ts
CHANGED
|
@@ -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
|
|
4
|
+
readonly label?: string;
|
|
4
5
|
readonly url: string;
|
|
5
6
|
};
|
|
6
7
|
export type MailFooter = {
|
package/dist/mail-footer.js
CHANGED
|
@@ -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({
|
|
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(
|
|
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(
|
|
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({
|
|
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
|
-
|
|
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: (
|
|
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: (
|
|
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
|
-
|
|
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}` :
|
|
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.
|
|
3
|
+
"version": "0.367.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.
|
|
23
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
24
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
25
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
22
|
+
"@cosmicdrift/kumiko-bundled-features": "0.367.0",
|
|
23
|
+
"@cosmicdrift/kumiko-framework": "0.367.0",
|
|
24
|
+
"@cosmicdrift/kumiko-headless": "0.367.0",
|
|
25
|
+
"@cosmicdrift/kumiko-types": "0.367.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-
|
|
31
|
+
"@cosmicdrift/kumiko-locale-de": "0.367.0",
|
|
32
|
+
"@cosmicdrift/kumiko-testing": "0.367.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",
|