@cosmicdrift/kumiko-renderer-mail-html 0.0.0-stage → 0.363.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/LICENSE ADDED
@@ -0,0 +1,57 @@
1
+ Business Source License 1.1
2
+
3
+ Parameters
4
+
5
+ Licensor: Marc Frost
6
+
7
+ Licensed Work: @cosmicdrift/kumiko-renderer-mail-html
8
+ The Licensed Work is © 2026 Marc Frost.
9
+
10
+ Additional Use Grant:
11
+ You may use the Licensed Work in production for any purpose, including
12
+ commercially, EXCEPT for the Restricted Use.
13
+
14
+ "Restricted Use" is defined as using the Licensed Work to provide a platform
15
+ or service to third parties that allows them to host, deploy, or run their
16
+ own applications built with the Licensed Work. This includes, but is not
17
+ limited to: managed hosting services, software-as-a-service (SaaS) platforms,
18
+ platform-as-a-service (PaaS), developer platforms, or any multi-tenant
19
+ managed offering of the Licensed Work.
20
+
21
+ This restriction does not apply to the Licensor, any entity controlled by,
22
+ controlling, or under common control with the Licensor ("Affiliates"), or
23
+ contractors acting on their behalf. The Licensor remains free to use the
24
+ Licensed Work for any purpose, including for the operation of kumiko.rocks.
25
+
26
+ Change Date: 2030-05-05
27
+ Change License: Apache License, Version 2.0
28
+
29
+
30
+ Terms
31
+
32
+ The Licensor hereby grants you the right to copy, modify, create derivative works,
33
+ redistribute, and make non-production use of the Licensed Work. The Licensor may
34
+ make an Additional Use Grant, above, permitting limited production use.
35
+
36
+ Effective on the Change Date, or the fourth anniversary of the first publicly
37
+ available distribution of the Licensed Work under this License, whichever comes
38
+ first, this License will convert to the Change License.
39
+
40
+ This Business Source License governs use of the Licensed Work in all cases, except
41
+ as to any use that is explicitly granted in the Additional Use Grant above or
42
+ under the Change License after the Change Date.
43
+
44
+ If your use of the Licensed Work does not comply with the requirements of this
45
+ License, you must cease use of the Licensed Work immediately.
46
+
47
+ All copies of the Licensed Work, and all derivative works thereof, must include
48
+ this License.
49
+
50
+ This License does not grant you any right, title, or interest in any trademark,
51
+ logo, or branding of the Licensor, except as required to comply with this License.
52
+
53
+ TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON AN
54
+ “AS IS” BASIS. LICENSOR DISCLAIMS ALL WARRANTIES, EXPRESS OR IMPLIED, INCLUDING
55
+ WITHOUT LIMITATION WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE,
56
+ TITLE, AND NON-INFRINGEMENT. IN NO EVENT WILL LICENSOR BE LIABLE FOR ANY DAMAGES
57
+ ARISING OUT OF OR RELATED TO THIS LICENSE OR THE USE OF THE LICENSED WORK.
package/README.md CHANGED
@@ -1,3 +1,84 @@
1
- # Temporary Holding Version
1
+ # renderer-mail-html
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Plugin für `renderer-foundation`. Rendert `RenderRequest { kind: "mail" }` zu Betreff + Email-safe HTML + plain-text: **Mail-Template (Dokument-HTML + `meta.subject`) -> isolierter Handlebars -> MJML-Layout -> html-to-text**.
4
+
5
+ ## Pipeline
6
+
7
+ ```
8
+ RenderRequest { kind: "mail", payload: { slug, locale, variables, unsubscribeUrl? } }
9
+ ↓
10
+ 1. Template laden resolveTemplate(slug, kind "mail", locale), nur contentFormat html,
11
+ meta.subject Pflicht
12
+ 2. Layout laden meta.layoutSlug ?? "default", kind "mail-layout";
13
+ tenant -> system -> in-code DEFAULT_MAIL_LAYOUT_MJML
14
+ 3. Body kompilieren compileDocumentBodyToHandlebars (template-resolver): span[data-variable],
15
+ Links, Listenzeilen -> Handlebars-Quelle; Autoren-Klammern sind inert
16
+ 4. Rendern Handlebars.create() mit Helfern money/date/link, HTML-Escaping an;
17
+ Betreff + Preview-Text ohne Escaping, CR/LF entfernt
18
+ 5. Layout Body als Datum (`{{{content}}}`), nie in die Layout-Quelle konkateniert;
19
+ Layout-Kontext nur: content, subject, previewText, tenantName,
20
+ signature, imprint, unsubscribeUrl
21
+ 6. mjml + html-to-text
22
+ ↓
23
+ RenderResponse { kind: "mail", subject, html, text, previewText? }
24
+ ```
25
+
26
+ Datum und Zahlen formatiert der Renderer nach `payload.locale`, der Sprache der Vorlage, im langen Datumsformat („1. Oktober 2026“). `ctx.config` (optional) liefert die Währung (tenant-settings) und `tenantName` (Branding-Titel); ohne Config gelten `EUR` und `""`.
27
+
28
+ ## Mount
29
+
30
+ ```typescript
31
+ import { createRendererMailHtmlFeature } from "@cosmicdrift/kumiko-renderer-mail-html";
32
+ import { createTemplateResolverFeature } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
33
+ import { createRendererFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/renderer-foundation";
34
+
35
+ const features = [
36
+ createTemplateResolverFeature(), // Vorausgesetzt
37
+ createRendererFoundationFeature(), // Vorausgesetzt
38
+ createRendererMailHtmlFeature(), // Dieses Plugin
39
+ ];
40
+ ```
41
+
42
+ Mail-Texte kommen aus `r.notification({ variables, template, meta })`; `applyBootSeeds` legt sie als System-Templates an.
43
+
44
+ ## Caller-Pattern
45
+
46
+ ```typescript
47
+ const foundation = requireRendererFoundation(ctx, "sendWelcomeMail");
48
+ const renderer = foundation.createRendererForTenant({ tenantId, kind: "mail" });
49
+
50
+ const result = await renderer.render(
51
+ {
52
+ kind: "mail",
53
+ payload: {
54
+ slug: "my-feature:notify:welcome",
55
+ locale: "de",
56
+ variables: { name: user.name, verifyUrl: "https://..." },
57
+ },
58
+ },
59
+ { db: ctx.db, registry: ctx.registry, tenantId, config: ctx.config },
60
+ );
61
+
62
+ await ctx.mailTransport.send({ to: user.email, subject: result.subject, html: result.html, text: result.text });
63
+ ```
64
+
65
+ ## Tenant-Branding
66
+
67
+ Ein Tenant legt ein eigenes Layout als Template `kind: "mail-layout"`, `contentFormat: "mjml"`, Slug `default` (oder den Slug aus `meta.layoutSlug`) an. Es gewinnt gegen System-Layout und in-code Default. Das Layout sieht nur die oben genannten Variablen; den Body setzt es mit `{{{content}}}` ein.
68
+
69
+ ## Optionales Seeding
70
+
71
+ ```typescript
72
+ import { seedSystemMailLayout } from "@cosmicdrift/kumiko-renderer-mail-html/seeding";
73
+
74
+ await seedSystemMailLayout(db); // idempotent
75
+ ```
76
+
77
+ Ohne Seeding läuft das Plugin trotzdem und fällt auf `DEFAULT_MAIL_LAYOUT_MJML` zurück.
78
+
79
+ ## Out-of-Scope
80
+
81
+ - Visual-Editor für Layouts
82
+ - Inline-Image-Embedding (CID-Attachments)
83
+ - PDF/Image -> andere Plugins (`renderer-puppeteer-client`)
84
+ - Mail-Versand -> `delivery` + `channel-email` + `mail-transport-*`
@@ -0,0 +1,2 @@
1
+ export { DEFAULT_MAIL_LAYOUT_SLUG } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
2
+ export declare const DEFAULT_MAIL_LAYOUT_MJML = "<mjml>\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}}Sent by {{tenantName}}{{/if}}{{#if unsubscribeUrl}} \u00B7 <a href=\"{{link unsubscribeUrl}}\">Unsubscribe</a>{{/if}}\n </mj-text>\n </mj-column>\n </mj-section>\n </mj-body>\n</mjml>";
@@ -0,0 +1,48 @@
1
+ // Default MJML layout that apps can use out of the box for mail templates. The
2
+ // renderer falls back to it when neither tenant nor system has a "mail-layout"
3
+ // template with the requested slug (default: "default").
4
+ //
5
+ // **Variables the layout receives (nothing else):**
6
+ // - {{tenantName}} - header + footer
7
+ // - {{{content}}} - the rendered mail body (escaped values), triple-stash.
8
+ // It sits in mj-text: mj-raw would put block HTML straight into
9
+ // the column tbody, where browsers move it out of the table into
10
+ // the column div with font-size 0, so the body turns invisible.
11
+ // - {{subject}} - mj-title
12
+ // - {{previewText}} - inbox preview line
13
+ // - {{unsubscribeUrl}} - optional footer link
14
+ //
15
+ // **Tenant override:** upsert a template of kind "mail-layout", contentFormat
16
+ // "html" (structured: data-content slot plus header/signature/footer regions),
17
+ // slug "default" (or the slug a mail names in meta.layoutSlug). Raw mjml is
18
+ // system-only.
19
+ export { DEFAULT_MAIL_LAYOUT_SLUG } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
20
+ export const DEFAULT_MAIL_LAYOUT_MJML = `<mjml>
21
+ <mj-head>
22
+ <mj-title>{{subject}}</mj-title>
23
+ <mj-preview>{{previewText}}</mj-preview>
24
+ <mj-attributes>
25
+ <mj-all font-family="Helvetica, Arial, sans-serif" />
26
+ <mj-text font-size="14px" color="#333" line-height="1.6" />
27
+ </mj-attributes>
28
+ </mj-head>
29
+ <mj-body background-color="#f5f5f5">
30
+ <mj-section background-color="#ffffff" padding="20px">
31
+ <mj-column>
32
+ <mj-text font-size="22px" font-weight="bold" color="#111">{{tenantName}}</mj-text>
33
+ </mj-column>
34
+ </mj-section>
35
+ <mj-section background-color="#ffffff" padding="20px">
36
+ <mj-column>
37
+ <mj-text>{{{content}}}</mj-text>
38
+ </mj-column>
39
+ </mj-section>
40
+ <mj-section background-color="#f5f5f5" padding="20px">
41
+ <mj-column>
42
+ <mj-text font-size="11px" color="#888" align="center">
43
+ {{#if tenantName}}Sent by {{tenantName}}{{/if}}{{#if unsubscribeUrl}} · <a href="{{link unsubscribeUrl}}">Unsubscribe</a>{{/if}}
44
+ </mj-text>
45
+ </mj-column>
46
+ </mj-section>
47
+ </mj-body>
48
+ </mjml>`;
@@ -0,0 +1,16 @@
1
+ import type { RendererContext } from "@cosmicdrift/kumiko-bundled-features/renderer-foundation";
2
+ import { type LayoutRegions, type TemplateResolverApi, type TemplateResource } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
3
+ import Handlebars from "handlebars";
4
+ export type DocumentFormat = {
5
+ readonly locale: string;
6
+ readonly currency: string;
7
+ };
8
+ export declare function createDocumentHandlebars(format: DocumentFormat): typeof Handlebars;
9
+ export declare function compileDocumentSource(hbs: typeof Handlebars, source: string, options?: {
10
+ readonly noEscape?: boolean;
11
+ }): HandlebarsTemplateDelegate;
12
+ export declare function readDocumentFormat(ctx: RendererContext, templateLocale: string): Promise<DocumentFormat>;
13
+ export declare function readTenantName(ctx: RendererContext): Promise<string>;
14
+ export declare function resolveResourceFileRefs(tplApi: TemplateResolverApi, ctx: RendererContext, template: TemplateResource, keys: readonly string[]): Promise<ReadonlyMap<string, string>>;
15
+ export declare function collectRegionResourceKeys(regions: LayoutRegions): readonly string[];
16
+ export declare function mergeResourceFileRefs(bodyRefs: ReadonlyMap<string, string>, layoutRefs: ReadonlyMap<string, string>): ReadonlyMap<string, string>;
@@ -0,0 +1,136 @@
1
+ import { BRANDING_QN } from "@cosmicdrift/kumiko-bundled-features/managed-pages";
2
+ import { collectDocumentResourceKeys, TemplateNotFoundError, } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
3
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
4
+ import { isUuid, SYSTEM_TENANT_ID, TENANT_CURRENCY_CONFIG_KEY, } from "@cosmicdrift/kumiko-framework/engine";
5
+ import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
6
+ import { formatMoney, resolveSafeMoneyLocale } from "@cosmicdrift/kumiko-headless";
7
+ import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
8
+ import Handlebars from "handlebars";
9
+ // Handlebars instance, template resolution and resource lookup shared by the
10
+ // mail and PDF renderers, so both format money/dates and find images the same way.
11
+ const FALLBACK_CURRENCY = "EUR";
12
+ const SAFE_LINK_PROTOCOL = /^(https?:|mailto:)/i;
13
+ const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
14
+ function isPlainRecord(value) {
15
+ return typeof value === "object" && value !== null && !Array.isArray(value);
16
+ }
17
+ export function createDocumentHandlebars(format) {
18
+ const hbs = Handlebars.create();
19
+ hbs.registerHelper("money", (value) => {
20
+ if (typeof value === "number" && Number.isFinite(value)) {
21
+ return formatMoney(value, format.currency, format.locale);
22
+ }
23
+ if (isPlainRecord(value)) {
24
+ const { amount, currency } = value;
25
+ if (typeof amount === "number" && Number.isFinite(amount) && typeof currency === "string") {
26
+ return formatMoney(amount, currency, format.locale);
27
+ }
28
+ }
29
+ return value === undefined || value === null ? "" : String(value);
30
+ });
31
+ hbs.registerHelper("date", (value) => {
32
+ if (typeof value !== "string" && typeof value !== "number") {
33
+ return value === undefined || value === null ? "" : String(value);
34
+ }
35
+ const locale = resolveSafeMoneyLocale(format.locale);
36
+ try {
37
+ // A date-only string has no zone; going through an instant would show the
38
+ // previous day west of UTC.
39
+ if (typeof value === "string" && DATE_ONLY.test(value)) {
40
+ return Temporal.PlainDate.from(value).toLocaleString(locale, { dateStyle: "long" });
41
+ }
42
+ const instant = typeof value === "number"
43
+ ? Temporal.Instant.fromEpochMilliseconds(value)
44
+ : Temporal.Instant.from(value);
45
+ return instant.toLocaleString(locale, { dateStyle: "long", timeZone: "UTC" });
46
+ }
47
+ catch {
48
+ return String(value);
49
+ }
50
+ });
51
+ hbs.registerHelper("link", (value) => typeof value === "string" && SAFE_LINK_PROTOCOL.test(value.trim()) ? value : "#");
52
+ return hbs;
53
+ }
54
+ const KNOWN_HELPERS = { each: true, if: true, money: true, date: true, link: true };
55
+ export function compileDocumentSource(hbs, source, options = {}) {
56
+ return hbs.compile(source, {
57
+ knownHelpers: KNOWN_HELPERS,
58
+ knownHelpersOnly: true,
59
+ strict: false,
60
+ ...options,
61
+ });
62
+ }
63
+ // A key only exists in the registry when its feature is mounted; reading an
64
+ // unregistered key through the accessor would throw instead of falling back.
65
+ async function readConfigText(ctx, key) {
66
+ if (ctx.config === undefined || ctx.registry.getConfigKey(key) === undefined)
67
+ return undefined;
68
+ const value = await ctx.config(key);
69
+ return typeof value === "string" && value !== "" ? value : undefined;
70
+ }
71
+ // Dates and numbers follow the template locale, the language the document is
72
+ // read in; the tenant's locale setting must not turn a German invoice into
73
+ // "Oct 1, 2026". The currency is a tenant fact and still comes from its settings.
74
+ export async function readDocumentFormat(ctx, templateLocale) {
75
+ const currency = await readConfigText(ctx, TENANT_CURRENCY_CONFIG_KEY);
76
+ return { locale: templateLocale, currency: currency ?? FALLBACK_CURRENCY };
77
+ }
78
+ export async function readTenantName(ctx) {
79
+ return (await readConfigText(ctx, BRANDING_QN.title)) ?? "";
80
+ }
81
+ const LOGO_RESOURCE_KEY = "logo";
82
+ // Lookup order: the template's own link, the system template it overrides, and for
83
+ // "logo" the tenant's branding logo. The system lookup is skipped for system templates.
84
+ export async function resolveResourceFileRefs(tplApi, ctx, template, keys) {
85
+ if (keys.length === 0)
86
+ return new Map();
87
+ let systemLinks = {};
88
+ if (template.scope === "tenant") {
89
+ try {
90
+ systemLinks = (await tplApi.resolveTemplate({
91
+ tenantId: SYSTEM_TENANT_ID,
92
+ slug: template.slug,
93
+ kind: template.kind,
94
+ locale: template.locale,
95
+ })).linkedResources;
96
+ }
97
+ catch (e) {
98
+ if (!(e instanceof TemplateNotFoundError))
99
+ throw e;
100
+ }
101
+ }
102
+ const brandingLogo = keys.includes(LOGO_RESOURCE_KEY) ? await readBrandingLogo(ctx) : undefined;
103
+ const fileRefIds = new Map();
104
+ for (const key of keys) {
105
+ const fileRefId = ownLink(template.linkedResources, key) ??
106
+ ownLink(systemLinks, key) ??
107
+ (key === LOGO_RESOURCE_KEY ? brandingLogo : undefined);
108
+ if (fileRefId !== undefined)
109
+ fileRefIds.set(key, fileRefId);
110
+ }
111
+ return fileRefIds;
112
+ }
113
+ // The token is a free field of the tenant admin and the send reader also reaches the system
114
+ // tenant's files, so only a file of the rendering tenant itself counts.
115
+ async function readBrandingLogo(ctx) {
116
+ const fileRefId = await readConfigText(ctx, BRANDING_QN.logoFile);
117
+ if (fileRefId === undefined || !isUuid(fileRefId))
118
+ return undefined;
119
+ const [row] = await selectMany(ctx.db, fileRefsTable, {
120
+ id: fileRefId,
121
+ tenantId: ctx.tenantId,
122
+ isDeleted: false,
123
+ });
124
+ return row === undefined ? undefined : fileRefId;
125
+ }
126
+ function ownLink(links, key) {
127
+ return Object.hasOwn(links, key) && links[key] !== "" ? links[key] : undefined;
128
+ }
129
+ export function collectRegionResourceKeys(regions) {
130
+ return collectDocumentResourceKeys([regions.header, regions.signature, regions.footer].join(""));
131
+ }
132
+ // A cid or data URI per key can point to one file only, so a key the body already
133
+ // resolved keeps the body's file even when the layout links another one.
134
+ export function mergeResourceFileRefs(bodyRefs, layoutRefs) {
135
+ return new Map([...layoutRefs, ...bodyRefs]);
136
+ }
@@ -0,0 +1,2 @@
1
+ import type { FeatureDefinition } from "@cosmicdrift/kumiko-types/feature";
2
+ export declare function createRendererMailHtmlFeature(): FeatureDefinition;
@@ -0,0 +1,25 @@
1
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { mailHtmlRender } from "./render.js";
3
+ // renderer-mail-html: plugin for renderer-foundation, renders `kind="mail"`
4
+ // requests to subject + email-safe HTML + plain text.
5
+ //
6
+ // **Caller pattern:** the caller (e.g. delivery's email-channel) selects the plugin
7
+ // via `createRendererForTenant({ tenantId, kind: "mail" })` and calls
8
+ // `plugin.render({ kind: "mail", payload: { slug, locale, variables } }, ctx)`.
9
+ // The plugin resolves the mail template and its layout itself through
10
+ // template-resolver (tenant -> system), so ctx needs `db`, `registry`, `tenantId`
11
+ // and optionally `config` for the tenant's currency and branding.
12
+ //
13
+ // **Layout:** meta.layoutSlug ?? "default", kind "mail-layout". Without a seeded
14
+ // layout the in-code DEFAULT_MAIL_LAYOUT_MJML is used, so mail works from day 1.
15
+ export function createRendererMailHtmlFeature() {
16
+ return defineFeature("renderer-mail-html", (r) => {
17
+ 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, in-code `DEFAULT_MAIL_LAYOUT_MJML` as last resort) that `mjml()` compiles to email-safe table HTML, with `html-to-text` producing the plain-text alternative. Seed an editable system layout with `seedSystemMailLayout(db)`. Requires `renderer-foundation` + `template-resolver`.');
18
+ r.requires("renderer-foundation");
19
+ r.requires("template-resolver");
20
+ r.useExtension("renderer", "mail-html", {
21
+ kinds: ["mail"],
22
+ render: mailHtmlRender,
23
+ });
24
+ });
25
+ }
@@ -0,0 +1,4 @@
1
+ export { DEFAULT_MAIL_LAYOUT_MJML } from "./default-layout.js";
2
+ export { collectRegionResourceKeys, compileDocumentSource, createDocumentHandlebars, type DocumentFormat, mergeResourceFileRefs, readDocumentFormat, readTenantName, resolveResourceFileRefs, } from "./document-kit.js";
3
+ export { createRendererMailHtmlFeature } from "./feature.js";
4
+ export { mailHtmlRender } from "./render.js";
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { DEFAULT_MAIL_LAYOUT_MJML } from "./default-layout.js";
2
+ export { collectRegionResourceKeys, compileDocumentSource, createDocumentHandlebars, mergeResourceFileRefs, readDocumentFormat, readTenantName, resolveResourceFileRefs, } from "./document-kit.js";
3
+ export { createRendererMailHtmlFeature } from "./feature.js";
4
+ export { mailHtmlRender } from "./render.js";
@@ -0,0 +1,10 @@
1
+ import { type FileAttachmentRef, type InlineAttachmentRef, type MailAttachmentRef, type RenderedPdfAttachmentRef } from "@cosmicdrift/kumiko-bundled-features/renderer-foundation";
2
+ import type { TemplatePdfAttachmentLink } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
3
+ export declare function buildRenderedPdfAttachments(args: {
4
+ readonly slug: string;
5
+ readonly links: readonly TemplatePdfAttachmentLink[];
6
+ readonly variables: Readonly<Record<string, unknown>>;
7
+ readonly locale: string;
8
+ readonly renderFileName: (pattern: string) => string;
9
+ }): readonly RenderedPdfAttachmentRef[];
10
+ export declare function combineMailAttachments(slug: string, inline: readonly InlineAttachmentRef[], files: readonly FileAttachmentRef[], renderedPdfs: readonly RenderedPdfAttachmentRef[]): readonly MailAttachmentRef[];
@@ -0,0 +1,26 @@
1
+ import { MAX_EMAIL_ATTACHMENTS, MAX_RENDERED_PDF_VARIABLES_BYTES, sanitizePdfAttachmentFileName, } from "@cosmicdrift/kumiko-bundled-features/delivery";
2
+ import { RendererError, } from "@cosmicdrift/kumiko-bundled-features/renderer-foundation";
3
+ export function buildRenderedPdfAttachments(args) {
4
+ const { slug, links, variables, locale, renderFileName } = args;
5
+ if (links.length === 0)
6
+ return [];
7
+ if (Buffer.byteLength(JSON.stringify(variables)) > MAX_RENDERED_PDF_VARIABLES_BYTES) {
8
+ 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
+ return links.map((link) => ({
11
+ kind: "rendered-pdf",
12
+ templateSlug: link.templateSlug,
13
+ variables,
14
+ locale,
15
+ fileName: sanitizePdfAttachmentFileName(renderFileName(link.fileName), link.templateSlug),
16
+ }));
17
+ }
18
+ // Inline images come first so their order in the mail matches the body; the delivery layer
19
+ // rejects more than MAX_EMAIL_ATTACHMENTS, so the render fails here with the slug instead.
20
+ export function combineMailAttachments(slug, inline, files, renderedPdfs) {
21
+ const attachments = [...inline, ...files, ...renderedPdfs];
22
+ if (attachments.length > MAX_EMAIL_ATTACHMENTS) {
23
+ throw new RendererError(`renderer-mail-html: mail "${slug}" would carry ${attachments.length} attachments (${inline.length} inline images, ${files.length} files, ${renderedPdfs.length} rendered PDFs), the limit is ${MAX_EMAIL_ATTACHMENTS}`, "other");
24
+ }
25
+ return attachments;
26
+ }
@@ -0,0 +1,2 @@
1
+ import { type RendererContext, type RenderRequest, type RenderResponse } from "@cosmicdrift/kumiko-bundled-features/renderer-foundation";
2
+ export declare function mailHtmlRender(req: RenderRequest, ctx: RendererContext): Promise<RenderResponse>;
package/dist/render.js ADDED
@@ -0,0 +1,307 @@
1
+ // renderer-mail-html pipeline for kind "mail":
2
+ // 1. Resolve the mail template (tenant -> system) and its layout
3
+ // (meta.layoutSlug ?? "default", kind mail-layout, fallback to the in-code
4
+ // DEFAULT_MAIL_LAYOUT_MJML).
5
+ // 2. compileDocumentBodyToHandlebars turns the sanitized document body into
6
+ // Handlebars source; every author brace is neutralized there, so the only
7
+ // expressions are the ones template-resolver wrote.
8
+ // 3. The body renders with HTML escaping on. The result is handed to the
9
+ // layout as data (`{{{content}}}`), never concatenated into layout source.
10
+ // 4. mjml -> email HTML, html-to-text -> plain-text alternative.
11
+ // Handlebars runs in an isolated instance with a fixed helper set.
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";
14
+ import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
15
+ import { convert as htmlToText } from "html-to-text";
16
+ import mjml2html from "mjml";
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";
21
+ const LINE_BREAKS = /[\r\n]+/g;
22
+ function describe(e) {
23
+ return e instanceof Error ? e.message : String(e);
24
+ }
25
+ const IN_CODE_LAYOUT = {
26
+ content: DEFAULT_MAIL_LAYOUT_MJML,
27
+ format: "mjml",
28
+ ref: null,
29
+ template: null,
30
+ };
31
+ // Only system layouts are trusted as written: a tenant mjml row left over from
32
+ // before that rule, or a tenant html row failing the layout sanitizer, is
33
+ // replaced by the system layout of the same slug.
34
+ async function resolveLayout(tplApi, ctx, meta, locale) {
35
+ const slug = meta.layoutSlug ?? DEFAULT_MAIL_LAYOUT_SLUG;
36
+ try {
37
+ let layout = await tplApi.resolveLayout({
38
+ tenantId: ctx.tenantId,
39
+ slug,
40
+ kind: "mail-layout",
41
+ locale,
42
+ });
43
+ let content = layout.content;
44
+ let useSystemLayout = false;
45
+ if (layout.scope !== "system") {
46
+ if (layout.contentFormat === "html") {
47
+ // Re-checked on every render: rows written before the write gate existed
48
+ // or through another path must not reach the mail unsanitized.
49
+ const sanitized = sanitizeLayoutHtml(content);
50
+ if (sanitized.ok)
51
+ content = sanitized.html;
52
+ else
53
+ useSystemLayout = true;
54
+ }
55
+ else {
56
+ useSystemLayout = layout.contentFormat === "mjml";
57
+ }
58
+ }
59
+ if (useSystemLayout) {
60
+ layout = await tplApi.resolveLayout({
61
+ tenantId: SYSTEM_TENANT_ID,
62
+ slug,
63
+ kind: "mail-layout",
64
+ locale,
65
+ });
66
+ content = layout.content;
67
+ }
68
+ return {
69
+ content,
70
+ format: layout.contentFormat === "html" ? "html" : "mjml",
71
+ ref: { templateId: layout.id, templateVersion: layout.version, scope: layout.scope },
72
+ template: layout,
73
+ };
74
+ }
75
+ catch (e) {
76
+ // Only "no layout seeded" falls back to the in-code layout. Connection or
77
+ // schema errors must not be masked, or every mail silently loses its branding.
78
+ if (!(e instanceof TemplateNotFoundError)) {
79
+ throw new RendererError(`renderer-mail-html: layout resolution failed — ${describe(e)}`, "other");
80
+ }
81
+ return IN_CODE_LAYOUT;
82
+ }
83
+ }
84
+ async function findStoredMailTemplate(tplApi, ctx, slug, locale) {
85
+ try {
86
+ return await tplApi.resolveTemplate({ tenantId: ctx.tenantId, slug, kind: "mail", locale });
87
+ }
88
+ catch (e) {
89
+ if (e instanceof TemplateNotFoundError)
90
+ return null;
91
+ throw new RendererError(`renderer-mail-html: template resolution failed — ${describe(e)}`, "other");
92
+ }
93
+ }
94
+ async function resolveLayoutFileRefs(tplApi, ctx, layout) {
95
+ if (layout.format !== "html" || layout.template === null)
96
+ return new Map();
97
+ try {
98
+ return await resolveResourceFileRefs(tplApi, ctx, layout.template, collectRegionResourceKeys(readLayoutRegions(layout.content)));
99
+ }
100
+ catch (e) {
101
+ throw new RendererError(`renderer-mail-html: resolving the layout images failed — ${describe(e)}`, "other");
102
+ }
103
+ }
104
+ // The draft comes from a caller outside the write pipeline, so it passes the document
105
+ // sanitizer and the meta check again instead of being trusted as already gated.
106
+ function applyMailDraft(stored, draft, slug, locale) {
107
+ const sanitized = sanitizeDocumentHtml(draft.content);
108
+ if (!sanitized.ok) {
109
+ throw new RendererError(`renderer-mail-html: draft of "${slug}" violates the document rules (${sanitized.violations.map((v) => v.code).join(", ")})`, "invalid_payload");
110
+ }
111
+ const parsedMeta = parseTemplateMeta("mail", draft.meta);
112
+ if (!parsedMeta.ok || !isMailTemplateMeta(parsedMeta.meta)) {
113
+ throw new RendererError(`renderer-mail-html: draft of "${slug}" has no valid mail meta`, "invalid_payload");
114
+ }
115
+ const base = stored ?? {
116
+ id: "",
117
+ version: 0,
118
+ tenantId: "",
119
+ slug,
120
+ kind: "mail",
121
+ locale,
122
+ title: null,
123
+ folder: null,
124
+ content: "",
125
+ contentFormat: "html",
126
+ variableSchema: {},
127
+ linkedResources: {},
128
+ meta: null,
129
+ scope: "tenant",
130
+ parentTemplateId: null,
131
+ status: "draft",
132
+ modifiedAt: null,
133
+ };
134
+ return {
135
+ ...base,
136
+ content: sanitized.html,
137
+ contentFormat: "html",
138
+ meta: parsedMeta.meta,
139
+ variableSchema: { ...(draft.variableSchema ?? base.variableSchema) },
140
+ ...(draft.linkedResources !== undefined && {
141
+ linkedResources: imageLinksOf(draft.linkedResources),
142
+ fileAttachments: fileAttachmentsOf(draft.linkedResources),
143
+ pdfAttachmentLinks: pdfAttachmentLinksOf(draft.linkedResources),
144
+ }),
145
+ };
146
+ }
147
+ async function resolveMailTemplate(tplApi, ctx, slug, locale, draft) {
148
+ const stored = await findStoredMailTemplate(tplApi, ctx, slug, locale);
149
+ const template = draft === undefined ? stored : applyMailDraft(stored, draft, slug, locale);
150
+ if (template === null) {
151
+ throw new RendererError(`renderer-mail-html: no mail template "${slug}" for locale "${locale}"`, "invalid_payload");
152
+ }
153
+ if (template.contentFormat !== "html") {
154
+ throw new RendererError(`renderer-mail-html: template "${slug}" has contentFormat "${template.contentFormat}", only html is supported`, "other");
155
+ }
156
+ if (!isMailTemplateMeta(template.meta)) {
157
+ throw new RendererError(`renderer-mail-html: template "${slug}" has no valid mail meta (subject is required)`, "other");
158
+ }
159
+ return { template, meta: template.meta };
160
+ }
161
+ // Each data-resource image is referenced by cid; the bytes are read at send time, so
162
+ // the render result carries only file ref ids.
163
+ async function renderMailBody(tplApi, ctx, template, hbs, context) {
164
+ try {
165
+ const resourceFileRefs = await resolveResourceFileRefs(tplApi, ctx, template, collectDocumentResourceKeys(template.content));
166
+ const handlebarsSource = compileDocumentBodyToHandlebars(template.content, resolveTemplateVariables(ctx.registry, template.slug, template.variableSchema), {
167
+ resourceSrc: (key) => (resourceFileRefs.has(key) ? `cid:${key}` : undefined),
168
+ alignmentAsInlineStyle: true,
169
+ });
170
+ return {
171
+ contentHtml: compileDocumentSource(hbs, handlebarsSource)(context),
172
+ resourceFileRefs,
173
+ };
174
+ }
175
+ catch (e) {
176
+ throw new RendererError(`renderer-mail-html: rendering the body of "${template.slug}" failed — ${describe(e)}`, "other");
177
+ }
178
+ }
179
+ function renderSubjectLine(hbs, source, context) {
180
+ return compileDocumentSource(hbs, source, { noEscape: true })(context).replace(LINE_BREAKS, "");
181
+ }
182
+ function renderFileNamePattern(hbs, pattern, slug, context) {
183
+ try {
184
+ return renderSubjectLine(hbs, pattern, context);
185
+ }
186
+ catch (e) {
187
+ throw new RendererError(`renderer-mail-html: rendering the attachment file name "${pattern}" of "${slug}" failed — ${describe(e)}`, "other");
188
+ }
189
+ }
190
+ function renderSubjectLines(hbs, meta, slug, context) {
191
+ try {
192
+ return {
193
+ subject: renderSubjectLine(hbs, meta.subject, context),
194
+ previewText: meta.previewText === undefined
195
+ ? undefined
196
+ : renderSubjectLine(hbs, meta.previewText, context),
197
+ };
198
+ }
199
+ catch (e) {
200
+ throw new RendererError(`renderer-mail-html: rendering subject or preview text of "${slug}" failed — ${describe(e)}`, "other");
201
+ }
202
+ }
203
+ function assembleMailAttachments(args) {
204
+ const { slug, template, resourceFileRefs, variables, locale, renderFileName } = args;
205
+ const inlineAttachments = [...resourceFileRefs].map(([cid, fileRefId]) => ({ kind: "inline", cid, fileRefId }));
206
+ const renderedPdfs = buildRenderedPdfAttachments({
207
+ slug,
208
+ links: template.pdfAttachmentLinks ?? [],
209
+ variables,
210
+ locale,
211
+ renderFileName,
212
+ });
213
+ return combineMailAttachments(slug, inlineAttachments, template.fileAttachments ?? [], renderedPdfs);
214
+ }
215
+ export async function mailHtmlRender(req, ctx) {
216
+ if (req.kind !== "mail") {
217
+ // Foundation selects plugins only for matching kinds; defensive guard.
218
+ throw new RendererError(`renderer-mail-html supports only kind="mail", got "${req.kind}"`, "invalid_payload");
219
+ }
220
+ const { slug, locale, variables, unsubscribeUrl, draft } = req.payload;
221
+ const tplApi = createTemplateResolverApi(ctx.db);
222
+ const { template, meta } = await resolveMailTemplate(tplApi, ctx, slug, locale, draft);
223
+ const layout = await resolveLayout(tplApi, ctx, meta, locale);
224
+ const [format, tenantName] = await Promise.all([
225
+ readDocumentFormat(ctx, locale),
226
+ readTenantName(ctx),
227
+ ]);
228
+ const hbs = createDocumentHandlebars(format);
229
+ const context = {
230
+ ...variables,
231
+ tenantName,
232
+ signature: "",
233
+ imprint: "",
234
+ unsubscribeUrl: unsubscribeUrl ?? "",
235
+ };
236
+ const body = await renderMailBody(tplApi, ctx, template, hbs, context);
237
+ const { contentHtml } = body;
238
+ const layoutFileRefs = await resolveLayoutFileRefs(tplApi, ctx, layout);
239
+ const resourceFileRefs = mergeResourceFileRefs(body.resourceFileRefs, layoutFileRefs);
240
+ const attachments = assembleMailAttachments({
241
+ slug,
242
+ template,
243
+ resourceFileRefs,
244
+ variables,
245
+ locale,
246
+ renderFileName: (pattern) => renderFileNamePattern(hbs, pattern, slug, context),
247
+ });
248
+ const { subject, previewText } = renderSubjectLines(hbs, meta, slug, context);
249
+ let mjmlSource;
250
+ try {
251
+ const layoutContext = {
252
+ subject,
253
+ previewText: previewText ?? "",
254
+ tenantName,
255
+ signature: "",
256
+ imprint: "",
257
+ 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
+ ],
290
+ });
291
+ return {
292
+ kind: "mail",
293
+ subject,
294
+ html: mjmlResult.html,
295
+ text,
296
+ ...(previewText === undefined ? {} : { previewText }),
297
+ ...(attachments.length > 0 && { attachments }),
298
+ ...(draft === undefined && {
299
+ template: {
300
+ templateId: template.id,
301
+ templateVersion: template.version,
302
+ scope: template.scope,
303
+ },
304
+ }),
305
+ ...(layout.ref === null ? {} : { layout: layout.ref }),
306
+ };
307
+ }
@@ -0,0 +1,10 @@
1
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
2
+ export type SeedSystemMailLayoutOptions = {
3
+ /** Override MJML — if omitted, DEFAULT_MAIL_LAYOUT_MJML is used. */
4
+ readonly mjml?: string;
5
+ /** Locale for the layout row — default "en". */
6
+ readonly locale?: string;
7
+ /** Layout slug — default "default". */
8
+ readonly slug?: string;
9
+ };
10
+ export declare function seedSystemMailLayout(db: DbConnection, options?: SeedSystemMailLayoutOptions): Promise<void>;
@@ -0,0 +1,25 @@
1
+ // Optional seeding helper for app authors. Writes DEFAULT_MAIL_LAYOUT_MJML as the
2
+ // system layout (slug "default", kind "mail-layout", tenantId=SYSTEM_TENANT_ID)
3
+ // into the template-resource table.
4
+ //
5
+ // **When to use?** When the app author wants the default layout editable in the
6
+ // tenant admin UI. Without seeding the renderer falls back to the in-code
7
+ // DEFAULT_MAIL_LAYOUT_MJML, which renders identically but is not editable.
8
+ //
9
+ // Idempotent: a second call with unchanged content writes no event, a changed
10
+ // layout updates the existing row.
11
+ import { seedSystemTemplate } from "@cosmicdrift/kumiko-bundled-features/template-resolver/seeding";
12
+ import { DEFAULT_MAIL_LAYOUT_MJML, DEFAULT_MAIL_LAYOUT_SLUG } from "./default-layout.js";
13
+ const DEFAULT_LOCALE = "en";
14
+ export async function seedSystemMailLayout(db, options = {}) {
15
+ const { mjml = DEFAULT_MAIL_LAYOUT_MJML, locale = DEFAULT_LOCALE, slug = DEFAULT_MAIL_LAYOUT_SLUG, } = options;
16
+ await seedSystemTemplate(db, {
17
+ slug,
18
+ kind: "mail-layout",
19
+ locale,
20
+ content: mjml,
21
+ contentFormat: "mjml",
22
+ meta: {},
23
+ ifExists: "update",
24
+ });
25
+ }
@@ -0,0 +1,11 @@
1
+ export type StructuredLayoutInput = {
2
+ readonly layoutHtml: string;
3
+ readonly contentHtml: string;
4
+ /** Layout context (tenantName, subject, previewText, signature, imprint, unsubscribeUrl). */
5
+ readonly context: Readonly<Record<string, unknown>>;
6
+ /** Compiles Handlebars source with the renderer's isolated instance and helper set. */
7
+ readonly compile: (source: string) => (context: Readonly<Record<string, unknown>>) => string;
8
+ /** src for `img[data-resource]` in the regions; without it layout images are dropped. */
9
+ readonly resourceSrc?: (key: string) => string | undefined;
10
+ };
11
+ export declare function renderStructuredLayoutMjml(input: StructuredLayoutInput): string;
@@ -0,0 +1,54 @@
1
+ // Renders a structured (html) mail layout: the tenant-editable regions go into
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
+ const STRUCTURED_LAYOUT_FRAME = `<mjml>
5
+ <mj-head>
6
+ <mj-title>{{subject}}</mj-title>
7
+ <mj-preview>{{previewText}}</mj-preview>
8
+ <mj-attributes>
9
+ <mj-all font-family="Helvetica, Arial, sans-serif" />
10
+ <mj-text font-size="14px" color="#333" line-height="1.6" />
11
+ </mj-attributes>
12
+ </mj-head>
13
+ <mj-body background-color="#f5f5f5">
14
+ {{#if regionHeader}}
15
+ <mj-section background-color="#ffffff" padding="20px">
16
+ <mj-column>
17
+ <mj-text>{{{regionHeader}}}</mj-text>
18
+ </mj-column>
19
+ </mj-section>
20
+ {{/if}}
21
+ <mj-section background-color="#ffffff" padding="20px">
22
+ <mj-column>
23
+ <mj-raw>{{{content}}}</mj-raw>
24
+ </mj-column>
25
+ </mj-section>
26
+ {{#if regionSignature}}
27
+ <mj-section background-color="#ffffff" padding="20px">
28
+ <mj-column>
29
+ <mj-text>{{{regionSignature}}}</mj-text>
30
+ </mj-column>
31
+ </mj-section>
32
+ {{/if}}
33
+ {{#if regionFooter}}
34
+ <mj-section background-color="#f5f5f5" padding="20px">
35
+ <mj-column>
36
+ <mj-text font-size="11px" color="#888" align="center">{{{regionFooter}}}</mj-text>
37
+ </mj-column>
38
+ </mj-section>
39
+ {{/if}}
40
+ </mj-body>
41
+ </mjml>`;
42
+ export function renderStructuredLayoutMjml(input) {
43
+ const regions = readLayoutRegions(input.layoutHtml);
44
+ const renderRegion = (region) => region.trim() === ""
45
+ ? ""
46
+ : input.compile(compileDocumentBodyToHandlebars(region, {}, input.resourceSrc && { resourceSrc: input.resourceSrc }))(input.context);
47
+ return input.compile(STRUCTURED_LAYOUT_FRAME)({
48
+ ...input.context,
49
+ content: input.contentHtml,
50
+ regionHeader: renderRegion(regions.header),
51
+ regionSignature: renderRegion(regions.signature),
52
+ regionFooter: renderRegion(regions.footer),
53
+ });
54
+ }
package/package.json CHANGED
@@ -1,6 +1,64 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-renderer-mail-html",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.363.0",
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
+ "license": "BUSL-1.1",
6
+ "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
7
+ "type": "module",
8
+ "kumiko": {
9
+ "runtime": "runtime"
10
+ },
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "default": "./dist/index.js"
15
+ },
16
+ "./seeding": {
17
+ "types": "./dist/seeding.d.ts",
18
+ "default": "./dist/seeding.js"
19
+ }
20
+ },
21
+ "dependencies": {
22
+ "@cosmicdrift/kumiko-bundled-features": "0.363.0",
23
+ "@cosmicdrift/kumiko-framework": "0.363.0",
24
+ "@cosmicdrift/kumiko-headless": "0.363.0",
25
+ "@cosmicdrift/kumiko-types": "0.363.0",
26
+ "handlebars": "^4.7.8",
27
+ "html-to-text": "^9.0.5",
28
+ "mjml": "^5.2.2"
29
+ },
30
+ "devDependencies": {
31
+ "@cosmicdrift/kumiko-testing": "0.363.0",
32
+ "@types/html-to-text": "^9.0.4",
33
+ "@types/mjml": "^5.0.0"
34
+ },
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/CosmicDriftGameStudio/kumiko-framework.git",
38
+ "directory": "packages/renderer-mail-html"
39
+ },
40
+ "homepage": "https://kumiko.rocks",
41
+ "bugs": {
42
+ "url": "https://github.com/CosmicDriftGameStudio/kumiko-framework/issues"
43
+ },
44
+ "publishConfig": {
45
+ "registry": "https://registry.npmjs.org",
46
+ "access": "public",
47
+ "exports": {
48
+ ".": {
49
+ "types": "./dist/index.d.ts",
50
+ "default": "./dist/index.js"
51
+ },
52
+ "./seeding": {
53
+ "types": "./dist/seeding.d.ts",
54
+ "default": "./dist/seeding.js"
55
+ }
56
+ }
57
+ },
58
+ "files": [
59
+ "dist",
60
+ "src/changes.json",
61
+ "README.md",
62
+ "LICENSE"
63
+ ]
64
+ }
@@ -0,0 +1,59 @@
1
+ [
2
+ {
3
+ "version": "0.363.0",
4
+ "type": "breaking",
5
+ "title": "Documents format dates and numbers by the template locale, dates in the long style",
6
+ "detail": "readDocumentFormat used the tenant setting tenant-settings:config:locale ahead of the template locale, so a German invoice of a tenant set to English printed \"Oct 1, 2026\" and \"€780.00\". Mails, mail previews and document PDFs now format by the locale of the template that is rendered; the tenant settings still supply the currency. The date helper prints the long style (\"1. Oktober 2026\", \"October 1, 2026\") instead of the medium style (\"01.10.2026\", \"Oct 1, 2026\").",
7
+ "migration": "Tests or snapshots that assert rendered dates change from \"05.03.2026\" to \"5. März 2026\" (de) and from \"Mar 5, 2026\" to \"March 5, 2026\" (en). A tenant that relied on tenant-settings:config:locale to change the number format of its documents now needs a template in that locale."
8
+ },
9
+ {
10
+ "version": "0.363.0",
11
+ "type": "improvement",
12
+ "title": "Images in layout regions, exported document-kit helpers",
13
+ "detail": "A mail layout region can contain images (data-resource) that are sent as inline attachments. The document helpers (createDocumentHandlebars, compileDocumentSource, readDocumentFormat, readTenantName, resolveResourceFileRefs, collectRegionResourceKeys, mergeResourceFileRefs) are exported, so other renderers format documents the same way."
14
+ },
15
+ {
16
+ "version": "0.363.0",
17
+ "type": "improvement",
18
+ "title": "Text alignment of paragraphs and headings is sent as an inline style",
19
+ "detail": "A paragraph or heading with data-align is rendered with an inline text-align, because mail clients ignore document CSS."
20
+ },
21
+ {
22
+ "version": "0.363.0",
23
+ "type": "breaking",
24
+ "title": "renders kind mail from document templates, Markdown path removed",
25
+ "detail": "The plugin serves kind mail. It resolves the template by slug (contentFormat html, meta.subject required), compiles the document body to Handlebars through template-resolver, renders it in an isolated Handlebars instance (helpers money, date and link, HTML escaping on) and places the result into the mail-layout named by meta.layoutSlug (default slug default, in-code DEFAULT_MAIL_LAYOUT_MJML as fallback). The layout only sees content, subject, previewText, tenantName, signature, imprint and unsubscribeUrl. The response carries subject and previewText; the subject has line breaks removed. Markdown rendering and the content/contentFormat payload are gone; DEFAULT_MAIL_LAYOUT_MJML uses tenantName instead of appName. seedSystemMailLayout writes kind mail-layout slug default through seedSystemTemplate and is idempotent. New dependency on @cosmicdrift/kumiko-headless.",
26
+ "migration": "Send kind mail requests with { slug, locale, variables } and move mail texts into kind mail templates (html body plus meta.subject), for example through r.notification({ template, meta }). Custom mail layouts become kind mail-layout, contentFormat mjml, slug default and use {{tenantName}} instead of {{appName}}; the body is inserted with {{{content}}}."
27
+ },
28
+ {
29
+ "version": "0.363.0",
30
+ "type": "breaking",
31
+ "title": "Package moved to the framework under the @cosmicdrift scope",
32
+ "detail": "Renamed from @cosmicdriftgamestudio/kumiko-renderer-mail-html (GitHub Packages, kumiko-enterprise) to @cosmicdrift/kumiko-renderer-mail-html on npmjs; the API is unchanged.",
33
+ "migration": "Replace @cosmicdriftgamestudio/kumiko-renderer-mail-html with @cosmicdrift/kumiko-renderer-mail-html in package.json and in every import; install from npmjs instead of GitHub Packages."
34
+ },
35
+ {
36
+ "version": "0.363.0",
37
+ "type": "fix",
38
+ "title": "The default mail layout shows the mail body",
39
+ "detail": "The in-code default mail layout 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 without a mail-layout row had an invisible body. seedSystemMailLayout updates a seeded row on its next run."
40
+ },
41
+ {
42
+ "version": "0.363.0",
43
+ "type": "improvement",
44
+ "title": "Mail renderer attaches linked PDF templates as rendered-pdf attachments",
45
+ "detail": "The renderer builds a RenderedPdfAttachmentRef per linked PDF with the mail's render variables, the mail locale and the file name pattern rendered like the subject and cleaned by sanitizePdfAttachmentFileName, now exported from @cosmicdrift/kumiko-bundled-features/delivery (control characters and / \\ \" ; .. removed, at most 200 characters including .pdf, empty falls back to the last slug segment). Order is inline images, files, rendered PDFs, all within MAX_EMAIL_ATTACHMENTS; variables above MAX_RENDERED_PDF_VARIABLES_BYTES fail the render with the mail slug. RenderedPdfAttachmentRef now lives in renderer-foundation (still exported from delivery) and RenderResponse and RenderedMail allow it."
46
+ },
47
+ {
48
+ "version": "0.363.0",
49
+ "type": "improvement",
50
+ "title": "Renders a MailPayload.draft instead of the stored template body and subject",
51
+ "detail": "With a draft the stored template of the slug only supplies its linked images and variable schema; the body goes through the document sanitizer and the meta through the mail meta check, and a violation is an invalid_payload error. The response has no template reference because a draft is not a stored version."
52
+ },
53
+ {
54
+ "version": "0.363.0",
55
+ "type": "fix",
56
+ "title": "Mails use a tenant layout stored under another locale",
57
+ "detail": "The mail renderer resolves its layout with resolveLayout, so a tenant layout saved in en no longer leaves German mails without the tenant branding."
58
+ }
59
+ ]