@cosmicdrift/kumiko-samples 0.391.1 → 0.392.1

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.
Files changed (137) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +1 -1
  3. package/packages/bundled-features/src/admin-shell/changes.json +6 -0
  4. package/packages/bundled-features/src/admin-shell/feature.ts +60 -31
  5. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -2
  6. package/packages/bundled-features/src/auth-email-password/handlers/invite-signup-complete.write.ts +2 -2
  7. package/packages/bundled-features/src/cap-overview/changes.json +18 -0
  8. package/packages/bundled-features/src/cap-overview/handlers/caps-usage.query.ts +1 -0
  9. package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +3 -2
  10. package/packages/bundled-features/src/cap-overview/i18n.ts +4 -0
  11. package/packages/bundled-features/src/cap-overview/types.ts +4 -1
  12. package/packages/bundled-features/src/cap-overview/web/cap-amount-format.ts +37 -0
  13. package/packages/bundled-features/src/cap-overview/web/cap-cards-panel.tsx +137 -54
  14. package/packages/bundled-features/src/cap-overview/web/cap-usage-bar.tsx +18 -4
  15. package/packages/bundled-features/src/cap-overview/web/cap-usage-cell.tsx +6 -3
  16. package/packages/bundled-features/src/cap-overview/web/usage-math.ts +5 -1
  17. package/packages/bundled-features/src/channel-email/app-transport.ts +37 -0
  18. package/packages/bundled-features/src/channel-email/changes.json +6 -0
  19. package/packages/bundled-features/src/channel-email/feature.ts +6 -0
  20. package/packages/bundled-features/src/channel-email/index.ts +5 -0
  21. package/packages/bundled-features/src/channel-email/smtp-transport.ts +2 -22
  22. package/packages/bundled-features/src/foundation-shared/changes.json +6 -0
  23. package/packages/bundled-features/src/foundation-shared/index.ts +1 -0
  24. package/packages/bundled-features/src/foundation-shared/mail-display-name.ts +20 -0
  25. package/packages/bundled-features/src/inbound-mail-foundation/account-record.ts +48 -0
  26. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +18 -0
  27. package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +1 -1
  28. package/packages/bundled-features/src/inbound-mail-foundation/i18n.ts +17 -0
  29. package/packages/bundled-features/src/inbound-mail-foundation/index.ts +18 -0
  30. package/packages/bundled-features/src/inbound-mail-foundation/outbound-mail.ts +66 -0
  31. package/packages/bundled-features/src/inbound-mail-foundation/probe.ts +8 -45
  32. package/packages/bundled-features/src/inbound-mail-foundation/screens.ts +14 -0
  33. package/packages/bundled-features/src/inbound-mail-foundation/send-from-account.ts +118 -0
  34. package/packages/bundled-features/src/inbound-mail-foundation/types.ts +8 -0
  35. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +13 -8
  36. package/packages/bundled-features/src/inbound-provider-imap/changes.json +18 -0
  37. package/packages/bundled-features/src/inbound-provider-imap/connect-mailbox.ts +158 -0
  38. package/packages/bundled-features/src/inbound-provider-imap/constants.ts +44 -0
  39. package/packages/bundled-features/src/inbound-provider-imap/credential-document.ts +47 -1
  40. package/packages/bundled-features/src/inbound-provider-imap/feature.ts +12 -35
  41. package/packages/bundled-features/src/inbound-provider-imap/i18n.ts +49 -0
  42. package/packages/bundled-features/src/inbound-provider-imap/imap-client.ts +1 -1
  43. package/packages/bundled-features/src/inbound-provider-imap/screens.ts +52 -0
  44. package/packages/bundled-features/src/inbound-provider-imap/send-mail.ts +73 -0
  45. package/packages/bundled-features/src/inbound-provider-imap/sent-folder.ts +49 -0
  46. package/packages/bundled-features/src/inbound-provider-imap/smtp-client.ts +135 -0
  47. package/packages/bundled-features/src/legal-pages/README.md +5 -10
  48. package/packages/bundled-features/src/mail-foundation/changes.json +6 -0
  49. package/packages/bundled-features/src/mail-foundation/i18n.ts +9 -1
  50. package/packages/bundled-features/src/mail-foundation/probe.ts +43 -22
  51. package/packages/bundled-features/src/readiness/built-in-probes.ts +15 -2
  52. package/packages/bundled-features/src/readiness/changes.json +30 -0
  53. package/packages/bundled-features/src/readiness/constants.ts +2 -0
  54. package/packages/bundled-features/src/readiness/i18n.ts +7 -1
  55. package/packages/bundled-features/src/readiness/index.ts +6 -1
  56. package/packages/bundled-features/src/readiness/screens.ts +25 -2
  57. package/packages/bundled-features/src/readiness/visible-results.ts +2 -1
  58. package/packages/bundled-features/src/renderer-foundation/README.md +19 -21
  59. package/packages/bundled-features/src/template-resolver/README.md +144 -165
  60. package/packages/bundled-features/src/tier-engine/changes.json +6 -0
  61. package/packages/bundled-features/src/tier-engine/feature.ts +17 -6
  62. package/packages/bundled-features/src/user-data-rights/handlers/download-by-token.query.ts +3 -3
  63. package/packages/bundled-features/src/user-data-rights/schema/download-token.ts +5 -3
  64. package/packages/bundled-features/src/user-data-rights/token-helpers.ts +5 -4
  65. package/samples/apps/cap-billing-demo/bin/server.ts +7 -7
  66. package/samples/apps/cap-billing-demo/src/feature.ts +38 -38
  67. package/samples/apps/cap-billing-demo/src/run-config.ts +20 -20
  68. package/samples/apps/cap-billing-demo/src/tier-map.ts +14 -14
  69. package/samples/apps/config-demo/src/app/server.ts +4 -4
  70. package/samples/apps/config-demo/src/features/demo/feature.ts +4 -4
  71. package/samples/apps/marketing-demo/playwright.config.ts +3 -3
  72. package/samples/apps/marketing-demo/src/app/client.tsx +2 -2
  73. package/samples/apps/marketing-demo/src/app/seed-data.ts +9 -9
  74. package/samples/apps/marketing-demo/src/app/seed.ts +14 -14
  75. package/samples/apps/marketing-demo/src/app/server.ts +7 -7
  76. package/samples/apps/marketing-demo/src/app/shell.tsx +3 -3
  77. package/samples/apps/marketing-demo/src/features/assets/feature.ts +3 -3
  78. package/samples/apps/marketing-demo/src/features/assets/i18n.ts +7 -7
  79. package/samples/apps/marketing-demo/src/features/assets/web/index.ts +2 -2
  80. package/samples/apps/showcase/src/app/client.tsx +3 -3
  81. package/samples/apps/showcase/src/app/seed-items.ts +14 -14
  82. package/samples/apps/showcase/src/app/server.ts +7 -7
  83. package/samples/apps/showcase/src/features/demos/feature.ts +13 -13
  84. package/samples/apps/showcase/src/features/demos/i18n.ts +2 -2
  85. package/samples/apps/showcase/src/features/demos/web/components/page.tsx +5 -5
  86. package/samples/apps/showcase/src/features/demos/web/index.ts +4 -4
  87. package/samples/apps/showcase/src/features/demos/web/pages/dialog.tsx +1 -1
  88. package/samples/apps/showcase/src/features/demos/web/pages/inputs.tsx +8 -8
  89. package/samples/apps/showcase/src/features/demos/web/pages/layout.tsx +5 -5
  90. package/samples/apps/showcase/src/features/demos/web/pages/toast.tsx +3 -3
  91. package/samples/apps/showcase/src/features/items/feature.ts +8 -8
  92. package/samples/apps/showcase/src/features/items/i18n.ts +3 -3
  93. package/samples/apps/showcase/src/features/items/index.ts +3 -3
  94. package/samples/apps/showcase/src/features/items/schema/index.ts +4 -4
  95. package/samples/apps/showcase/src/features/items/schema/item.ts +27 -27
  96. package/samples/apps/showcase/src/features/items/web/index.ts +6 -6
  97. package/samples/apps/styleguide/src/app/seed.ts +3 -3
  98. package/samples/apps/styleguide/src/app/server.ts +2 -2
  99. package/samples/apps/styleguide/src/app/shell.tsx +4 -4
  100. package/samples/apps/styleguide/src/features/content/web.tsx +6 -6
  101. package/samples/apps/styleguide/src/features/examples/feature.ts +8 -8
  102. package/samples/apps/styleguide/src/features/gallery/feature.ts +9 -9
  103. package/samples/apps/styleguide/src/features/gallery/web/Gallery.tsx +12 -12
  104. package/samples/apps/styleguide/src/features/widgets/feature.ts +2 -2
  105. package/samples/apps/styleguide/src/features/widgets/web/DashboardFilterEcho.tsx +3 -3
  106. package/samples/apps/styleguide/src/features/widgets/web/DashboardKpiIcon.tsx +4 -4
  107. package/samples/apps/ui-walkthrough/playwright.config.ts +6 -6
  108. package/samples/apps/ui-walkthrough/scripts/emit-e2e-data.ts +5 -5
  109. package/samples/apps/ui-walkthrough/src/app/auth-constants.ts +13 -13
  110. package/samples/apps/ui-walkthrough/src/app/client.tsx +2 -2
  111. package/samples/apps/ui-walkthrough/src/app/server.ts +14 -14
  112. package/samples/apps/ui-walkthrough/src/app/shell.tsx +3 -3
  113. package/samples/apps/use-all-bundled/bin/main.ts +5 -5
  114. package/samples/apps/use-all-bundled/feature-manifest.json +10 -4
  115. package/samples/apps/use-all-bundled/kumiko/schema.ts +7 -7
  116. package/samples/apps/use-all-bundled/scripts/check-coverage.ts +20 -20
  117. package/samples/apps/use-all-bundled/src/app/auth-constants.ts +4 -4
  118. package/samples/apps/use-all-bundled/src/app/client.tsx +4 -4
  119. package/samples/apps/use-all-bundled/src/app/dev-app-options.ts +12 -12
  120. package/samples/apps/use-all-bundled/src/app/seed.ts +6 -6
  121. package/samples/apps/user-data-rights-demo/src/feature.ts +23 -23
  122. package/samples/apps/workspaces/playwright.config.ts +5 -5
  123. package/samples/apps/workspaces/src/app/auth-constants.ts +3 -3
  124. package/samples/apps/workspaces/src/app/server.ts +16 -16
  125. package/samples/apps/workspaces/src/app/shell.tsx +2 -2
  126. package/samples/apps/workspaces/src/features/demo/schema/order.ts +4 -4
  127. package/samples/apps/workspaces/src/features/demo-driver/feature.ts +5 -5
  128. package/samples/recipes/apex-surface-auth/src/feature.ts +31 -31
  129. package/samples/recipes/designer-demo/src/feature.ts +6 -6
  130. package/samples/recipes/encrypted-tenant-config/src/feature.ts +20 -20
  131. package/samples/recipes/lane-routing/src/feature.ts +5 -5
  132. package/samples/recipes/legal-pages/src/feature.ts +29 -29
  133. package/samples/recipes/record-detail-layout/src/feature.ts +21 -2
  134. package/samples/recipes/seed-migration/scripts/smoke.ts +20 -20
  135. package/samples/recipes/seed-migration/seeds/2026-01-01-initial-tenants.ts +11 -11
  136. package/samples/recipes/seed-migration/seeds/2026-02-01-fix-admin-roles.ts +13 -13
  137. package/samples/recipes/user-data-rights/src/feature.ts +13 -13
