@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.
- package/dist/document-kit.d.ts +1 -0
- package/dist/document-kit.js +35 -2
- package/dist/mail-attachments.d.ts +1 -0
- package/dist/mail-attachments.js +13 -2
- package/dist/render.js +127 -62
- package/dist/structured-layout.d.ts +6 -0
- package/dist/structured-layout.js +27 -8
- package/package.json +6 -6
- package/src/changes.json +12 -0
package/dist/document-kit.d.ts
CHANGED
|
@@ -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>;
|
package/dist/document-kit.js
CHANGED
|
@@ -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 = {
|
|
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[];
|
package/dist/mail-attachments.js
CHANGED
|
@@ -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,
|
|
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 =
|
|
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
|
-
|
|
204
|
-
|
|
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
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
292
|
-
|
|
360
|
+
const html = await compileMjmlToHtml(mjmlSource);
|
|
361
|
+
return buildMailRenderResponse({
|
|
293
362
|
subject,
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
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="
|
|
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="
|
|
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-
|
|
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="
|
|
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, {},
|
|
47
|
-
|
|
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.
|
|
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.
|
|
23
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
24
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
25
|
-
"@cosmicdrift/kumiko-types": "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.
|
|
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",
|