@cosmicdrift/kumiko-bundled-features 0.349.0 → 0.350.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/channel-email/email-channel.js +13 -5
- package/dist/channel-email/pii-guard.js +6 -1
- package/dist/channel-email/smtp-transport.js +1 -0
- package/dist/channel-email/types.d.ts +1 -0
- package/dist/delivery/jobs.js +3 -1
- package/dist/delivery/types.d.ts +3 -0
- package/dist/renderer-simple/simple-renderer.js +49 -13
- package/dist/user-data-rights/email-templates.d.ts +1 -0
- package/dist/user-data-rights/email-templates.js +26 -4
- package/dist/user-data-rights/lib/default-mailers.js +8 -8
- package/package.json +9 -9
- package/src/channel-email/changes.json +6 -0
|
@@ -78,13 +78,15 @@ export function createEmailChannel(options) {
|
|
|
78
78
|
title: message.title,
|
|
79
79
|
body: message.body,
|
|
80
80
|
};
|
|
81
|
-
const
|
|
81
|
+
const rendererInput = {
|
|
82
82
|
template: message.notificationType,
|
|
83
83
|
variables,
|
|
84
84
|
locale: message.locale,
|
|
85
|
-
}
|
|
85
|
+
};
|
|
86
|
+
const html = await renderer.render(rendererInput);
|
|
87
|
+
const text = renderer.renderText ? await renderer.renderText(rendererInput) : undefined;
|
|
86
88
|
const subject = variables["subject"] ?? message.title; // @cast-boundary dynamic-key
|
|
87
|
-
return { html, subject };
|
|
89
|
+
return { html, subject, ...(text !== undefined && { text }) };
|
|
88
90
|
}
|
|
89
91
|
return {
|
|
90
92
|
name: "email",
|
|
@@ -101,9 +103,15 @@ export function createEmailChannel(options) {
|
|
|
101
103
|
return renderMessage(message);
|
|
102
104
|
},
|
|
103
105
|
async send(address, message, _ctx, rendered) {
|
|
104
|
-
const { html, subject } = rendered ?? (await renderMessage(message));
|
|
106
|
+
const { html, subject, text } = rendered ?? (await renderMessage(message));
|
|
105
107
|
const envelope = emailEnvelopeFrom(message.data);
|
|
106
|
-
await transport.send(guardEmailMessage({
|
|
108
|
+
await transport.send(guardEmailMessage({
|
|
109
|
+
to: address,
|
|
110
|
+
subject,
|
|
111
|
+
html,
|
|
112
|
+
...(text !== undefined && { text }),
|
|
113
|
+
...envelope,
|
|
114
|
+
}));
|
|
107
115
|
return { status: "sent", address };
|
|
108
116
|
},
|
|
109
117
|
};
|
|
@@ -28,7 +28,9 @@ export function guardEmailMessage(message) {
|
|
|
28
28
|
`("${PII_CIPHERTEXT_PREFIX}…") — decrypt the stored value before mailing (decryptStoredPii).`);
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
|
-
const leaking = message.subject.includes(CIPHERTEXT_MARKER) ||
|
|
31
|
+
const leaking = message.subject.includes(CIPHERTEXT_MARKER) ||
|
|
32
|
+
message.html.includes(CIPHERTEXT_MARKER) ||
|
|
33
|
+
message.text?.includes(CIPHERTEXT_MARKER) === true;
|
|
32
34
|
if (!leaking)
|
|
33
35
|
return message;
|
|
34
36
|
const detail = "[channel-email] mail subject/body contains a PII ciphertext " +
|
|
@@ -41,6 +43,9 @@ export function guardEmailMessage(message) {
|
|
|
41
43
|
...message,
|
|
42
44
|
subject: message.subject.replace(CIPHERTEXT_RE, "[pii-redacted]"),
|
|
43
45
|
html: message.html.replace(CIPHERTEXT_RE, "[pii-redacted]"),
|
|
46
|
+
...(message.text !== undefined && {
|
|
47
|
+
text: message.text.replace(CIPHERTEXT_RE, "[pii-redacted]"),
|
|
48
|
+
}),
|
|
44
49
|
};
|
|
45
50
|
}
|
|
46
51
|
export function withPiiCiphertextGuard(transport) {
|
|
@@ -54,6 +54,7 @@ export function createSmtpTransport(options) {
|
|
|
54
54
|
to: message.to,
|
|
55
55
|
subject: message.subject,
|
|
56
56
|
html: message.html,
|
|
57
|
+
...(message.text !== undefined && { text: message.text }),
|
|
57
58
|
...(message.replyTo && { replyTo: message.replyTo }),
|
|
58
59
|
...(message.headers && { headers: message.headers }),
|
|
59
60
|
});
|
package/dist/delivery/jobs.js
CHANGED
|
@@ -28,7 +28,9 @@ const renderJobPayloadSchema = z.object({
|
|
|
28
28
|
message: channelMessageSchema,
|
|
29
29
|
});
|
|
30
30
|
const sendJobPayloadSchema = renderJobPayloadSchema.extend({
|
|
31
|
-
rendered: z
|
|
31
|
+
rendered: z
|
|
32
|
+
.object({ html: z.string(), subject: z.string(), text: z.string().optional() })
|
|
33
|
+
.optional(),
|
|
32
34
|
});
|
|
33
35
|
function requireTenantScopedDeps(ctx, tenantId) {
|
|
34
36
|
const registry = ctx.registry;
|
package/dist/delivery/types.d.ts
CHANGED
|
@@ -26,6 +26,7 @@ export type ChannelResult = {
|
|
|
26
26
|
export type RenderedMessage = {
|
|
27
27
|
readonly html: string;
|
|
28
28
|
readonly subject: string;
|
|
29
|
+
readonly text?: string;
|
|
29
30
|
};
|
|
30
31
|
export declare const DELIVERY_CHANNEL_MODES: readonly ["inline", "queued"];
|
|
31
32
|
export type DeliveryChannelMode = (typeof DELIVERY_CHANNEL_MODES)[number];
|
|
@@ -44,6 +45,8 @@ export type RendererInput = {
|
|
|
44
45
|
export type NotificationRenderer = {
|
|
45
46
|
readonly name: string;
|
|
46
47
|
render(input: RendererInput): Promise<string>;
|
|
48
|
+
/** Plain-text version of the same mail; the email channel sends it as the text part of a multipart/alternative mail. */
|
|
49
|
+
renderText?(input: RendererInput): Promise<string>;
|
|
47
50
|
};
|
|
48
51
|
export type DeliveryLogEntry = {
|
|
49
52
|
readonly tenantId: TenantId;
|
|
@@ -73,25 +73,57 @@ function renderBrandingHeader(branding, primaryColor, logoUrl) {
|
|
|
73
73
|
: `<span style="font-size:18px;font-weight:700;color:${escapeHtmlAttr(primaryColor)}">${escapeHtml(branding.productName ?? "")}</span>`;
|
|
74
74
|
return `<div style="margin:0 0 24px;padding:0 0 16px;border-bottom:3px solid ${escapeHtmlAttr(primaryColor)}">${logo}</div>`;
|
|
75
75
|
}
|
|
76
|
-
function
|
|
77
|
-
if (!branding)
|
|
78
|
-
return "";
|
|
76
|
+
function resolveBrandingFooter(branding, locale) {
|
|
79
77
|
const pick = (value) => resolveLocalized(value, locale, branding.defaultLocale);
|
|
80
78
|
const links = (branding.footerLinks ?? []).flatMap((link) => {
|
|
81
79
|
const url = pick(link.url);
|
|
82
80
|
if (url === undefined || !isHttpUrl(url))
|
|
83
81
|
return [];
|
|
84
|
-
return [
|
|
85
|
-
`<a href="${escapeHtmlAttr(url)}" style="color:#999">${escapeHtml(pick(link.label) ?? "")}</a>`,
|
|
86
|
-
];
|
|
82
|
+
return [{ label: pick(link.label) ?? "", url }];
|
|
87
83
|
});
|
|
88
|
-
|
|
84
|
+
return { footerText: pick(branding.footerText), links };
|
|
85
|
+
}
|
|
86
|
+
function renderBrandingFooter(branding, locale) {
|
|
87
|
+
if (!branding)
|
|
88
|
+
return "";
|
|
89
|
+
const { footerText, links: resolvedLinks } = resolveBrandingFooter(branding, locale);
|
|
90
|
+
const links = resolvedLinks.map((link) => `<a href="${escapeHtmlAttr(link.url)}" style="color:#999">${escapeHtml(link.label)}</a>`);
|
|
89
91
|
const footerPartsHtml = [footerText ? escapeHtml(footerText) : "", links.join(" · ")].filter((part) => part !== "");
|
|
90
92
|
if (footerPartsHtml.length === 0)
|
|
91
93
|
return "";
|
|
92
94
|
const footerHtml = footerPartsHtml.join("<br />");
|
|
93
95
|
return `<p style="margin:16px 0 0;color:#999;font-size:12px">${footerHtml}</p>`;
|
|
94
96
|
}
|
|
97
|
+
function brandingFooterLines(branding, locale) {
|
|
98
|
+
if (!branding)
|
|
99
|
+
return [];
|
|
100
|
+
const { footerText, links } = resolveBrandingFooter(branding, locale);
|
|
101
|
+
return [
|
|
102
|
+
...(footerText ? [footerText] : []),
|
|
103
|
+
...links.map((link) => (link.label ? `${link.label}: ${link.url}` : link.url)),
|
|
104
|
+
];
|
|
105
|
+
}
|
|
106
|
+
function templateContent(variables) {
|
|
107
|
+
const data = variables; // @cast-boundary render-helper
|
|
108
|
+
// Without structured fields, title + body become the header and a single text section.
|
|
109
|
+
return {
|
|
110
|
+
header: data.header ?? data.title,
|
|
111
|
+
sections: data.sections ?? (data.body ? [{ text: data.body }] : undefined),
|
|
112
|
+
footer: data.footer,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
// Markdown stays as written: its source is already readable plain text.
|
|
116
|
+
function sectionText(section) {
|
|
117
|
+
if ("text" in section)
|
|
118
|
+
return section.text;
|
|
119
|
+
if ("heading" in section)
|
|
120
|
+
return section.heading;
|
|
121
|
+
if ("markdown" in section)
|
|
122
|
+
return section.markdown;
|
|
123
|
+
if ("button" in section)
|
|
124
|
+
return `${section.button.label}: ${section.button.url}`;
|
|
125
|
+
return "";
|
|
126
|
+
}
|
|
95
127
|
function renderSection(section, primaryColor) {
|
|
96
128
|
if ("text" in section) {
|
|
97
129
|
return `<p style="margin:0 0 16px;color:#333;font-size:14px;line-height:1.5">${escapeHtml(section.text)}</p>`;
|
|
@@ -118,10 +150,7 @@ export function createSimpleRenderer(branding) {
|
|
|
118
150
|
return {
|
|
119
151
|
name: "simple",
|
|
120
152
|
async render(input) {
|
|
121
|
-
const
|
|
122
|
-
// Fallback: if no structured fields, use title + body as header + single text section
|
|
123
|
-
const header = data.header ?? data.title;
|
|
124
|
-
const sections = data.sections ?? (data.body ? [{ text: data.body }] : undefined);
|
|
153
|
+
const { header, sections, footer } = templateContent(input.variables);
|
|
125
154
|
const parts = [];
|
|
126
155
|
parts.push('<!DOCTYPE html><html><body style="margin:0;padding:0;font-family:sans-serif">');
|
|
127
156
|
parts.push('<div style="max-width:600px;margin:0 auto;padding:24px">');
|
|
@@ -134,13 +163,20 @@ export function createSimpleRenderer(branding) {
|
|
|
134
163
|
parts.push(renderSection(section, primaryColor));
|
|
135
164
|
}
|
|
136
165
|
}
|
|
137
|
-
if (
|
|
138
|
-
parts.push(`<p style="margin:24px 0 0;color:#999;font-size:12px;border-top:1px solid #eee;padding-top:16px">${escapeHtml(
|
|
166
|
+
if (footer) {
|
|
167
|
+
parts.push(`<p style="margin:24px 0 0;color:#999;font-size:12px;border-top:1px solid #eee;padding-top:16px">${escapeHtml(footer)}</p>`);
|
|
139
168
|
}
|
|
140
169
|
parts.push(renderBrandingFooter(branding, input.locale));
|
|
141
170
|
parts.push("</div></body></html>");
|
|
142
171
|
return parts.join("");
|
|
143
172
|
},
|
|
173
|
+
async renderText(input) {
|
|
174
|
+
const { header, sections, footer } = templateContent(input.variables);
|
|
175
|
+
const brandingFooter = brandingFooterLines(branding, input.locale).join("\n");
|
|
176
|
+
return [header, ...(sections ?? []).map(sectionText), footer, brandingFooter]
|
|
177
|
+
.filter((block) => block !== undefined && block !== "")
|
|
178
|
+
.join("\n\n");
|
|
179
|
+
},
|
|
144
180
|
};
|
|
145
181
|
}
|
|
146
182
|
export const simpleRenderer = createSimpleRenderer();
|
|
@@ -45,7 +45,13 @@ export function renderExportReadyEmail(args) {
|
|
|
45
45
|
<p style="margin: 0 0 24px;">${renderButton({ url: args.downloadUrl, label: t(locale, "gdpr.mail.exportReady.button") })}</p>
|
|
46
46
|
<p style="margin: 0 0 8px; font-size: 13px; color: #555;">${escapeHtml(t(locale, "gdpr.mail.exportReady.expiry", { when: formatTimestamp(args.expiresAt) }))}</p>
|
|
47
47
|
${renderFallbackUrl({ url: args.downloadUrl, label: t(locale, "gdpr.mail.fallbackUrl") })}`;
|
|
48
|
-
|
|
48
|
+
const text = plainTextBody([
|
|
49
|
+
t(locale, "gdpr.mail.greeting"),
|
|
50
|
+
t(locale, "gdpr.mail.exportReady.intro", { app }),
|
|
51
|
+
`${t(locale, "gdpr.mail.exportReady.button")}: ${args.downloadUrl}`,
|
|
52
|
+
t(locale, "gdpr.mail.exportReady.expiry", { when: formatTimestamp(args.expiresAt) }),
|
|
53
|
+
]);
|
|
54
|
+
return { subject, html: renderShell({ title: subject, bodyHtml: wrapCell(body), locale }), text };
|
|
49
55
|
}
|
|
50
56
|
export function renderExportFailedEmail(args) {
|
|
51
57
|
const locale = args.locale ?? "en";
|
|
@@ -54,7 +60,11 @@ export function renderExportFailedEmail(args) {
|
|
|
54
60
|
const body = `
|
|
55
61
|
<p style="margin: 0 0 16px; font-size: 16px;">${escapeHtml(t(locale, "gdpr.mail.greeting"))}</p>
|
|
56
62
|
<p style="margin: 0; font-size: 14px; line-height: 1.5;">${escapeHtml(t(locale, "gdpr.mail.exportFailed.intro", { app }))}</p>`;
|
|
57
|
-
|
|
63
|
+
const text = plainTextBody([
|
|
64
|
+
t(locale, "gdpr.mail.greeting"),
|
|
65
|
+
t(locale, "gdpr.mail.exportFailed.intro", { app }),
|
|
66
|
+
]);
|
|
67
|
+
return { subject, html: renderShell({ title: subject, bodyHtml: wrapCell(body), locale }), text };
|
|
58
68
|
}
|
|
59
69
|
export function renderDeletionRequestedEmail(args) {
|
|
60
70
|
const locale = args.locale ?? "en";
|
|
@@ -65,7 +75,12 @@ export function renderDeletionRequestedEmail(args) {
|
|
|
65
75
|
<p style="margin: 0 0 16px; font-size: 16px;">${escapeHtml(t(locale, "gdpr.mail.greeting"))}</p>
|
|
66
76
|
<p style="margin: 0 0 16px; font-size: 14px; line-height: 1.5;">${escapeHtml(t(locale, "gdpr.mail.deletionRequested.intro", { app, when }))}</p>
|
|
67
77
|
<p style="margin: 0; font-size: 13px; color: #555;">${escapeHtml(t(locale, "gdpr.mail.deletionRequested.cancel"))}</p>`;
|
|
68
|
-
|
|
78
|
+
const text = plainTextBody([
|
|
79
|
+
t(locale, "gdpr.mail.greeting"),
|
|
80
|
+
t(locale, "gdpr.mail.deletionRequested.intro", { app, when }),
|
|
81
|
+
t(locale, "gdpr.mail.deletionRequested.cancel"),
|
|
82
|
+
]);
|
|
83
|
+
return { subject, html: renderShell({ title: subject, bodyHtml: wrapCell(body), locale }), text };
|
|
69
84
|
}
|
|
70
85
|
export function renderDeletionExecutedEmail(args) {
|
|
71
86
|
const locale = args.locale ?? "en";
|
|
@@ -75,7 +90,14 @@ export function renderDeletionExecutedEmail(args) {
|
|
|
75
90
|
const body = `
|
|
76
91
|
<p style="margin: 0 0 16px; font-size: 16px;">${escapeHtml(t(locale, "gdpr.mail.greeting"))}</p>
|
|
77
92
|
<p style="margin: 0; font-size: 14px; line-height: 1.5;">${escapeHtml(t(locale, "gdpr.mail.deletionExecuted.intro", { app, when }))}</p>`;
|
|
78
|
-
|
|
93
|
+
const text = plainTextBody([
|
|
94
|
+
t(locale, "gdpr.mail.greeting"),
|
|
95
|
+
t(locale, "gdpr.mail.deletionExecuted.intro", { app, when }),
|
|
96
|
+
]);
|
|
97
|
+
return { subject, html: renderShell({ title: subject, bodyHtml: wrapCell(body), locale }), text };
|
|
98
|
+
}
|
|
99
|
+
function plainTextBody(paragraphs) {
|
|
100
|
+
return paragraphs.join("\n\n");
|
|
79
101
|
}
|
|
80
102
|
function wrapCell(bodyHtml) {
|
|
81
103
|
return `<tr><td>${bodyHtml}</td></tr>`;
|
|
@@ -29,34 +29,34 @@ function localeFor(userLocale, defaults) {
|
|
|
29
29
|
export function makeDefaultExportReadyEmail(resolveTransport, defaults = {}) {
|
|
30
30
|
return async (args) => {
|
|
31
31
|
const transport = await resolveTransport(args.tenantId);
|
|
32
|
-
const { subject, html } = renderExportReadyEmail({
|
|
32
|
+
const { subject, html, text } = renderExportReadyEmail({
|
|
33
33
|
downloadUrl: args.downloadUrl,
|
|
34
34
|
expiresAt: args.expiresAt,
|
|
35
35
|
locale: localeFor(args.userLocale, defaults),
|
|
36
36
|
appName: defaults.appName,
|
|
37
37
|
});
|
|
38
|
-
await transport.send({ to: args.userEmail, subject, html });
|
|
38
|
+
await transport.send({ to: args.userEmail, subject, html, text });
|
|
39
39
|
};
|
|
40
40
|
}
|
|
41
41
|
export function makeDefaultExportFailedEmail(resolveTransport, defaults = {}) {
|
|
42
42
|
return async (args) => {
|
|
43
43
|
const transport = await resolveTransport(args.tenantId);
|
|
44
|
-
const { subject, html } = renderExportFailedEmail({
|
|
44
|
+
const { subject, html, text } = renderExportFailedEmail({
|
|
45
45
|
locale: localeFor(args.userLocale, defaults),
|
|
46
46
|
appName: defaults.appName,
|
|
47
47
|
});
|
|
48
|
-
await transport.send({ to: args.userEmail, subject, html });
|
|
48
|
+
await transport.send({ to: args.userEmail, subject, html, text });
|
|
49
49
|
};
|
|
50
50
|
}
|
|
51
51
|
export function makeDefaultDeletionRequestedEmail(resolveTransport, defaults = {}) {
|
|
52
52
|
return async (args) => {
|
|
53
53
|
const transport = await resolveTransport(args.tenantId);
|
|
54
|
-
const { subject, html } = renderDeletionRequestedEmail({
|
|
54
|
+
const { subject, html, text } = renderDeletionRequestedEmail({
|
|
55
55
|
gracePeriodEnd: args.gracePeriodEnd,
|
|
56
56
|
locale: localeFor(args.userLocale, defaults),
|
|
57
57
|
appName: defaults.appName,
|
|
58
58
|
});
|
|
59
|
-
await transport.send({ to: args.userEmail, subject, html });
|
|
59
|
+
await transport.send({ to: args.userEmail, subject, html, text });
|
|
60
60
|
};
|
|
61
61
|
}
|
|
62
62
|
export function makeDefaultDeletionExecutedEmail(resolveTransport, defaults = {}) {
|
|
@@ -70,11 +70,11 @@ export function makeDefaultDeletionExecutedEmail(resolveTransport, defaults = {}
|
|
|
70
70
|
return;
|
|
71
71
|
}
|
|
72
72
|
const transport = await resolveTransport(tenantId);
|
|
73
|
-
const { subject, html } = renderDeletionExecutedEmail({
|
|
73
|
+
const { subject, html, text } = renderDeletionExecutedEmail({
|
|
74
74
|
executedAt: args.executedAt,
|
|
75
75
|
locale: localeFor(args.userLocale, defaults),
|
|
76
76
|
appName: defaults.appName,
|
|
77
77
|
});
|
|
78
|
-
await transport.send({ to: args.userEmail, subject, html });
|
|
78
|
+
await transport.send({ to: args.userEmail, subject, html, text });
|
|
79
79
|
};
|
|
80
80
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.350.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -503,12 +503,12 @@
|
|
|
503
503
|
}
|
|
504
504
|
},
|
|
505
505
|
"dependencies": {
|
|
506
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
507
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
508
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
509
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
510
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
511
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
506
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.350.0",
|
|
507
|
+
"@cosmicdrift/kumiko-framework": "0.350.0",
|
|
508
|
+
"@cosmicdrift/kumiko-headless": "0.350.0",
|
|
509
|
+
"@cosmicdrift/kumiko-renderer": "0.350.0",
|
|
510
|
+
"@cosmicdrift/kumiko-renderer-web": "0.350.0",
|
|
511
|
+
"@cosmicdrift/kumiko-types": "0.350.0",
|
|
512
512
|
"@mollie/api-client": "^4.5.0",
|
|
513
513
|
"@node-rs/argon2": "^2.0.2",
|
|
514
514
|
"@types/mailparser": "^3.4.6",
|
|
@@ -1025,8 +1025,8 @@
|
|
|
1025
1025
|
],
|
|
1026
1026
|
"devDependencies": {
|
|
1027
1027
|
"@testing-library/user-event": "^14.6.1",
|
|
1028
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
1029
|
-
"@cosmicdrift/kumiko-locale-es": "0.
|
|
1028
|
+
"@cosmicdrift/kumiko-locale-de": "0.350.0",
|
|
1029
|
+
"@cosmicdrift/kumiko-locale-es": "0.350.0",
|
|
1030
1030
|
"jsqr": "^1.4.0"
|
|
1031
1031
|
}
|
|
1032
1032
|
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.350.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Mails get a plain-text part (EmailMessage.text, NotificationRenderer.renderText, simple renderer and GDPR mails fill it)",
|
|
6
|
+
"detail": "Email text part\n`EmailMessage` gets an optional `text`. With it the SMTP transport sends `multipart/alternative` with a plain-text part. `NotificationRenderer` gets an optional `renderText`, which the email channel sends next to the HTML through the queued render and send jobs. `createSimpleRenderer` implements it from the template data (header, sections, footer, branding footer; buttons as `label: url`). The GDPR default mails return and send `text` as well. The PII guard checks and redacts `text` like the body."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.348.1",
|
|
4
10
|
"type": "fix",
|