@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.
@@ -78,13 +78,15 @@ export function createEmailChannel(options) {
78
78
  title: message.title,
79
79
  body: message.body,
80
80
  };
81
- const html = await renderer.render({
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({ to: address, subject, html, ...envelope }));
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) || message.html.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
  });
@@ -2,6 +2,7 @@ export type EmailMessage = {
2
2
  readonly to: string;
3
3
  readonly subject: string;
4
4
  readonly html: string;
5
+ readonly text?: string;
5
6
  readonly from?: string;
6
7
  readonly fromName?: string;
7
8
  readonly replyTo?: string;
@@ -28,7 +28,9 @@ const renderJobPayloadSchema = z.object({
28
28
  message: channelMessageSchema,
29
29
  });
30
30
  const sendJobPayloadSchema = renderJobPayloadSchema.extend({
31
- rendered: z.object({ html: z.string(), subject: z.string() }).optional(),
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;
@@ -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 renderBrandingFooter(branding, locale) {
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
- const footerText = pick(branding.footerText);
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 data = input.variables; // @cast-boundary render-helper
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 (data.footer) {
138
- parts.push(`<p style="margin:24px 0 0;color:#999;font-size:12px;border-top:1px solid #eee;padding-top:16px">${escapeHtml(data.footer)}</p>`);
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();
@@ -4,6 +4,7 @@ export declare const GDPR_MAIL_EN: Readonly<Record<string, string>>;
4
4
  export type RenderedEmail = {
5
5
  readonly subject: string;
6
6
  readonly html: string;
7
+ readonly text: string;
7
8
  };
8
9
  export type RenderExportReadyEmailArgs = {
9
10
  readonly downloadUrl: string;
@@ -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
- return { subject, html: renderShell({ title: subject, bodyHtml: wrapCell(body), locale }) };
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
- return { subject, html: renderShell({ title: subject, bodyHtml: wrapCell(body), locale }) };
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
- return { subject, html: renderShell({ title: subject, bodyHtml: wrapCell(body), locale }) };
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
- return { subject, html: renderShell({ title: subject, bodyHtml: wrapCell(body), locale }) };
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.349.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.349.0",
507
- "@cosmicdrift/kumiko-framework": "0.349.0",
508
- "@cosmicdrift/kumiko-headless": "0.349.0",
509
- "@cosmicdrift/kumiko-renderer": "0.349.0",
510
- "@cosmicdrift/kumiko-renderer-web": "0.349.0",
511
- "@cosmicdrift/kumiko-types": "0.349.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.349.0",
1029
- "@cosmicdrift/kumiko-locale-es": "0.349.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",