@cosmicdrift/kumiko-renderer-mail-html 0.0.0-stage → 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/LICENSE +57 -0
- package/README.md +83 -2
- package/dist/default-layout.d.ts +2 -0
- package/dist/default-layout.js +48 -0
- package/dist/document-kit.d.ts +17 -0
- package/dist/document-kit.js +169 -0
- package/dist/feature.d.ts +2 -0
- package/dist/feature.js +25 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/mail-attachments.d.ts +11 -0
- package/dist/mail-attachments.js +37 -0
- package/dist/render.d.ts +2 -0
- package/dist/render.js +372 -0
- package/dist/seeding.d.ts +10 -0
- package/dist/seeding.js +25 -0
- package/dist/structured-layout.d.ts +17 -0
- package/dist/structured-layout.js +73 -0
- package/package.json +62 -4
- package/src/changes.json +71 -0
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
|
-
#
|
|
1
|
+
# renderer-mail-html
|
|
2
2
|
|
|
3
|
-
|
|
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,17 @@
|
|
|
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 readBrandingAccentColor(ctx: RendererContext): Promise<string | undefined>;
|
|
15
|
+
export declare function resolveResourceFileRefs(tplApi: TemplateResolverApi, ctx: RendererContext, template: TemplateResource, keys: readonly string[]): Promise<ReadonlyMap<string, string>>;
|
|
16
|
+
export declare function collectRegionResourceKeys(regions: LayoutRegions): readonly string[];
|
|
17
|
+
export declare function mergeResourceFileRefs(bodyRefs: ReadonlyMap<string, string>, layoutRefs: ReadonlyMap<string, string>): ReadonlyMap<string, string>;
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { BRANDING_QN } from "@cosmicdrift/kumiko-bundled-features/managed-pages";
|
|
2
|
+
import { collectDocumentResourceKeys, isHexColor, sanitizeDocumentHtml, TemplateNotFoundError, } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
|
|
3
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
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 { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
|
|
7
|
+
import { formatMoney, resolveSafeMoneyLocale } from "@cosmicdrift/kumiko-headless";
|
|
8
|
+
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
9
|
+
import Handlebars from "handlebars";
|
|
10
|
+
// Handlebars instance, template resolution and resource lookup shared by the
|
|
11
|
+
// mail and PDF renderers, so both format money/dates and find images the same way.
|
|
12
|
+
const documentKitLogger = createFallbackLogger("renderer-mail-html");
|
|
13
|
+
const FALLBACK_CURRENCY = "EUR";
|
|
14
|
+
const SAFE_LINK_PROTOCOL = /^(https?:|mailto:)/i;
|
|
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})?$/;
|
|
17
|
+
function isPlainRecord(value) {
|
|
18
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
19
|
+
}
|
|
20
|
+
export function createDocumentHandlebars(format) {
|
|
21
|
+
const hbs = Handlebars.create();
|
|
22
|
+
hbs.registerHelper("money", (value) => {
|
|
23
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
24
|
+
return formatMoney(value, format.currency, format.locale);
|
|
25
|
+
}
|
|
26
|
+
if (isPlainRecord(value)) {
|
|
27
|
+
const { amount, currency } = value;
|
|
28
|
+
if (typeof amount === "number" && Number.isFinite(amount) && typeof currency === "string") {
|
|
29
|
+
return formatMoney(amount, currency, format.locale);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return value === undefined || value === null ? "" : String(value);
|
|
33
|
+
});
|
|
34
|
+
hbs.registerHelper("date", (value) => {
|
|
35
|
+
if (typeof value !== "string" && typeof value !== "number") {
|
|
36
|
+
return value === undefined || value === null ? "" : String(value);
|
|
37
|
+
}
|
|
38
|
+
const locale = resolveSafeMoneyLocale(format.locale);
|
|
39
|
+
try {
|
|
40
|
+
// A date-only string has no zone; going through an instant would show the
|
|
41
|
+
// previous day west of UTC.
|
|
42
|
+
if (typeof value === "string" && DATE_ONLY.test(value)) {
|
|
43
|
+
return Temporal.PlainDate.from(value).toLocaleString(locale, { dateStyle: "long" });
|
|
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
|
+
}
|
|
52
|
+
const instant = typeof value === "number"
|
|
53
|
+
? Temporal.Instant.fromEpochMilliseconds(value)
|
|
54
|
+
: Temporal.Instant.from(value);
|
|
55
|
+
return instant.toLocaleString(locale, { dateStyle: "long", timeZone: "UTC" });
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return String(value);
|
|
59
|
+
}
|
|
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
|
+
});
|
|
73
|
+
hbs.registerHelper("link", (value) => typeof value === "string" && SAFE_LINK_PROTOCOL.test(value.trim()) ? value : "#");
|
|
74
|
+
return hbs;
|
|
75
|
+
}
|
|
76
|
+
const KNOWN_HELPERS = {
|
|
77
|
+
each: true,
|
|
78
|
+
if: true,
|
|
79
|
+
money: true,
|
|
80
|
+
date: true,
|
|
81
|
+
link: true,
|
|
82
|
+
richText: true,
|
|
83
|
+
};
|
|
84
|
+
export function compileDocumentSource(hbs, source, options = {}) {
|
|
85
|
+
return hbs.compile(source, {
|
|
86
|
+
knownHelpers: KNOWN_HELPERS,
|
|
87
|
+
knownHelpersOnly: true,
|
|
88
|
+
strict: false,
|
|
89
|
+
...options,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
// A key only exists in the registry when its feature is mounted; reading an
|
|
93
|
+
// unregistered key through the accessor would throw instead of falling back.
|
|
94
|
+
async function readConfigText(ctx, key) {
|
|
95
|
+
if (ctx.config === undefined || ctx.registry.getConfigKey(key) === undefined)
|
|
96
|
+
return undefined;
|
|
97
|
+
const value = await ctx.config(key);
|
|
98
|
+
return typeof value === "string" && value !== "" ? value : undefined;
|
|
99
|
+
}
|
|
100
|
+
// Dates and numbers follow the template locale, the language the document is
|
|
101
|
+
// read in; the tenant's locale setting must not turn a German invoice into
|
|
102
|
+
// "Oct 1, 2026". The currency is a tenant fact and still comes from its settings.
|
|
103
|
+
export async function readDocumentFormat(ctx, templateLocale) {
|
|
104
|
+
const currency = await readConfigText(ctx, TENANT_CURRENCY_CONFIG_KEY);
|
|
105
|
+
return { locale: templateLocale, currency: currency ?? FALLBACK_CURRENCY };
|
|
106
|
+
}
|
|
107
|
+
export async function readTenantName(ctx) {
|
|
108
|
+
return (await readConfigText(ctx, BRANDING_QN.title)) ?? "";
|
|
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
|
+
}
|
|
114
|
+
const LOGO_RESOURCE_KEY = "logo";
|
|
115
|
+
// Lookup order: the template's own link, the system template it overrides, and for
|
|
116
|
+
// "logo" the tenant's branding logo. The system lookup is skipped for system templates.
|
|
117
|
+
export async function resolveResourceFileRefs(tplApi, ctx, template, keys) {
|
|
118
|
+
if (keys.length === 0)
|
|
119
|
+
return new Map();
|
|
120
|
+
let systemLinks = {};
|
|
121
|
+
if (template.scope === "tenant") {
|
|
122
|
+
try {
|
|
123
|
+
systemLinks = (await tplApi.resolveTemplate({
|
|
124
|
+
tenantId: SYSTEM_TENANT_ID,
|
|
125
|
+
slug: template.slug,
|
|
126
|
+
kind: template.kind,
|
|
127
|
+
locale: template.locale,
|
|
128
|
+
})).linkedResources;
|
|
129
|
+
}
|
|
130
|
+
catch (e) {
|
|
131
|
+
if (!(e instanceof TemplateNotFoundError))
|
|
132
|
+
throw e;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const brandingLogo = keys.includes(LOGO_RESOURCE_KEY) ? await readBrandingLogo(ctx) : undefined;
|
|
136
|
+
const fileRefIds = new Map();
|
|
137
|
+
for (const key of keys) {
|
|
138
|
+
const fileRefId = ownLink(template.linkedResources, key) ??
|
|
139
|
+
ownLink(systemLinks, key) ??
|
|
140
|
+
(key === LOGO_RESOURCE_KEY ? brandingLogo : undefined);
|
|
141
|
+
if (fileRefId !== undefined)
|
|
142
|
+
fileRefIds.set(key, fileRefId);
|
|
143
|
+
}
|
|
144
|
+
return fileRefIds;
|
|
145
|
+
}
|
|
146
|
+
// The token is a free field of the tenant admin and the send reader also reaches the system
|
|
147
|
+
// tenant's files, so only a file of the rendering tenant itself counts.
|
|
148
|
+
async function readBrandingLogo(ctx) {
|
|
149
|
+
const fileRefId = await readConfigText(ctx, BRANDING_QN.logoFile);
|
|
150
|
+
if (fileRefId === undefined || !isUuid(fileRefId))
|
|
151
|
+
return undefined;
|
|
152
|
+
const [row] = await selectMany(ctx.db, fileRefsTable, {
|
|
153
|
+
id: fileRefId,
|
|
154
|
+
tenantId: ctx.tenantId,
|
|
155
|
+
isDeleted: false,
|
|
156
|
+
});
|
|
157
|
+
return row === undefined ? undefined : fileRefId;
|
|
158
|
+
}
|
|
159
|
+
function ownLink(links, key) {
|
|
160
|
+
return Object.hasOwn(links, key) && links[key] !== "" ? links[key] : undefined;
|
|
161
|
+
}
|
|
162
|
+
export function collectRegionResourceKeys(regions) {
|
|
163
|
+
return collectDocumentResourceKeys([regions.header, regions.signature, regions.footer].join(""));
|
|
164
|
+
}
|
|
165
|
+
// A cid or data URI per key can point to one file only, so a key the body already
|
|
166
|
+
// resolved keeps the body's file even when the layout links another one.
|
|
167
|
+
export function mergeResourceFileRefs(bodyRefs, layoutRefs) {
|
|
168
|
+
return new Map([...layoutRefs, ...bodyRefs]);
|
|
169
|
+
}
|
package/dist/feature.js
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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,11 @@
|
|
|
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[];
|
|
11
|
+
export declare function mergeInheritedAttachments<T>(own: readonly T[], inherited: readonly T[], fileNameOf: (attachment: T) => string): readonly T[];
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { MAX_EMAIL_ATTACHMENTS, MAX_RENDERED_PDF_VARIABLES_BYTES, sanitizePdfAttachmentFileName, withoutDeliveryEnvelopeKeys, } 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, locale, renderFileName } = args;
|
|
5
|
+
if (links.length === 0)
|
|
6
|
+
return [];
|
|
7
|
+
const variables = withoutDeliveryEnvelopeKeys(args.variables);
|
|
8
|
+
if (Buffer.byteLength(JSON.stringify(variables)) > MAX_RENDERED_PDF_VARIABLES_BYTES) {
|
|
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");
|
|
10
|
+
}
|
|
11
|
+
return links.map((link) => ({
|
|
12
|
+
kind: "rendered-pdf",
|
|
13
|
+
templateSlug: link.templateSlug,
|
|
14
|
+
variables,
|
|
15
|
+
locale,
|
|
16
|
+
fileName: sanitizePdfAttachmentFileName(renderFileName(link.fileName), link.templateSlug),
|
|
17
|
+
}));
|
|
18
|
+
}
|
|
19
|
+
// Inline images come first so their order in the mail matches the body; the delivery layer
|
|
20
|
+
// rejects more than MAX_EMAIL_ATTACHMENTS, so the render fails here with the slug instead.
|
|
21
|
+
export function combineMailAttachments(slug, inline, files, renderedPdfs) {
|
|
22
|
+
const attachments = [...inline, ...files, ...renderedPdfs];
|
|
23
|
+
if (attachments.length > MAX_EMAIL_ATTACHMENTS) {
|
|
24
|
+
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");
|
|
25
|
+
}
|
|
26
|
+
return attachments;
|
|
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.d.ts
ADDED
package/dist/render.js
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
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, mailLayoutAppearance, 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, 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
|
+
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 = true;
|
|
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, buttonInlineStyle) {
|
|
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
|
+
resourceImageMaxWidthPx: MAIL_IMAGE_MAX_WIDTH_PX,
|
|
169
|
+
alignmentAsInlineStyle: true,
|
|
170
|
+
buttonAsInlineStyle: true,
|
|
171
|
+
tableAsInlineStyle: true,
|
|
172
|
+
...(buttonInlineStyle !== undefined && { buttonInlineStyle }),
|
|
173
|
+
});
|
|
174
|
+
return {
|
|
175
|
+
contentHtml: compileDocumentSource(hbs, handlebarsSource)(context),
|
|
176
|
+
resourceFileRefs,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
catch (e) {
|
|
180
|
+
throw new RendererError(`renderer-mail-html: rendering the body of "${template.slug}" failed — ${describe(e)}`, "other");
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
function renderSubjectLine(hbs, source, context) {
|
|
184
|
+
return compileDocumentSource(hbs, source, { noEscape: true })(context).replace(LINE_BREAKS, "");
|
|
185
|
+
}
|
|
186
|
+
function renderFileNamePattern(hbs, pattern, slug, context) {
|
|
187
|
+
try {
|
|
188
|
+
return renderSubjectLine(hbs, pattern, context);
|
|
189
|
+
}
|
|
190
|
+
catch (e) {
|
|
191
|
+
throw new RendererError(`renderer-mail-html: rendering the attachment file name "${pattern}" of "${slug}" failed — ${describe(e)}`, "other");
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
function renderSubjectLines(hbs, meta, slug, context) {
|
|
195
|
+
try {
|
|
196
|
+
return {
|
|
197
|
+
subject: renderSubjectLine(hbs, meta.subject, context),
|
|
198
|
+
previewText: meta.previewText === undefined
|
|
199
|
+
? undefined
|
|
200
|
+
: renderSubjectLine(hbs, meta.previewText, context),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
catch (e) {
|
|
204
|
+
throw new RendererError(`renderer-mail-html: rendering subject or preview text of "${slug}" failed — ${describe(e)}`, "other");
|
|
205
|
+
}
|
|
206
|
+
}
|
|
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);
|
|
233
|
+
const inlineAttachments = [...resourceFileRefs].map(([cid, fileRefId]) => ({ kind: "inline", cid, fileRefId }));
|
|
234
|
+
const renderedPdfs = buildRenderedPdfAttachments({
|
|
235
|
+
slug,
|
|
236
|
+
links: mergeInheritedAttachments(template.pdfAttachmentLinks ?? [], inherited.pdfAttachmentLinks ?? [], (link) => link.fileName),
|
|
237
|
+
variables,
|
|
238
|
+
locale,
|
|
239
|
+
renderFileName,
|
|
240
|
+
});
|
|
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
|
+
};
|
|
305
|
+
}
|
|
306
|
+
export async function mailHtmlRender(req, ctx) {
|
|
307
|
+
if (req.kind !== "mail") {
|
|
308
|
+
// Foundation selects plugins only for matching kinds; defensive guard.
|
|
309
|
+
throw new RendererError(`renderer-mail-html supports only kind="mail", got "${req.kind}"`, "invalid_payload");
|
|
310
|
+
}
|
|
311
|
+
const { slug, locale, variables, unsubscribeUrl, draft } = req.payload;
|
|
312
|
+
const tplApi = createTemplateResolverApi(ctx.db);
|
|
313
|
+
const { template, meta } = await resolveMailTemplate(tplApi, ctx, slug, locale, draft);
|
|
314
|
+
const layout = await resolveLayout(tplApi, ctx, meta, locale);
|
|
315
|
+
const [format, tenantName] = await Promise.all([
|
|
316
|
+
readDocumentFormat(ctx, locale),
|
|
317
|
+
readTenantName(ctx),
|
|
318
|
+
]);
|
|
319
|
+
const hbs = createDocumentHandlebars(format);
|
|
320
|
+
const context = {
|
|
321
|
+
...variables,
|
|
322
|
+
tenantName,
|
|
323
|
+
signature: "",
|
|
324
|
+
imprint: "",
|
|
325
|
+
unsubscribeUrl: unsubscribeUrl ?? "",
|
|
326
|
+
};
|
|
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);
|
|
330
|
+
const { contentHtml } = body;
|
|
331
|
+
const layoutFileRefs = await resolveLayoutFileRefs(tplApi, ctx, layout);
|
|
332
|
+
const resourceFileRefs = mergeResourceFileRefs(body.resourceFileRefs, layoutFileRefs);
|
|
333
|
+
const attachments = await assembleMailAttachments({
|
|
334
|
+
tplApi,
|
|
335
|
+
slug,
|
|
336
|
+
template,
|
|
337
|
+
resourceFileRefs,
|
|
338
|
+
variables,
|
|
339
|
+
locale,
|
|
340
|
+
renderFileName: (pattern) => renderFileNamePattern(hbs, pattern, slug, context),
|
|
341
|
+
});
|
|
342
|
+
const { subject, previewText } = renderSubjectLines(hbs, meta, slug, context);
|
|
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: {
|
|
352
|
+
subject,
|
|
353
|
+
previewText: previewText ?? "",
|
|
354
|
+
tenantName,
|
|
355
|
+
signature: "",
|
|
356
|
+
imprint: "",
|
|
357
|
+
unsubscribeUrl: unsubscribeUrl ?? "",
|
|
358
|
+
},
|
|
359
|
+
});
|
|
360
|
+
const html = await compileMjmlToHtml(mjmlSource);
|
|
361
|
+
return buildMailRenderResponse({
|
|
362
|
+
subject,
|
|
363
|
+
previewText,
|
|
364
|
+
html,
|
|
365
|
+
text: htmlToPlainText(html),
|
|
366
|
+
meta,
|
|
367
|
+
attachments,
|
|
368
|
+
template,
|
|
369
|
+
layout,
|
|
370
|
+
isDraft: draft !== undefined,
|
|
371
|
+
});
|
|
372
|
+
}
|
|
@@ -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>;
|
package/dist/seeding.js
ADDED
|
@@ -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,17 @@
|
|
|
1
|
+
import { type MailLayoutAppearance } from "@cosmicdrift/kumiko-bundled-features/template-resolver";
|
|
2
|
+
export declare const MAIL_IMAGE_MAX_WIDTH_PX = 180;
|
|
3
|
+
export type StructuredLayoutInput = {
|
|
4
|
+
readonly layoutHtml: string;
|
|
5
|
+
readonly contentHtml: string;
|
|
6
|
+
/** Layout context (tenantName, subject, previewText, signature, imprint, unsubscribeUrl). */
|
|
7
|
+
readonly context: Readonly<Record<string, unknown>>;
|
|
8
|
+
/** Compiles Handlebars source with the renderer's isolated instance and helper set. */
|
|
9
|
+
readonly compile: (source: string) => (context: Readonly<Record<string, unknown>>) => string;
|
|
10
|
+
/** src for `img[data-resource]` in the regions; without it layout images are dropped. */
|
|
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;
|
|
16
|
+
};
|
|
17
|
+
export declare function renderStructuredLayoutMjml(input: StructuredLayoutInput): string;
|
|
@@ -0,0 +1,73 @@
|
|
|
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, 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>
|
|
18
|
+
<mj-head>
|
|
19
|
+
<mj-title>{{subject}}</mj-title>
|
|
20
|
+
<mj-preview>{{previewText}}</mj-preview>
|
|
21
|
+
<mj-attributes>
|
|
22
|
+
<mj-all font-family="${escapeHtmlAttr(appearance.fontFamily)}" />
|
|
23
|
+
<mj-text font-size="14px" color="#333" line-height="1.6" />
|
|
24
|
+
</mj-attributes>
|
|
25
|
+
</mj-head>
|
|
26
|
+
<mj-body width="${appearance.width}px" background-color="${escapeHtmlAttr(appearance.outerBackground)}">
|
|
27
|
+
${accentLine}
|
|
28
|
+
{{#if regionHeader}}
|
|
29
|
+
<mj-section background-color="#ffffff" padding="20px">
|
|
30
|
+
<mj-column>
|
|
31
|
+
<mj-text>{{{regionHeader}}}</mj-text>
|
|
32
|
+
</mj-column>
|
|
33
|
+
</mj-section>
|
|
34
|
+
{{/if}}
|
|
35
|
+
<mj-section background-color="#ffffff" padding="20px">
|
|
36
|
+
<mj-column>
|
|
37
|
+
<mj-text>{{{content}}}</mj-text>
|
|
38
|
+
</mj-column>
|
|
39
|
+
</mj-section>
|
|
40
|
+
{{#if regionSignature}}
|
|
41
|
+
<mj-section background-color="#ffffff" padding="20px">
|
|
42
|
+
<mj-column>
|
|
43
|
+
<mj-text>{{{regionSignature}}}</mj-text>
|
|
44
|
+
</mj-column>
|
|
45
|
+
</mj-section>
|
|
46
|
+
{{/if}}
|
|
47
|
+
{{#if regionFooter}}
|
|
48
|
+
<mj-section background-color="${escapeHtmlAttr(appearance.outerBackground)}" padding="20px">
|
|
49
|
+
<mj-column>
|
|
50
|
+
<mj-text font-size="11px" color="#888" align="center">{{{regionFooter}}}</mj-text>
|
|
51
|
+
</mj-column>
|
|
52
|
+
</mj-section>
|
|
53
|
+
{{/if}}
|
|
54
|
+
</mj-body>
|
|
55
|
+
</mjml>`;
|
|
56
|
+
}
|
|
57
|
+
export function renderStructuredLayoutMjml(input) {
|
|
58
|
+
const regions = readLayoutRegions(input.layoutHtml);
|
|
59
|
+
const renderRegion = (region) => region.trim() === ""
|
|
60
|
+
? ""
|
|
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)({
|
|
67
|
+
...input.context,
|
|
68
|
+
content: input.contentHtml,
|
|
69
|
+
regionHeader: renderRegion(regions.header),
|
|
70
|
+
regionSignature: renderRegion(regions.signature),
|
|
71
|
+
regionFooter: renderRegion(regions.footer),
|
|
72
|
+
});
|
|
73
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,64 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-renderer-mail-html",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.364.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.364.0",
|
|
23
|
+
"@cosmicdrift/kumiko-framework": "0.364.0",
|
|
24
|
+
"@cosmicdrift/kumiko-headless": "0.364.0",
|
|
25
|
+
"@cosmicdrift/kumiko-types": "0.364.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.364.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
|
+
}
|
package/src/changes.json
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
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
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.363.0",
|
|
16
|
+
"type": "breaking",
|
|
17
|
+
"title": "Documents format dates and numbers by the template locale, dates in the long style",
|
|
18
|
+
"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\").",
|
|
19
|
+
"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."
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"version": "0.363.0",
|
|
23
|
+
"type": "improvement",
|
|
24
|
+
"title": "Images in layout regions, exported document-kit helpers",
|
|
25
|
+
"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."
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"version": "0.363.0",
|
|
29
|
+
"type": "improvement",
|
|
30
|
+
"title": "Text alignment of paragraphs and headings is sent as an inline style",
|
|
31
|
+
"detail": "A paragraph or heading with data-align is rendered with an inline text-align, because mail clients ignore document CSS."
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"version": "0.363.0",
|
|
35
|
+
"type": "breaking",
|
|
36
|
+
"title": "renders kind mail from document templates, Markdown path removed",
|
|
37
|
+
"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.",
|
|
38
|
+
"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}}}."
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"version": "0.363.0",
|
|
42
|
+
"type": "breaking",
|
|
43
|
+
"title": "Package moved to the framework under the @cosmicdrift scope",
|
|
44
|
+
"detail": "Renamed from @cosmicdriftgamestudio/kumiko-renderer-mail-html (GitHub Packages, kumiko-enterprise) to @cosmicdrift/kumiko-renderer-mail-html on npmjs; the API is unchanged.",
|
|
45
|
+
"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."
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"version": "0.363.0",
|
|
49
|
+
"type": "fix",
|
|
50
|
+
"title": "The default mail layout shows the mail body",
|
|
51
|
+
"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."
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"version": "0.363.0",
|
|
55
|
+
"type": "improvement",
|
|
56
|
+
"title": "Mail renderer attaches linked PDF templates as rendered-pdf attachments",
|
|
57
|
+
"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."
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"version": "0.363.0",
|
|
61
|
+
"type": "improvement",
|
|
62
|
+
"title": "Renders a MailPayload.draft instead of the stored template body and subject",
|
|
63
|
+
"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."
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"version": "0.363.0",
|
|
67
|
+
"type": "fix",
|
|
68
|
+
"title": "Mails use a tenant layout stored under another locale",
|
|
69
|
+
"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."
|
|
70
|
+
}
|
|
71
|
+
]
|