@@ -0,0 +1,49 @@
1
+ // Stores the copy of a sent mail in the mailbox's sent folder, so the customer finds the reply in
2
+ // their own mail client. Best effort: the mail is already out, a failed copy is only logged.
3
+
4
+ import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
5
+ import type { ImapFlow } from "imapflow";
6
+ import type { ImapCredentialDocument } from "./credential-document.ts";
7
+ import { createImapClient, imapMailHostGuard } from "./imap-client.ts";
8
+
9
+ const log = createFallbackLogger("inbound-provider-imap");
10
+
11
+ const SENT_SPECIAL_USE = "\\Sent";
12
+ const SENT_FALLBACK_PATHS = ["Sent", "Gesendet", "INBOX.Sent"];
13
+
14
+ type ListedMailbox = { readonly path: string; readonly specialUse?: string };
15
+
16
+ export function pickSentMailboxPath(mailboxes: readonly ListedMailbox[]): string | undefined {
17
+ const flagged = mailboxes.find((mailbox) => mailbox.specialUse === SENT_SPECIAL_USE);
18
+ if (flagged) return flagged.path;
19
+ for (const name of SENT_FALLBACK_PATHS) {
20
+ const match = mailboxes.find((mailbox) => mailbox.path.toLowerCase() === name.toLowerCase());
21
+ if (match) return match.path;
22
+ }
23
+ return undefined;
24
+ }
25
+
26
+ export async function appendToSentFolder(
27
+ doc: ImapCredentialDocument,
28
+ raw: Buffer,
29
+ accountId: string,
30
+ ): Promise<void> {
31
+ let client: ImapFlow | undefined;
32
+ try {
33
+ client = await createImapClient(doc, imapMailHostGuard());
34
+ await client.connect();
35
+ const path = pickSentMailboxPath(await client.list());
36
+ if (!path) {
37
+ log.warn("mailbox has no sent folder, sent copy not stored", { accountId });
38
+ return;
39
+ }
40
+ await client.append(path, raw, ["\\Seen"]);
41
+ } catch (err) {
42
+ log.warn("storing the sent copy failed", {
43
+ accountId,
44
+ reason: err instanceof Error ? err.message : String(err),
45
+ });
46
+ } finally {
47
+ await client?.logout().catch(() => {});
48
+ }
49
+ }
@@ -0,0 +1,135 @@
1
+ // nodemailer wiring for sending from a connected mailbox: one short-lived, non-pooled connection
2
+ // per call, TLS enforced (implicit on 465, STARTTLS otherwise), pinned to the address the mail host guard approved.
3
+
4
+ import {
5
+ BlockedHostError,
6
+ type MailConnectTarget,
7
+ resolveMailConnectTarget,
8
+ } from "@cosmicdrift/kumiko-bundled-features/foundation-shared";
9
+ import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
10
+ import { errorCodeOf, propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
11
+ import { createTransport, type Transporter } from "nodemailer";
12
+ import { imapMailHostGuard } from "./imap-client.ts";
13
+
14
+ const log = createFallbackLogger("inbound-provider-imap");
15
+
16
+ export const SMTP_IMPLICIT_TLS_PORT = 465;
17
+
18
+ // nodemailer's defaults wait minutes; the connect form holds a request open meanwhile.
19
+ const CONNECTION_TIMEOUT_MS = 10_000;
20
+ const GREETING_TIMEOUT_MS = 10_000;
21
+ const SOCKET_TIMEOUT_MS = 30_000;
22
+
23
+ export type SmtpLogin = {
24
+ readonly host: string;
25
+ readonly port: number;
26
+ readonly user: string;
27
+ readonly password: string;
28
+ };
29
+
30
+ export type SmtpFailure =
31
+ | "hostUnreachable"
32
+ | "loginRejected"
33
+ | "tlsFailed"
34
+ | "connectFailed"
35
+ | "rejected";
36
+
37
+ // Carries only the category and the library code: the raw error holds the host and the server
38
+ // response, which stay in the server log.
39
+ export class SmtpFailureError extends Error {
40
+ readonly failure: SmtpFailure;
41
+ constructor(failure: SmtpFailure, detail: string) {
42
+ super(`SMTP ${failure}: ${detail}`);
43
+ this.name = "SmtpFailureError";
44
+ this.failure = failure;
45
+ }
46
+ }
47
+
48
+ const LOGIN_CODES = ["EAUTH", "ENOAUTH"];
49
+ const CONNECTION_CODES = ["ECONNECTION", "ETIMEDOUT", "ESOCKET", "EDNS", "EPROXY"];
50
+ const TLS_CODE = /^(ETLS|ERR_SSL|ERR_TLS)|CERT|SELF_SIGNED|UNABLE_TO_VERIFY/;
51
+
52
+ // nodemailer wraps a TLS handshake error as ESOCKET and keeps the TLS code as originalCode.
53
+ function isTlsFailure(code: string, originalCode: unknown): boolean {
54
+ return TLS_CODE.test(code) || (typeof originalCode === "string" && TLS_CODE.test(originalCode));
55
+ }
56
+
57
+ export function classifySmtpError(err: unknown): SmtpFailure {
58
+ const code = errorCodeOf(err) ?? "";
59
+ if (LOGIN_CODES.includes(code)) return "loginRejected";
60
+ if (isTlsFailure(code, propertyOf(err, "originalCode"))) return "tlsFailed";
61
+ if (CONNECTION_CODES.includes(code)) return "connectFailed";
62
+ if (typeof propertyOf(err, "responseCode") === "number") return "rejected";
63
+ return "connectFailed";
64
+ }
65
+
66
+ export function smtpTransportOptions(login: SmtpLogin, target: MailConnectTarget) {
67
+ return {
68
+ host: target.host,
69
+ port: login.port,
70
+ secure: login.port === SMTP_IMPLICIT_TLS_PORT,
71
+ // Without it nodemailer sends AUTH in clear text when the server offers no STARTTLS.
72
+ ...(login.port !== SMTP_IMPLICIT_TLS_PORT && { requireTLS: true }),
73
+ ...(target.servername && { servername: target.servername }),
74
+ auth: { user: login.user, pass: login.password },
75
+ connectionTimeout: CONNECTION_TIMEOUT_MS,
76
+ greetingTimeout: GREETING_TIMEOUT_MS,
77
+ socketTimeout: SOCKET_TIMEOUT_MS,
78
+ // The message is composed by us; nothing in it may make nodemailer read files or URLs.
79
+ disableFileAccess: true,
80
+ disableUrlAccess: true,
81
+ logger: false,
82
+ debug: false,
83
+ } as const;
84
+ }
85
+
86
+ async function openSmtpTransport(login: SmtpLogin): Promise<Transporter> {
87
+ let target: MailConnectTarget;
88
+ try {
89
+ target = await resolveMailConnectTarget(login.host, imapMailHostGuard());
90
+ } catch (err) {
91
+ const reason = err instanceof Error ? err.message : String(err);
92
+ const blocked = err instanceof BlockedHostError;
93
+ log.warn(blocked ? "rejected blocked smtp host" : "smtp host resolution failed", {
94
+ host: login.host,
95
+ reason,
96
+ });
97
+ throw new SmtpFailureError("hostUnreachable", blocked ? "blocked" : "unresolved");
98
+ }
99
+ return createTransport(smtpTransportOptions(login, target));
100
+ }
101
+
102
+ // No error message in the log: a server's reply to a failed AUTH can echo the user name.
103
+ function smtpFailureOf(err: unknown, host: string): SmtpFailureError {
104
+ const failure = classifySmtpError(err);
105
+ const code = errorCodeOf(err) ?? "unknown";
106
+ const responseCode = propertyOf(err, "responseCode");
107
+ log.warn("smtp call failed", { host, failure, code, responseCode });
108
+ return new SmtpFailureError(failure, code);
109
+ }
110
+
111
+ export async function verifySmtpLogin(login: SmtpLogin): Promise<void> {
112
+ const transport = await openSmtpTransport(login);
113
+ try {
114
+ await transport.verify();
115
+ } catch (err) {
116
+ throw smtpFailureOf(err, login.host);
117
+ } finally {
118
+ transport.close();
119
+ }
120
+ }
121
+
122
+ export async function sendRawViaSmtp(
123
+ login: SmtpLogin,
124
+ envelope: { readonly from: string; readonly to: readonly string[] },
125
+ raw: Buffer,
126
+ ): Promise<void> {
127
+ const transport = await openSmtpTransport(login);
128
+ try {
129
+ await transport.sendMail({ envelope: { from: envelope.from, to: [...envelope.to] }, raw });
130
+ } catch (err) {
131
+ throw smtpFailureOf(err, login.host);
132
+ } finally {
133
+ transport.close();
134
+ }
135
+ }
@@ -251,10 +251,8 @@ import { legalContentSeed } from "@cosmicdrift/kumiko-bundled-features/legal-pag
251
251
  runProdApp({ /* ... */ seeds: [legalContentSeed({ blocks: legalContent })] });
252
252
  ```
253
253
 
254
- Templates for imprint + privacy policy: see
255
- [docs/plans/datenschutz/legal-artifacts.md](../../../../docs/plans/datenschutz/legal-artifacts.md)
256
- and vetted external generators (e-recht24.de,
257
- datenschutz-generator.de).
254
+ Templates for imprint + privacy policy: use vetted external generators
255
+ (e-recht24.de, datenschutz-generator.de).
258
256
 
259
257
  ---
260
258
 
@@ -290,11 +288,8 @@ and put your own routes in front.
290
288
 
291
289
  ## Architecture cross-refs
292
290
 
293
- - [docs/plans/datenschutz/](../../../../docs/plans/datenschutz/)
294
- — consolidated privacy plan index
295
- - [docs/plans/datenschutz/legal-artifacts.md](../../../../docs/plans/datenschutz/legal-artifacts.md)
296
- — templates + where-is-what for imprint/AVV/TOMs/RoPA
297
- - [docs/plans/datenschutz/compliance-as-product.md](../../../../docs/plans/datenschutz/compliance-as-product.md)
298
- — roadmap for auto-generation (sub-processor list, TOMs, data-breach workflow)
291
+ - Privacy planning (templates and where-is-what for imprint/AVV/TOMs/RoPA, plus the
292
+ roadmap for auto-generation of the sub-processor list, TOMs and data-breach
293
+ workflow) lives in the private platform repo.
299
294
  - [samples/recipes/legal-pages/](../../../../samples/recipes/legal-pages/)
300
295
  — live sample with both features wired up
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.392.0",
4
+ "type": "improvement",
5
+ "title": "The mail probe tests the platform sender",
6
+ "detail": "A tenant without its own mail provider now gets the test mail through the channel-email app transport, the sender its tenant-transport notifications fall back to. The probe reports mail-foundation.probe.send-test-mail.platform-accepted on success and mail-foundation.probe.platform-send-rejected when the platform server rejects it; it is skipped only when neither an own provider nor an app transport exists, and mail-foundation.probe.not-configured now reads \"No email sending set up\". New keys in all locale packages."
7
+ },
2
8
  {
3
9
  "version": "0.391.0",
4
10
  "type": "improvement",
@@ -2,12 +2,14 @@ export const MAIL_PROBE_KEYS = {
2
2
  area: "mail-foundation.probe.area",
3
3
  label: "mail-foundation.probe.send-test-mail.label",
4
4
  accepted: "mail-foundation.probe.send-test-mail.accepted",
5
+ platformAccepted: "mail-foundation.probe.send-test-mail.platform-accepted",
5
6
  notConfigured: "mail-foundation.probe.not-configured",
6
7
  noRecipient: "mail-foundation.probe.no-recipient",
7
8
  hostNotFound: "mail-foundation.probe.host-not-found",
8
9
  setupIncomplete: "mail-foundation.probe.setup-incomplete",
9
10
  setupFailed: "mail-foundation.probe.setup-failed",
10
11
  sendRejected: "mail-foundation.probe.send-rejected",
12
+ platformSendRejected: "mail-foundation.probe.platform-send-rejected",
11
13
  } as const;
12
14
 
13
15
  export const MAIL_FOUNDATION_I18N = {
@@ -21,7 +23,10 @@ export const MAIL_FOUNDATION_I18N = {
21
23
  [MAIL_PROBE_KEYS.area]: { en: "Email" },
22
24
  [MAIL_PROBE_KEYS.label]: { en: "Test email to the person who started the check" },
23
25
  [MAIL_PROBE_KEYS.accepted]: { en: "Accepted by the mail server" },
24
- [MAIL_PROBE_KEYS.notConfigured]: { en: "No own email sending set up" },
26
+ [MAIL_PROBE_KEYS.platformAccepted]: {
27
+ en: "Sent via the platform sender, accepted by its mail server",
28
+ },
29
+ [MAIL_PROBE_KEYS.notConfigured]: { en: "No email sending set up" },
25
30
  [MAIL_PROBE_KEYS.noRecipient]: {
26
31
  en: "No email address known for the person who started the check",
27
32
  },
@@ -33,4 +38,7 @@ export const MAIL_FOUNDATION_I18N = {
33
38
  [MAIL_PROBE_KEYS.sendRejected]: {
34
39
  en: "The mail server did not accept the test email, check address, port and login",
35
40
  },
41
+ [MAIL_PROBE_KEYS.platformSendRejected]: {
42
+ en: "The platform mail server did not accept the test email",
43
+ },
36
44
  } as const;
@@ -1,3 +1,7 @@
1
+ import {
2
+ type EmailTransport,
3
+ findEmailAppTransport,
4
+ } from "@cosmicdrift/kumiko-bundled-features/channel-email";
1
5
  import {
2
6
  createSystemUser,
3
7
  type FeatureRegistrar,
@@ -30,6 +34,36 @@ function transportFailureKey(error: unknown): string {
30
34
  : MAIL_PROBE_KEYS.setupFailed;
31
35
  }
32
36
 
37
+ async function ownTenantTransport(ctx: ProbeContext): Promise<EmailTransport | null> {
38
+ const { configResolver, secrets } = ctx.services;
39
+ if (!configResolver) return null;
40
+ const config = createConfigAccessor(
41
+ ctx.registry,
42
+ configResolver,
43
+ ctx.tenantId,
44
+ createSystemUser(ctx.tenantId).id,
45
+ ctx.db,
46
+ );
47
+ return tenantMailTransport({
48
+ tenantId: ctx.tenantId,
49
+ config,
50
+ registry: ctx.registry,
51
+ secrets,
52
+ }).catch((error: unknown) => {
53
+ throw probeFailure(ctx, error, transportFailureKey(error));
54
+ });
55
+ }
56
+
57
+ // Same order as channel-email for tenant mails: the tenant's own provider, else the app sender.
58
+ async function probeTransport(
59
+ ctx: ProbeContext,
60
+ ): Promise<{ readonly transport: EmailTransport; readonly viaPlatform: boolean } | null> {
61
+ const own = await ownTenantTransport(ctx);
62
+ if (own !== null) return { transport: own, viaPlatform: false };
63
+ const platform = findEmailAppTransport(ctx.registry);
64
+ return platform === null ? null : { transport: platform, viaPlatform: true };
65
+ }
66
+
33
67
  // The mail goes to whoever started the check; the address never appears in the stored result.
34
68
  export function registerMailProbe(r: FeatureRegistrar<"mail-foundation">): void {
35
69
  r.probe({
@@ -40,31 +74,14 @@ export function registerMailProbe(r: FeatureRegistrar<"mail-foundation">): void
40
74
  label: MAIL_PROBE_KEYS.label,
41
75
  fixScreen: MAIL_SETTINGS_SCREEN_QN,
42
76
  run: async (ctx) => {
43
- const { configResolver, secrets } = ctx.services;
44
- if (!configResolver) {
45
- return { status: "skipped", message: { key: MAIL_PROBE_KEYS.notConfigured } };
46
- }
47
- const config = createConfigAccessor(
48
- ctx.registry,
49
- configResolver,
50
- ctx.tenantId,
51
- createSystemUser(ctx.tenantId).id,
52
- ctx.db,
53
- );
54
- const transport = await tenantMailTransport({
55
- tenantId: ctx.tenantId,
56
- config,
57
- registry: ctx.registry,
58
- secrets,
59
- }).catch((error: unknown) => {
60
- throw probeFailure(ctx, error, transportFailureKey(error));
61
- });
62
- if (transport === null) {
77
+ const resolved = await probeTransport(ctx);
78
+ if (resolved === null) {
63
79
  return { status: "skipped", message: { key: MAIL_PROBE_KEYS.notConfigured } };
64
80
  }
65
81
  if (ctx.triggeredByEmail === undefined) {
66
82
  return { status: "skipped", message: { key: MAIL_PROBE_KEYS.noRecipient } };
67
83
  }
84
+ const { transport, viaPlatform } = resolved;
68
85
  await transport
69
86
  .send({
70
87
  to: ctx.triggeredByEmail,
@@ -73,9 +90,13 @@ export function registerMailProbe(r: FeatureRegistrar<"mail-foundation">): void
73
90
  html: `<p>${PROBE_TEXT}</p>`,
74
91
  })
75
92
  .catch((error: unknown) => {
76
- throw probeFailure(ctx, error, MAIL_PROBE_KEYS.sendRejected);
93
+ const key = viaPlatform
94
+ ? MAIL_PROBE_KEYS.platformSendRejected
95
+ : MAIL_PROBE_KEYS.sendRejected;
96
+ throw probeFailure(ctx, error, key);
77
97
  });
78
- return { status: "ok", message: { key: MAIL_PROBE_KEYS.accepted } };
98
+ const accepted = viaPlatform ? MAIL_PROBE_KEYS.platformAccepted : MAIL_PROBE_KEYS.accepted;
99
+ return { status: "ok", message: { key: accepted } };
79
100
  },
80
101
  });
81
102
  }
@@ -1,4 +1,8 @@
1
- import type { FeatureRegistrar, ProbeContext } from "@cosmicdrift/kumiko-framework/engine";
1
+ import {
2
+ type FeatureRegistrar,
3
+ type ProbeContext,
4
+ ProbeFailure,
5
+ } from "@cosmicdrift/kumiko-framework/engine";
2
6
  import { ProbeTextKeys } from "./constants.ts";
3
7
  import { probeRunsTable } from "./schema/probe-run.ts";
4
8
 
@@ -10,6 +14,7 @@ export const BuiltInProbeKeys = {
10
14
  cacheOk: "readiness.probe.cache.ok",
11
15
  searchLabel: "readiness.probe.search.label",
12
16
  searchOk: "readiness.probe.search.ok",
17
+ searchUnavailable: "readiness.probe.search.unavailable",
13
18
  } as const;
14
19
 
15
20
  export function registerBuiltInProbes(r: FeatureRegistrar<"readiness">): void {
@@ -51,7 +56,15 @@ export function registerBuiltInProbes(r: FeatureRegistrar<"readiness">): void {
51
56
  if (!searchAdapter) {
52
57
  return { status: "skipped", message: { key: ProbeTextKeys.notConfigured } };
53
58
  }
54
- await searchAdapter.search(ctx.tenantId, "readiness-probe", { limit: 1 });
59
+ try {
60
+ await searchAdapter.search(ctx.tenantId, "readiness-probe", { limit: 1 });
61
+ } catch (error) {
62
+ // The raw error can name the search host, so it stays in the server log.
63
+ ctx.obs.log.warn("readiness search probe failed", {
64
+ error: error instanceof Error ? error.message : String(error),
65
+ });
66
+ throw new ProbeFailure({ key: BuiltInProbeKeys.searchUnavailable });
67
+ }
55
68
  return { status: "ok", message: { key: BuiltInProbeKeys.searchOk } };
56
69
  },
57
70
  });
@@ -1,4 +1,34 @@
1
1
  [
2
+ {
3
+ "version": "0.392.0",
4
+ "type": "improvement",
5
+ "title": "The paid run is a button next to Start check",
6
+ "detail": "The system check screen declares run-paid-check as an inline action, so \"Test AI extraction\" shows as a button beside \"Start check\" instead of in the \"More actions\" menu. It stays hidden while the app has no paid probes."
7
+ },
8
+ {
9
+ "version": "0.392.0",
10
+ "type": "improvement",
11
+ "title": "The readiness entry exports ProbeTextKeys",
12
+ "detail": "ProbeTextKeys comes from @cosmicdrift/kumiko-bundled-features/readiness next to ReadinessQueries and ReadinessWrites, so apps can name probe texts without a deep import."
13
+ },
14
+ {
15
+ "version": "0.392.0",
16
+ "type": "improvement",
17
+ "title": "The paid probe hint names the button",
18
+ "detail": "readiness.probe.paid-not-requested now reads \"Only via “Test AI extraction”\" (de „Nur über „KI-Auslesen testen““), the label of the button that starts the paid run."
19
+ },
20
+ {
21
+ "version": "0.392.0",
22
+ "type": "improvement",
23
+ "title": "The search probe fails with its own text",
24
+ "detail": "When the search service throws, the search probe fails with readiness.probe.search.unavailable (\"The search service does not answer\"). The raw error can name the search host, so it goes only to the server log. New key in all locale packages."
25
+ },
26
+ {
27
+ "version": "0.392.0",
28
+ "type": "improvement",
29
+ "title": "An empty latest run says why it is empty",
30
+ "detail": "The latest results and the run detail show \"The check is running. Results appear when it is done.\" while a run is running and \"There is nothing to check for this tenant.\" when a finished run has no rows the caller may see, and \"The run was stopped before it returned results.\" for an abandoned run. Without a run the old text stays. New keys readiness.screen.latest.running, readiness.screen.latest.nothing-to-check and readiness.screen.latest.abandoned in all locale packages."
31
+ },
2
32
  {
3
33
  "version": "0.391.0",
4
34
  "type": "improvement",
@@ -15,6 +15,8 @@ export const ReadinessWrites = {
15
15
  runProbes: "readiness:write:run-probes",
16
16
  } as const;
17
17
 
18
+ export const RUN_OUTCOME = { ok: "ok", failed: "failed", running: "running" } as const;
19
+
18
20
  export const DEFAULT_PROBE_TIMEOUT_MS = 10_000;
19
21
 
20
22
  // An active run older than this is treated as dead (worker crash) and may be replaced.
@@ -10,7 +10,8 @@ export const READINESS_I18N: Readonly<Record<string, LocalizedString>> = {
10
10
  "readiness.probe.cache.ok": { en: "Cache reachable" },
11
11
  "readiness.probe.search.label": { en: "Search" },
12
12
  "readiness.probe.search.ok": { en: "Search reachable" },
13
- "readiness.probe.paid-not-requested": { en: "Not run, paid checks were not requested" },
13
+ "readiness.probe.search.unavailable": { en: "The search service does not answer" },
14
+ "readiness.probe.paid-not-requested": { en: "Only via “Test AI extraction”" },
14
15
  "readiness.probe.no-targets": { en: "Nothing to check" },
15
16
  "readiness.probe.timeout": { en: "No answer after {seconds} seconds" },
16
17
  "readiness.probe.failed": { en: "The check failed" },
@@ -29,6 +30,11 @@ export const READINESS_I18N: Readonly<Record<string, LocalizedString>> = {
29
30
  en: "Real calls against mail, storage, search and the other connected services. A failed row links to the screen that fixes it.",
30
31
  },
31
32
  "readiness.screen.latest.empty": { en: "No results yet. Start a check." },
33
+ "readiness.screen.latest.running": {
34
+ en: "The check is running. Results appear when it is done.",
35
+ },
36
+ "readiness.screen.latest.nothing-to-check": { en: "There is nothing to check for this tenant." },
37
+ "readiness.screen.latest.abandoned": { en: "The run was stopped before it returned results." },
32
38
  "readiness.screen.history.title": { en: "History" },
33
39
  "readiness.screen.history.description": {
34
40
  en: "Earlier runs, newest first. Open one to see its results.",
@@ -1,3 +1,8 @@
1
- export { READINESS_FEATURE, ReadinessQueries, ReadinessWrites } from "./constants.ts";
1
+ export {
2
+ ProbeTextKeys,
3
+ READINESS_FEATURE,
4
+ ReadinessQueries,
5
+ ReadinessWrites,
6
+ } from "./constants.ts";
2
7
  export { createReadinessFeature } from "./feature.ts";
3
8
  export type { ReadinessMissingSecret } from "./handlers/status.query.ts";
@@ -4,7 +4,8 @@ import type {
4
4
  ProjectionDetailScreenDefinition,
5
5
  RowAction,
6
6
  } from "@cosmicdrift/kumiko-framework/ui-types";
7
- import { ReadinessQueries, ReadinessWrites } from "./constants.ts";
7
+ import { ReadinessQueries, ReadinessWrites, RUN_OUTCOME } from "./constants.ts";
8
+ import { PROBE_RUN_STATUS } from "./schema/probe-run.ts";
8
9
 
9
10
  export const SYSTEM_CHECK_SCREEN_ID = "system-check";
10
11
  export const SYSTEM_CHECK_RUN_SCREEN_ID = "system-check-run";
@@ -17,6 +18,9 @@ export const ScreenTextKeys = {
17
18
  latestTitle: "readiness.screen.latest.title",
18
19
  latestDescription: "readiness.screen.latest.description",
19
20
  latestEmpty: "readiness.screen.latest.empty",
21
+ latestRunning: "readiness.screen.latest.running",
22
+ latestNothingToCheck: "readiness.screen.latest.nothing-to-check",
23
+ latestAbandoned: "readiness.screen.latest.abandoned",
20
24
  historyTitle: "readiness.screen.history.title",
21
25
  historyDescription: "readiness.screen.history.description",
22
26
  historyEmpty: "readiness.screen.history.empty",
@@ -76,7 +80,24 @@ function resultsSection(
76
80
  title,
77
81
  query,
78
82
  columns: RESULT_COLUMNS,
79
- emptyState: { title: ScreenTextKeys.latestEmpty },
83
+ emptyState: {
84
+ title: ScreenTextKeys.latestEmpty,
85
+ // A finished run without rows had nothing the caller may see, not "no run yet".
86
+ variants: [
87
+ {
88
+ when: { field: "outcome", eq: RUN_OUTCOME.running },
89
+ title: ScreenTextKeys.latestRunning,
90
+ },
91
+ {
92
+ when: { field: "outcome", eq: RUN_OUTCOME.ok },
93
+ title: ScreenTextKeys.latestNothingToCheck,
94
+ },
95
+ {
96
+ when: { field: "status", eq: PROBE_RUN_STATUS.Abandoned },
97
+ title: ScreenTextKeys.latestAbandoned,
98
+ },
99
+ ],
100
+ },
80
101
  ...extra,
81
102
  };
82
103
  }
@@ -121,6 +142,8 @@ export function systemCheckScreen(dormant: boolean): ProjectionDetailScreenDefin
121
142
  access: ACCESS,
122
143
  actions: RUN_ACTIONS,
123
144
  primaryAction: "run-check",
145
+ // The paid run is the only way to test AI extraction, so it must not hide in the menu.
146
+ inlineActions: ["run-paid-check"],
124
147
  metrics: RESULT_METRICS,
125
148
  metricsStyle: "tiles",
126
149
  layout: {
@@ -1,5 +1,6 @@
1
1
  import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import * as z from "zod";
3
+ import type { RUN_OUTCOME } from "./constants.ts";
3
4
  import { PROBE_RUN_STATUS } from "./schema/probe-run.ts";
4
5
 
5
6
  const probeTextSchema = z.object({
@@ -38,7 +39,7 @@ export type VisibleProbeResult = Omit<StoredProbeResult, "label" | "message" | "
38
39
  readonly fixScreen?: string;
39
40
  };
40
41
 
41
- export type RunOutcome = "ok" | "failed" | "running";
42
+ export type RunOutcome = (typeof RUN_OUTCOME)[keyof typeof RUN_OUTCOME];
42
43
 
43
44
  export type ResultCounts = {
44
45
  readonly okCount: number;
@@ -1,8 +1,6 @@
1
1
  # renderer-foundation
2
2
 
3
- Plugin-Foundation für Renderer (HTML-Mail, PDF, Image). Plugins (`renderer-mail-html`, `renderer-puppeteer-client`) registrieren sich via `r.useExtension("renderer", "<name>", { kinds, render })`.
4
-
5
- **Plan-Doc:** [`kumiko-platform/docs/plans/features/renderer-foundation.md`](../../../../../../kumiko-platform/docs/plans/features/renderer-foundation.md)
3
+ Plugin foundation for renderers (HTML email, PDF, image). Plugins (`renderer-mail-html`, `renderer-puppeteer-client`) register via `r.useExtension("renderer", "<name>", { kinds, render })`.
6
4
 
7
5
  ## Mount
8
6
 
@@ -17,7 +15,7 @@ const features = [
17
15
  createTemplateResolverFeature(),
18
16
  createRendererFoundationFeature(),
19
17
  createRendererMailHtmlFeature(), // Plugin: kind "mail"
20
- // weitere Plugins...
18
+ // more plugins...
21
19
  ];
22
20
 
23
21
  const app = createKumikoApp({
@@ -30,7 +28,7 @@ const app = createKumikoApp({
30
28
  });
31
29
  ```
32
30
 
33
- ## Konsumtion
31
+ ## Consumption
34
32
 
35
33
  ```typescript
36
34
  import { requireRendererFoundation } from "@cosmicdrift/kumiko-bundled-features/renderer-foundation";
@@ -49,18 +47,18 @@ async function sendMail(ctx, tenantId) {
49
47
  }
50
48
  ```
51
49
 
52
- Plugins ohne Service-Deps ignorieren den zweiten `ctx`-Parameter.
50
+ Plugins without service dependencies ignore the second `ctx` parameter.
53
51
 
54
- Mails einer `r.notification` rendert die Delivery selbst: `template` + `meta` an der Notification werden beim Boot gesät (`seedNotificationTemplates`) und über kind `"mail"` gerendert. Ohne `template` + `meta` scheitert der Versand über den Email-Channel.
52
+ Emails from an `r.notification` are rendered by the delivery itself: `template` and `meta` on the notification are seeded at boot (`seedNotificationTemplates`) and rendered with kind `"mail"`. Without `template` and `meta`, sending through the email channel fails.
55
53
 
56
- ## Plugin-Auswahl-Reihenfolge
54
+ ## Plugin Selection Order
57
55
 
58
- 1. Tenant-Override (Config-Key `rendererPluginByKind`, z.B. `{ "mail": "mail-html" }`)
59
- 2. `DEFAULT_PLUGIN_BY_KIND` aus constants
60
- 3. Erstes Plugin im Pool das das kind bedient
61
- 4. `RendererError("no_plugin_for_kind")` wenn nichts passt
56
+ 1. Tenant override (config key `rendererPluginByKind`, for example `{ "mail": "mail-html" }`)
57
+ 2. `DEFAULT_PLUGIN_BY_KIND` from constants
58
+ 3. First plugin in the pool that handles the kind
59
+ 4. `RendererError("no_plugin_for_kind")` if nothing matches
62
60
 
63
- ## Eigenes Plugin schreiben
61
+ ## Writing a Custom Plugin
64
62
 
65
63
  ```typescript
66
64
  import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
@@ -69,19 +67,19 @@ export const myRendererFeature = defineFeature("renderer-myown", (r) => {
69
67
  r.requires({ features: ["renderer-foundation"] });
70
68
  r.useExtension("renderer", "myown", {
71
69
  kinds: ["document-pdf"],
72
- // ctx ist optional — nur nehmen wenn Service-Access nötig
70
+ // ctx is optional, only use it when service access is required
73
71
  render: async (req, ctx) => {
74
- // ctx.db, ctx.registry, ctx.tenantId verfügbar
75
- // eigene PDF-Logik
72
+ // ctx.db, ctx.registry, ctx.tenantId available
73
+ // custom PDF logic
76
74
  return { kind: "document-pdf", pdfBytes: ..., pageCount: 1, sizeBytes: ... };
77
75
  },
78
76
  });
79
77
  });
80
78
  ```
81
79
 
82
- ## Out-of-Scope
80
+ ## Out of Scope
83
81
 
84
- - Template-Storage (kommt aus `template-resolver`)
85
- - Resource-URL-Substitution (Caller-Verantwortung: signed-URL vs. data-URI je nach kind)
86
- - Template-Authoring-UI — `designer`-Bundle (geplant)
87
- - Mail-Versand — `delivery` + `mail-transport-smtp`
82
+ - Template storage (provided by `template-resolver`)
83
+ - Resource URL substitution (caller's responsibility: signed URL versus data URI depending on the kind)
84
+ - Template authoring UI, `designer` bundle (planned)
85
+ - Email delivery, `delivery` + `mail-transport-smtp`