@cosmicdrift/kumiko-samples 0.362.0 → 0.363.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (252) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +17 -6
  3. package/packages/bundled-features/src/admin-shell/changes.json +6 -0
  4. package/packages/bundled-features/src/admin-shell/feature.ts +8 -1
  5. package/packages/bundled-features/src/agent-tools/agent-manifest.ts +3 -4
  6. package/packages/bundled-features/src/agent-tools/filter-ops.ts +1 -3
  7. package/packages/bundled-features/src/agent-tools/tool-catalog.ts +1 -3
  8. package/packages/bundled-features/src/agent-tools/tool-dispatch.ts +2 -1
  9. package/packages/bundled-features/src/auth-email-password/changes.json +14 -0
  10. package/packages/bundled-features/src/auth-email-password/email-templates.ts +12 -138
  11. package/packages/bundled-features/src/auth-email-password/feature.ts +30 -9
  12. package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +4 -5
  13. package/packages/bundled-features/src/auth-email-password/handlers/request-account-unlock.write.ts +2 -5
  14. package/packages/bundled-features/src/auth-email-password/handlers/request-email-verification.write.ts +2 -5
  15. package/packages/bundled-features/src/auth-email-password/handlers/request-password-reset.write.ts +2 -5
  16. package/packages/bundled-features/src/auth-email-password/handlers/signup-confirm.write.ts +3 -4
  17. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +3 -12
  18. package/packages/bundled-features/src/auth-email-password/index.ts +2 -17
  19. package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +30 -22
  20. package/packages/bundled-features/src/auth-email-password/mail-notifications.ts +158 -0
  21. package/packages/bundled-features/src/auth-email-password/signup-activation.ts +2 -3
  22. package/packages/bundled-features/src/auth-foundation/types.ts +8 -18
  23. package/packages/bundled-features/src/auth-mfa/feature.ts +8 -10
  24. package/packages/bundled-features/src/auth-mfa/hmac-token-codec.ts +4 -4
  25. package/packages/bundled-features/src/auth-mfa/mfa-challenge-token.ts +3 -3
  26. package/packages/bundled-features/src/auth-mfa/mfa-preauth-setup-token.ts +3 -3
  27. package/packages/bundled-features/src/auth-mfa/mfa-setup-token.ts +8 -8
  28. package/packages/bundled-features/src/billing-foundation/changes.json +7 -0
  29. package/packages/bundled-features/src/billing-foundation/constants.ts +5 -4
  30. package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +38 -44
  31. package/packages/bundled-features/src/billing-foundation/consumer-protection/issue-confirmation.ts +3 -3
  32. package/packages/bundled-features/src/billing-foundation/consumer-protection/mail-notifications.ts +115 -0
  33. package/packages/bundled-features/src/billing-foundation/consumer-protection/register-consent.ts +2 -0
  34. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +30 -30
  35. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-notify.ts +2 -2
  36. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-pages.ts +1 -4
  37. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +2 -2
  38. package/packages/bundled-features/src/billing-foundation/get-subscription-for-tenant.ts +2 -2
  39. package/packages/bundled-features/src/billing-foundation/handlers/create-portal-session.write.ts +4 -9
  40. package/packages/bundled-features/src/billing-foundation/handlers/list-subscriptions.query.ts +1 -1
  41. package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +2 -2
  42. package/packages/bundled-features/src/billing-foundation/handlers/process-payment-event.write.ts +1 -1
  43. package/packages/bundled-features/src/billing-foundation/subscription-tier-sync.ts +3 -6
  44. package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +2 -1
  45. package/packages/bundled-features/src/cap-overview/web/cap-usage-cell.tsx +6 -4
  46. package/packages/bundled-features/src/channel-email/changes.json +19 -0
  47. package/packages/bundled-features/src/channel-email/email-channel.ts +122 -29
  48. package/packages/bundled-features/src/channel-email/feature.ts +6 -3
  49. package/packages/bundled-features/src/channel-email/index.ts +7 -1
  50. package/packages/bundled-features/src/channel-email/pii-guard.ts +13 -0
  51. package/packages/bundled-features/src/channel-email/smtp-transport.ts +15 -1
  52. package/packages/bundled-features/src/channel-email/types.ts +23 -0
  53. package/packages/bundled-features/src/channel-in-app/handlers/mark-read.write.ts +2 -2
  54. package/packages/bundled-features/src/config/handlers/set.write.ts +1 -5
  55. package/packages/bundled-features/src/custom-fields/db/queries/user-data-rights.ts +2 -2
  56. package/packages/bundled-features/src/custom-fields/lib/value-schema.ts +10 -11
  57. package/packages/bundled-features/src/custom-fields/wire-user-data-rights.ts +7 -6
  58. package/packages/bundled-features/src/data-retention/changes.json +6 -0
  59. package/packages/bundled-features/src/data-retention/index.ts +1 -0
  60. package/packages/bundled-features/src/data-retention/run-retention-cleanup.ts +8 -6
  61. package/packages/bundled-features/src/delivery/attachment-ref.ts +84 -0
  62. package/packages/bundled-features/src/delivery/changes.json +48 -0
  63. package/packages/bundled-features/src/delivery/channel-context.ts +129 -4
  64. package/packages/bundled-features/src/delivery/constants.ts +2 -0
  65. package/packages/bundled-features/src/delivery/delivery-service.ts +104 -26
  66. package/packages/bundled-features/src/delivery/feature.ts +1 -1
  67. package/packages/bundled-features/src/delivery/index.ts +17 -2
  68. package/packages/bundled-features/src/delivery/jobs.ts +48 -5
  69. package/packages/bundled-features/src/delivery/notify-factory.ts +14 -9
  70. package/packages/bundled-features/src/delivery/testing.ts +9 -1
  71. package/packages/bundled-features/src/delivery/types.ts +28 -18
  72. package/packages/bundled-features/src/delivery/unsubscribe.ts +5 -2
  73. package/packages/bundled-features/src/derivatives-sharp/render.ts +1 -22
  74. package/packages/bundled-features/src/document-ingest-foundation/pages.ts +6 -3
  75. package/packages/bundled-features/src/feature-toggles/toggle-sync-signal.ts +4 -4
  76. package/packages/bundled-features/src/file-foundation/index.ts +7 -0
  77. package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +86 -0
  78. package/packages/bundled-features/src/folders/web/folder-manager.tsx +5 -2
  79. package/packages/bundled-features/src/form-draft/db/queries/cleanup.ts +11 -11
  80. package/packages/bundled-features/src/form-draft/db/queries/draft-count.ts +2 -2
  81. package/packages/bundled-features/src/form-draft/db/queries/owned-file-refs.ts +2 -2
  82. package/packages/bundled-features/src/form-draft/release-file-refs.ts +3 -2
  83. package/packages/bundled-features/src/inbound-mail-foundation/handlers/connect-account.write.ts +1 -1
  84. package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message.write.ts +6 -6
  85. package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-accounts.query.ts +1 -1
  86. package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-messages.query.ts +1 -1
  87. package/packages/bundled-features/src/inbound-mail-foundation/handlers/scope-visibility.ts +2 -2
  88. package/packages/bundled-features/src/inbound-mail-foundation/oauth-state.ts +7 -7
  89. package/packages/bundled-features/src/inbound-mail-foundation/retention-sweep.ts +2 -6
  90. package/packages/bundled-features/src/inbound-mail-foundation/tenant-destroy-hook.ts +5 -13
  91. package/packages/bundled-features/src/inbound-mail-foundation/types.ts +4 -6
  92. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +40 -25
  93. package/packages/bundled-features/src/inbound-provider-imap/imap-client.ts +4 -3
  94. package/packages/bundled-features/src/jobs/handlers/catalog.query.ts +1 -1
  95. package/packages/bundled-features/src/ledger/recurring.ts +1 -3
  96. package/packages/bundled-features/src/managed-pages/branding.ts +33 -9
  97. package/packages/bundled-features/src/managed-pages/changes.json +6 -0
  98. package/packages/bundled-features/src/managed-pages/feature.ts +1 -0
  99. package/packages/bundled-features/src/managed-pages/i18n.ts +1 -0
  100. package/packages/bundled-features/src/managed-pages/screens/branding-screen.ts +8 -0
  101. package/packages/bundled-features/src/page-render/branding.ts +3 -0
  102. package/packages/bundled-features/src/page-render/markdown.ts +1 -1
  103. package/packages/bundled-features/src/personal-access-tokens/feature.ts +2 -4
  104. package/packages/bundled-features/src/personal-access-tokens/handlers/list.query.ts +2 -1
  105. package/packages/bundled-features/src/personal-access-tokens/index.ts +1 -1
  106. package/packages/bundled-features/src/personal-access-tokens/revoke-for-user.ts +2 -1
  107. package/packages/bundled-features/src/renderer-foundation/README.md +11 -10
  108. package/packages/bundled-features/src/renderer-foundation/api.ts +14 -14
  109. package/packages/bundled-features/src/renderer-foundation/changes.json +39 -0
  110. package/packages/bundled-features/src/renderer-foundation/constants.ts +6 -8
  111. package/packages/bundled-features/src/renderer-foundation/feature.ts +7 -7
  112. package/packages/bundled-features/src/renderer-foundation/index.ts +13 -2
  113. package/packages/bundled-features/src/renderer-foundation/types.ts +132 -35
  114. package/packages/bundled-features/src/renderer-pdf/feature.ts +7 -1
  115. package/packages/bundled-features/src/sessions/db/queries/cleanup.ts +2 -2
  116. package/packages/bundled-features/src/sessions/feature.ts +2 -4
  117. package/packages/bundled-features/src/sessions/handlers/list.query.ts +2 -1
  118. package/packages/bundled-features/src/shared/attachment-file-name.ts +39 -0
  119. package/packages/bundled-features/src/shared/index.ts +6 -0
  120. package/packages/bundled-features/src/shared/is-tenant-db.ts +1 -1
  121. package/packages/bundled-features/src/shared/mail-template-body.ts +25 -0
  122. package/packages/bundled-features/src/shared/sharp-input-limits.ts +26 -0
  123. package/packages/bundled-features/src/shared/signup-handover.ts +4 -4
  124. package/packages/bundled-features/src/tags/handlers/assign-tag.write.ts +5 -1
  125. package/packages/bundled-features/src/tags/web/tag-chip.tsx +2 -1
  126. package/packages/bundled-features/src/template-resolver/README.md +63 -7
  127. package/packages/bundled-features/src/template-resolver/api.ts +83 -8
  128. package/packages/bundled-features/src/template-resolver/changes.json +118 -0
  129. package/packages/bundled-features/src/template-resolver/compile-document-body.ts +214 -0
  130. package/packages/bundled-features/src/template-resolver/constants.ts +39 -11
  131. package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +335 -0
  132. package/packages/bundled-features/src/template-resolver/document-schema.ts +323 -0
  133. package/packages/bundled-features/src/template-resolver/feature.ts +56 -6
  134. package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +188 -0
  135. package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +30 -2
  136. package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +142 -0
  137. package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +72 -0
  138. package/packages/bundled-features/src/template-resolver/handlers/export-bundle.query.ts +83 -0
  139. package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +3 -3
  140. package/packages/bundled-features/src/template-resolver/handlers/import-bundle-system.write.ts +42 -0
  141. package/packages/bundled-features/src/template-resolver/handlers/import-bundle.write.ts +22 -0
  142. package/packages/bundled-features/src/template-resolver/handlers/layout-source.query.ts +108 -0
  143. package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +5 -7
  144. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +250 -0
  145. package/packages/bundled-features/src/template-resolver/handlers/preview.query.ts +25 -0
  146. package/packages/bundled-features/src/template-resolver/handlers/reset-to-system.write.ts +58 -0
  147. package/packages/bundled-features/src/template-resolver/handlers/resource-link-pdf.write.ts +34 -0
  148. package/packages/bundled-features/src/template-resolver/handlers/resource-remove.write.ts +50 -0
  149. package/packages/bundled-features/src/template-resolver/handlers/resource-shared.ts +24 -0
  150. package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +135 -0
  151. package/packages/bundled-features/src/template-resolver/handlers/send-test.write.ts +106 -0
  152. package/packages/bundled-features/src/template-resolver/handlers/set.write.ts +32 -3
  153. package/packages/bundled-features/src/template-resolver/handlers/shared.ts +7 -2
  154. package/packages/bundled-features/src/template-resolver/handlers/template-gates.ts +184 -0
  155. package/packages/bundled-features/src/template-resolver/handlers/template-write.ts +183 -0
  156. package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +55 -0
  157. package/packages/bundled-features/src/template-resolver/handlers/toggle-status.write.ts +57 -10
  158. package/packages/bundled-features/src/template-resolver/handlers/upsert-system.write.ts +19 -71
  159. package/packages/bundled-features/src/template-resolver/handlers/upsert-tenant.write.ts +15 -76
  160. package/packages/bundled-features/src/template-resolver/handlers/usage.query.ts +17 -0
  161. package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +116 -0
  162. package/packages/bundled-features/src/template-resolver/index.ts +100 -0
  163. package/packages/bundled-features/src/template-resolver/layout-regions.ts +206 -0
  164. package/packages/bundled-features/src/template-resolver/linked-resources.ts +102 -0
  165. package/packages/bundled-features/src/template-resolver/preview-display-html.ts +36 -0
  166. package/packages/bundled-features/src/template-resolver/print-document.ts +58 -0
  167. package/packages/bundled-features/src/template-resolver/qualified-names.ts +30 -0
  168. package/packages/bundled-features/src/template-resolver/record-mapping.ts +26 -0
  169. package/packages/bundled-features/src/template-resolver/resource-image.ts +47 -0
  170. package/packages/bundled-features/src/template-resolver/seeding.ts +147 -17
  171. package/packages/bundled-features/src/template-resolver/store-template-resource.ts +416 -0
  172. package/packages/bundled-features/src/template-resolver/table.ts +14 -0
  173. package/packages/bundled-features/src/template-resolver/template-bundle.ts +79 -0
  174. package/packages/bundled-features/src/template-resolver/template-diff.ts +78 -0
  175. package/packages/bundled-features/src/template-resolver/template-meta.ts +129 -0
  176. package/packages/bundled-features/src/template-resolver/template-preview.ts +573 -0
  177. package/packages/bundled-features/src/template-resolver/template-resources.ts +74 -0
  178. package/packages/bundled-features/src/template-resolver/template-usage.ts +48 -0
  179. package/packages/bundled-features/src/template-resolver/template-validation.ts +229 -0
  180. package/packages/bundled-features/src/template-resolver/testing.ts +5 -5
  181. package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +9 -6
  182. package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +162 -0
  183. package/packages/bundled-features/src/template-resolver/web/document-editor.tsx +28 -0
  184. package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +850 -0
  185. package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +61 -0
  186. package/packages/bundled-features/src/template-resolver/web/editor-handle.ts +26 -0
  187. package/packages/bundled-features/src/template-resolver/web/i18n.ts +100 -0
  188. package/packages/bundled-features/src/template-resolver/web/index.ts +19 -0
  189. package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor-lazy.tsx +36 -0
  190. package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +169 -0
  191. package/packages/bundled-features/src/template-resolver/web/placeholder-text.ts +37 -0
  192. package/packages/bundled-features/src/template-resolver/web/use-stable-by-key.ts +14 -0
  193. package/packages/bundled-features/src/tenant/handlers/team-list.query.ts +3 -2
  194. package/packages/bundled-features/src/tenant/seeding.ts +41 -7
  195. package/packages/bundled-features/src/tenant-lifecycle/boot-checks.ts +1 -1
  196. package/packages/bundled-features/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +2 -1
  197. package/packages/bundled-features/src/user/db/queries/stream-tenant-backfill.ts +4 -4
  198. package/packages/bundled-features/src/user/handlers/detail.query.ts +3 -5
  199. package/packages/bundled-features/src/user/roles-input-schema.ts +2 -2
  200. package/packages/bundled-features/src/user-data-rights/changes.json +7 -0
  201. package/packages/bundled-features/src/user-data-rights/db/queries/forget-cleanup.ts +2 -2
  202. package/packages/bundled-features/src/user-data-rights/email-templates.ts +5 -149
  203. package/packages/bundled-features/src/user-data-rights/feature.ts +72 -35
  204. package/packages/bundled-features/src/user-data-rights/handlers/cancel-deletion.write.ts +2 -1
  205. package/packages/bundled-features/src/user-data-rights/handlers/request-deletion.write.ts +5 -11
  206. package/packages/bundled-features/src/user-data-rights/lib/default-mailers.ts +134 -53
  207. package/packages/bundled-features/src/user-data-rights/lib/update-user-lifecycle.ts +2 -2
  208. package/packages/bundled-features/src/user-data-rights/mail-notifications.ts +157 -0
  209. package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +1 -1
  210. package/packages/bundled-features/src/user-data-rights/web/privacy-center-screen.tsx +2 -1
  211. package/packages/bundled-features/src/user-profile/web/profile-screen.tsx +2 -1
  212. package/packages/bundled-features/src/waitlist/changes.json +7 -0
  213. package/packages/bundled-features/src/waitlist/feature.ts +17 -0
  214. package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +4 -5
  215. package/packages/bundled-features/src/waitlist/mail.ts +69 -18
  216. package/packages/bundled-features/src/workflow-runner/event-trigger.ts +1 -2
  217. package/samples/apps/ui-walkthrough/package.json +2 -0
  218. package/samples/apps/ui-walkthrough/src/app/auth-constants.ts +9 -0
  219. package/samples/apps/ui-walkthrough/src/app/client.tsx +6 -0
  220. package/samples/apps/ui-walkthrough/src/app/seed-templates.ts +154 -0
  221. package/samples/apps/ui-walkthrough/src/app/server.ts +5 -1
  222. package/samples/apps/ui-walkthrough/src/app/shell.tsx +12 -1
  223. package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +354 -0
  224. package/samples/apps/ui-walkthrough/src/features/billing/feature.ts +68 -0
  225. package/samples/apps/ui-walkthrough/src/features/billing/i18n.ts +18 -0
  226. package/samples/apps/ui-walkthrough/src/features/billing/index.ts +2 -0
  227. package/samples/apps/ui-walkthrough/src/features/billing/web/index.ts +7 -0
  228. package/samples/apps/ui-walkthrough/src/run-config.ts +37 -1
  229. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +0 -2
  230. package/samples/apps/use-all-bundled/feature-manifest.json +115 -20
  231. package/samples/apps/use-all-bundled/kumiko/migrations/0045_template-resource-meta.rebuild.json +4 -0
  232. package/samples/apps/use-all-bundled/kumiko/migrations/0046_pdf-rendered.rebuild.json +4 -0
  233. package/samples/apps/use-all-bundled/package.json +2 -0
  234. package/samples/apps/use-all-bundled/playwright.config.ts +38 -3
  235. package/samples/apps/use-all-bundled/schema/generate.ts +13 -3
  236. package/samples/apps/use-all-bundled/schema/schema.generated.ts +3 -0
  237. package/samples/apps/use-all-bundled/scripts/check-coverage.ts +3 -0
  238. package/samples/apps/use-all-bundled/src/app/auth-constants.ts +3 -0
  239. package/samples/apps/use-all-bundled/src/app/client.tsx +4 -0
  240. package/samples/apps/use-all-bundled/src/app/seed-documents.ts +128 -0
  241. package/samples/apps/use-all-bundled/src/app/seed.ts +4 -0
  242. package/samples/apps/use-all-bundled/src/run-config.ts +11 -11
  243. package/samples/recipes/delivery-notifications/README.md +26 -25
  244. package/samples/recipes/delivery-notifications/package.json +2 -1
  245. package/samples/recipes/delivery-notifications/src/feature.ts +64 -58
  246. package/samples/recipes/seed-migration/README.md +1 -1
  247. package/packages/bundled-features/src/renderer-simple/changes.json +0 -27
  248. package/packages/bundled-features/src/renderer-simple/feature.ts +0 -66
  249. package/packages/bundled-features/src/renderer-simple/index.ts +0 -8
  250. package/packages/bundled-features/src/renderer-simple/mail-blocks.ts +0 -138
  251. package/packages/bundled-features/src/renderer-simple/resolve-variables.ts +0 -57
  252. package/packages/bundled-features/src/renderer-simple/simple-renderer.ts +0 -304
@@ -0,0 +1,206 @@
1
+ // Server-only (happy-dom). A structured layout is the editable frame around a
2
+ // mail or PDF body: header, signature and footer regions plus one empty
3
+ // content slot. Everything else is rejected so a layout can never carry markup
4
+ // the renderer did not plan for.
5
+
6
+ import type { Element as ScanElement } from "happy-dom";
7
+ import type { ContentFormat } from "./constants.js";
8
+ import {
9
+ type DocumentViolation,
10
+ type SanitizeBodyResult,
11
+ sanitizeDocumentHtml,
12
+ sanitizeTemplateBody,
13
+ violation,
14
+ withParsedDocument,
15
+ } from "./document-sanitizer.js";
16
+
17
+ const REGION_ATTRIBUTES = ["data-header", "data-content", "data-signature", "data-footer"] as const;
18
+ type RegionAttribute = (typeof REGION_ATTRIBUTES)[number];
19
+
20
+ const MAIL_LAYOUT_CONTENT_SLOT = /\{\{\{\s*content\s*\}\}\}/g;
21
+
22
+ export type LayoutRegions = {
23
+ readonly header: string;
24
+ readonly signature: string;
25
+ readonly footer: string;
26
+ };
27
+
28
+ export type SanitizeLayoutResult =
29
+ | { readonly ok: true; readonly html: string }
30
+ | { readonly ok: false; readonly violations: readonly DocumentViolation[] };
31
+
32
+ type ParsedRegions = {
33
+ readonly innerHtml: Partial<Record<RegionAttribute, string>>;
34
+ readonly contentIsEmpty: boolean;
35
+ readonly violations: DocumentViolation[];
36
+ };
37
+
38
+ const ELEMENT_NODE = 1;
39
+ const TEXT_NODE = 3;
40
+
41
+ function regionAttributeOf(element: ScanElement): RegionAttribute | null {
42
+ if (element.tagName.toLowerCase() !== "div") return null;
43
+ const present = REGION_ATTRIBUTES.filter((name) => element.hasAttribute(name));
44
+ return present.length === 1 ? (present[0] ?? null) : null;
45
+ }
46
+
47
+ function parseRegions(html: string): ParsedRegions {
48
+ return withParsedDocument(html, (body) => {
49
+ const innerHtml: Partial<Record<RegionAttribute, string>> = {};
50
+ const violations: DocumentViolation[] = [];
51
+ let contentIsEmpty = true;
52
+ const invalid = (detail: string, message: string) =>
53
+ violations.push(violation("layout_invalid_structure", detail, message));
54
+ for (const node of Array.from(body.childNodes)) {
55
+ if (node.nodeType === ELEMENT_NODE) continue;
56
+ if (node.nodeType !== TEXT_NODE || (node.textContent ?? "").trim() !== "") {
57
+ invalid("text", "Text and comments outside the layout regions are not allowed");
58
+ }
59
+ }
60
+ for (const element of Array.from(body.children)) {
61
+ const region = regionAttributeOf(element);
62
+ if (region === null) {
63
+ invalid(
64
+ element.tagName.toLowerCase(),
65
+ "Only <div data-header>, <div data-content>, <div data-signature> and <div data-footer> are allowed at the top level",
66
+ );
67
+ continue;
68
+ }
69
+ if (innerHtml[region] !== undefined) {
70
+ invalid(region, `Layout region ${region} may appear only once`);
71
+ continue;
72
+ }
73
+ innerHtml[region] = element.innerHTML;
74
+ if (region === "data-content") {
75
+ contentIsEmpty = element.children.length === 0 && (element.textContent ?? "").trim() === "";
76
+ }
77
+ }
78
+ return { innerHtml, contentIsEmpty, violations };
79
+ });
80
+ }
81
+
82
+ export function sanitizeLayoutHtml(html: string): SanitizeLayoutResult {
83
+ const parsed = parseRegions(html);
84
+ const violations = [...parsed.violations];
85
+ if (parsed.innerHtml["data-content"] === undefined) {
86
+ violations.push(
87
+ violation(
88
+ "layout_missing_content_slot",
89
+ "data-content",
90
+ "A layout needs exactly one empty <div data-content></div> where the body is placed",
91
+ ),
92
+ );
93
+ } else if (!parsed.contentIsEmpty) {
94
+ violations.push(
95
+ violation(
96
+ "layout_invalid_structure",
97
+ "data-content",
98
+ "The data-content region must be empty",
99
+ ),
100
+ );
101
+ }
102
+ const cleanedHtml: Partial<Record<RegionAttribute, string>> = {};
103
+ for (const name of ["data-header", "data-signature", "data-footer"] as const) {
104
+ const inner = parsed.innerHtml[name];
105
+ if (inner === undefined || inner.trim() === "") continue;
106
+ const result = sanitizeDocumentHtml(inner);
107
+ if (result.ok) cleanedHtml[name] = result.html;
108
+ else violations.push(...result.violations);
109
+ }
110
+ if (violations.length > 0) return { ok: false, violations };
111
+ const part = (name: RegionAttribute) => {
112
+ const regionHtml = cleanedHtml[name];
113
+ return regionHtml === undefined ? "" : `<div ${name}>${regionHtml}</div>`;
114
+ };
115
+ return {
116
+ ok: true,
117
+ html: [
118
+ part("data-header"),
119
+ "<div data-content></div>",
120
+ part("data-signature"),
121
+ part("data-footer"),
122
+ ].join(""),
123
+ };
124
+ }
125
+
126
+ export function readLayoutRegions(html: string): LayoutRegions {
127
+ const { innerHtml } = parseRegions(html);
128
+ return {
129
+ header: innerHtml["data-header"] ?? "",
130
+ signature: innerHtml["data-signature"] ?? "",
131
+ footer: innerHtml["data-footer"] ?? "",
132
+ };
133
+ }
134
+
135
+ export type TemplateWriteScope = "system" | "tenant";
136
+
137
+ function rejected(violationItem: DocumentViolation): SanitizeBodyResult {
138
+ return { ok: false, violations: [violationItem] };
139
+ }
140
+
141
+ function sanitizeMailLayout(
142
+ scope: TemplateWriteScope,
143
+ contentFormat: ContentFormat,
144
+ content: string,
145
+ ): SanitizeBodyResult {
146
+ if (contentFormat === "html") return fromLayoutResult(sanitizeLayoutHtml(content));
147
+ if (contentFormat !== "mjml") {
148
+ return rejected(
149
+ violation(
150
+ "layout_requires_mjml_format",
151
+ contentFormat,
152
+ `Templates of kind mail-layout require contentFormat mjml or html, got "${contentFormat}"`,
153
+ ),
154
+ );
155
+ }
156
+ if (scope !== "system") {
157
+ return rejected(
158
+ violation(
159
+ "layout_source_system_only",
160
+ "mjml",
161
+ "Only system layouts may use mjml source; tenant layouts use the structured html format",
162
+ ),
163
+ );
164
+ }
165
+ if ((content.match(MAIL_LAYOUT_CONTENT_SLOT) ?? []).length !== 1) {
166
+ return rejected(
167
+ violation(
168
+ "layout_missing_content_slot",
169
+ "{{{content}}}",
170
+ "A mjml layout must contain {{{content}}} exactly once",
171
+ ),
172
+ );
173
+ }
174
+ return { ok: true, content };
175
+ }
176
+
177
+ function fromLayoutResult(result: SanitizeLayoutResult): SanitizeBodyResult {
178
+ return result.ok ? { ok: true, content: result.html } : result;
179
+ }
180
+
181
+ // One entry for every write path: layout kinds follow the scope-dependent
182
+ // layout rules, everything else the plain document sanitizer.
183
+ export function sanitizeTemplateWriteBody(input: {
184
+ readonly kind: string;
185
+ readonly scope: TemplateWriteScope;
186
+ readonly contentFormat: ContentFormat;
187
+ readonly content: string | null;
188
+ }): SanitizeBodyResult {
189
+ const { kind, scope, contentFormat, content } = input;
190
+ if (kind === "mail-layout" && content !== null) {
191
+ return sanitizeMailLayout(scope, contentFormat, content);
192
+ }
193
+ if (kind === "pdf-layout" && content !== null) {
194
+ if (contentFormat !== "html") {
195
+ return rejected(
196
+ violation(
197
+ "document_requires_html_format",
198
+ contentFormat,
199
+ `Templates of kind pdf-layout require contentFormat html, got "${contentFormat}"`,
200
+ ),
201
+ );
202
+ }
203
+ return fromLayoutResult(sanitizeLayoutHtml(content));
204
+ }
205
+ return sanitizeTemplateBody({ kind, contentFormat, content });
206
+ }
@@ -0,0 +1,102 @@
1
+ import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
2
+ import * as z from "zod";
3
+ import type { FileAttachmentRef } from "../renderer-foundation/index.js";
4
+ import { pdfAttachmentFileNamePatternSchema, templateSlugSchema } from "./template-meta.js";
5
+
6
+ export const templatePdfAttachmentLinkSchema = z
7
+ .object({
8
+ kind: z.literal("rendered-pdf"),
9
+ templateSlug: templateSlugSchema,
10
+ fileName: pdfAttachmentFileNamePatternSchema,
11
+ })
12
+ .strict();
13
+
14
+ // A PDF the mail renderer asks for at send time: the document template is rendered with the
15
+ // mail's variables, so nothing is stored. `fileName` is a pattern that may hold {{placeholders}}.
16
+ export type TemplatePdfAttachmentLink = z.infer<typeof templatePdfAttachmentLinkSchema>;
17
+
18
+ // A string is an image the body embeds by key (the fileRef id); an object is a file attached to
19
+ // the mail as-is or a PDF rendered from a document template. Images are the only thing a document
20
+ // body can reference.
21
+ export type LinkedResourceEntry = string | FileAttachmentRef | TemplatePdfAttachmentLink;
22
+
23
+ export function isFileAttachmentEntry(entry: LinkedResourceEntry): entry is FileAttachmentRef {
24
+ return typeof entry !== "string" && entry.kind === "file";
25
+ }
26
+
27
+ export function isPdfAttachmentLink(
28
+ entry: LinkedResourceEntry,
29
+ ): entry is TemplatePdfAttachmentLink {
30
+ return typeof entry !== "string" && entry.kind === "rendered-pdf";
31
+ }
32
+
33
+ function toLinkedResourceEntry(raw: unknown): LinkedResourceEntry | null {
34
+ if (typeof raw === "string") return raw;
35
+ if (!isPlainObject(raw)) return null;
36
+ if (raw["kind"] === "rendered-pdf") {
37
+ const link = templatePdfAttachmentLinkSchema.safeParse(raw);
38
+ return link.success ? link.data : null;
39
+ }
40
+ const { fileRefId, fileName } = raw;
41
+ if (raw["kind"] !== "file" || typeof fileRefId !== "string" || typeof fileName !== "string") {
42
+ return null;
43
+ }
44
+ return { kind: "file", fileRefId, fileName };
45
+ }
46
+
47
+ export function parseLinkedResourceEntries(
48
+ raw: string | null,
49
+ ): Record<string, LinkedResourceEntry> {
50
+ if (!raw) return {};
51
+ let parsed: unknown;
52
+ try {
53
+ parsed = JSON.parse(raw);
54
+ } catch {
55
+ return {};
56
+ }
57
+ if (!isPlainObject(parsed)) return {};
58
+ const entries: Record<string, LinkedResourceEntry> = {};
59
+ for (const [key, value] of Object.entries(parsed)) {
60
+ const entry = toLinkedResourceEntry(value);
61
+ if (entry !== null) entries[key] = entry;
62
+ }
63
+ return entries;
64
+ }
65
+
66
+ export function imageLinksOf(
67
+ entries: Readonly<Record<string, LinkedResourceEntry>>,
68
+ ): Record<string, string> {
69
+ const images: Record<string, string> = {};
70
+ for (const [key, entry] of Object.entries(entries)) {
71
+ if (typeof entry === "string") images[key] = entry;
72
+ }
73
+ return images;
74
+ }
75
+
76
+ export function fileAttachmentsOf(
77
+ entries: Readonly<Record<string, LinkedResourceEntry>>,
78
+ ): FileAttachmentRef[] {
79
+ return Object.entries(entries)
80
+ .sort(([a], [b]) => a.localeCompare(b))
81
+ .flatMap(([, entry]) => (isFileAttachmentEntry(entry) ? [entry] : []));
82
+ }
83
+
84
+ export function pdfAttachmentLinksOf(
85
+ entries: Readonly<Record<string, LinkedResourceEntry>>,
86
+ ): TemplatePdfAttachmentLink[] {
87
+ return Object.entries(entries)
88
+ .sort(([a], [b]) => a.localeCompare(b))
89
+ .flatMap(([, entry]) => (isPdfAttachmentLink(entry) ? [entry] : []));
90
+ }
91
+
92
+ export function attachmentEntriesOf(
93
+ entries: Readonly<Record<string, LinkedResourceEntry>>,
94
+ ): [string, FileAttachmentRef | TemplatePdfAttachmentLink][] {
95
+ return Object.entries(entries).flatMap(([key, entry]) =>
96
+ typeof entry === "string" ? [] : [[key, entry]],
97
+ );
98
+ }
99
+
100
+ export function imageResourceKeysOf(raw: string | null): string[] {
101
+ return Object.keys(imageLinksOf(parseLinkedResourceEntries(raw)));
102
+ }
@@ -0,0 +1,36 @@
1
+ import type { InlineAttachmentRef } from "../renderer-foundation/index.js";
2
+
3
+ export const PREVIEW_IMAGE_BUDGET_BYTES = 5 * 1024 * 1024;
4
+
5
+ const DISPLAYABLE_IMAGE_TYPES: ReadonlySet<string> = new Set([
6
+ "image/png",
7
+ "image/jpeg",
8
+ "image/gif",
9
+ "image/webp",
10
+ ]);
11
+
12
+ export type PreviewAttachmentReader = (
13
+ ref: InlineAttachmentRef,
14
+ ) => Promise<{ readonly content: Uint8Array; readonly contentType: string } | null>;
15
+
16
+ // The preview shows images the mail client would resolve by Content-ID; a browser cannot, so each
17
+ // resolved image becomes a data URI. `html` itself stays untouched because the send path needs the cids.
18
+ export async function buildDisplayHtml(
19
+ html: string,
20
+ inline: readonly InlineAttachmentRef[],
21
+ readAttachment: PreviewAttachmentReader,
22
+ ): Promise<string> {
23
+ let displayHtml = html;
24
+ let usedBytes = 0;
25
+ for (const ref of inline) {
26
+ const needle = `src="cid:${ref.cid}"`;
27
+ if (!displayHtml.includes(needle)) continue;
28
+ const found = await readAttachment(ref);
29
+ if (found === null || !DISPLAYABLE_IMAGE_TYPES.has(found.contentType)) continue;
30
+ if (usedBytes + found.content.length > PREVIEW_IMAGE_BUDGET_BYTES) continue;
31
+ usedBytes += found.content.length;
32
+ const dataUri = `src="data:${found.contentType};base64,${Buffer.from(found.content).toString("base64")}"`;
33
+ displayHtml = displayHtml.replaceAll(needle, () => dataUri);
34
+ }
35
+ return displayHtml;
36
+ }
@@ -0,0 +1,58 @@
1
+ // Server-only (happy-dom). Prepares a rendered document body for the browser
2
+ // that prints it: repeating table headers become real <thead> rows, and
3
+ // everything that would make the browser fetch something external is removed.
4
+
5
+ import type { Element as ScanElement } from "happy-dom";
6
+ import { withParsedDocument } from "./document-sanitizer.js";
7
+
8
+ const REPEAT_HEADER_ATTRIBUTE = "data-repeat-header";
9
+ const FETCHING_ELEMENTS =
10
+ "script, link, iframe, object, embed, video, audio, source, meta, base, style";
11
+ const FETCHING_ATTRIBUTES = ["src", "srcset", "poster", "background", "data"] as const;
12
+ // Anchors only navigate; on svg image/use/feImage an href fetches.
13
+ const HREF_ATTRIBUTES = ["href", "xlink:href"] as const;
14
+ const EXTERNAL_STYLE_REFERENCE = /url\(|@import/i;
15
+
16
+ function isEmbeddedValue(value: string): boolean {
17
+ return value.trim().toLowerCase().startsWith("data:");
18
+ }
19
+
20
+ function moveRepeatHeadersIntoThead(table: ScanElement): void {
21
+ const rows = Array.from(table.querySelectorAll("tr")).filter(
22
+ (row) => row.closest("table") === table,
23
+ );
24
+ const leading: ScanElement[] = [];
25
+ for (const row of rows) {
26
+ if (!row.hasAttribute(REPEAT_HEADER_ATTRIBUTE)) break;
27
+ leading.push(row);
28
+ }
29
+ // skip: a table without leading repeat-header rows keeps its structure as written
30
+ if (leading.length === 0) return;
31
+ const thead = table.ownerDocument.createElement("thead");
32
+ for (const row of leading) {
33
+ row.removeAttribute(REPEAT_HEADER_ATTRIBUTE);
34
+ thead.appendChild(row);
35
+ }
36
+ table.insertBefore(thead, table.firstChild);
37
+ }
38
+
39
+ export function prepareDocumentForPrint(html: string): string {
40
+ return withParsedDocument(html, (body) => {
41
+ for (const table of Array.from(body.querySelectorAll("table"))) {
42
+ moveRepeatHeadersIntoThead(table);
43
+ }
44
+ for (const element of Array.from(body.querySelectorAll(FETCHING_ELEMENTS))) element.remove();
45
+ for (const element of Array.from(body.querySelectorAll("*"))) {
46
+ for (const name of FETCHING_ATTRIBUTES) {
47
+ const value = element.getAttribute(name);
48
+ if (value !== null && !isEmbeddedValue(value)) element.removeAttribute(name);
49
+ }
50
+ if (element.tagName.toLowerCase() !== "a") {
51
+ for (const name of HREF_ATTRIBUTES) element.removeAttribute(name);
52
+ }
53
+ const style = element.getAttribute("style");
54
+ if (style !== null && EXTERNAL_STYLE_REFERENCE.test(style)) element.removeAttribute("style");
55
+ }
56
+ return body.innerHTML;
57
+ });
58
+ }
@@ -7,12 +7,30 @@ export const TemplateResolverHandlers = {
7
7
  upsertTenant: "template-resolver:write:upsert-tenant",
8
8
  publish: "template-resolver:write:publish",
9
9
  archive: "template-resolver:write:archive",
10
+ publishSystem: "template-resolver:write:publish-system",
11
+ archiveSystem: "template-resolver:write:archive-system",
12
+ resetToSystem: "template-resolver:write:reset-to-system",
10
13
  set: "template-resolver:write:set",
14
+ importBundle: "template-resolver:write:import-bundle",
15
+ importBundleSystem: "template-resolver:write:import-bundle-system",
16
+ copyLocale: "template-resolver:write:copy-locale",
17
+ resourceUpload: "template-resolver:write:resource-upload",
18
+ resourceRemove: "template-resolver:write:resource-remove",
19
+ resourceLinkPdf: "template-resolver:write:resource-link-pdf",
20
+ sendTest: "template-resolver:write:send-test",
11
21
  } as const;
12
22
 
13
23
  export const TemplateResolverQueries = {
14
24
  findById: "template-resolver:query:find-by-id",
15
25
  list: "template-resolver:query:list",
26
+ usage: "template-resolver:query:usage",
27
+ overview: "template-resolver:query:overview",
28
+ layoutSource: "template-resolver:query:layout-source",
29
+ tenantOverrides: "template-resolver:query:tenant-overrides",
30
+ diffToSystem: "template-resolver:query:diff-to-system",
31
+ validate: "template-resolver:query:validate",
32
+ preview: "template-resolver:query:preview",
33
+ exportBundle: "template-resolver:query:export-bundle",
16
34
  // Public pair — anonymous-reachable, kind pinned to text-block.
17
35
  bySlug: "template-resolver:query:by-slug",
18
36
  byTenant: "template-resolver:query:by-tenant",
@@ -37,4 +55,16 @@ export const TemplateResolverErrors = {
37
55
  systemAdminRequired: "system_admin_required",
38
56
  alreadyActive: "template_already_active",
39
57
  alreadyArchived: "template_already_archived",
58
+ targetExists: "target_exists",
59
+ systemResourcesNeedSystemTenant: "system_resources_need_system_tenant",
60
+ resourceTooLarge: "template_resource_too_large",
61
+ resourceUnsupportedImage: "template_resource_unsupported_image",
62
+ attachmentUnsupportedType: "template_attachment_unsupported_type",
63
+ attachmentLimit: "template_attachment_limit",
64
+ attachmentInvalid: "template_attachment_invalid",
65
+ attachmentPdfTemplateMissing: "template_attachment_pdf_template_missing",
66
+ previewRenderFailed: "template_preview_render_failed",
67
+ testMailNoChannel: "template_test_mail_no_channel",
68
+ testMailNoAddress: "template_test_mail_no_address",
69
+ testMailFailed: "template_test_mail_failed",
40
70
  } as const;
@@ -0,0 +1,26 @@
1
+ import type { NotificationRecordMapping } from "@cosmicdrift/kumiko-framework/engine";
2
+
3
+ function readPath(row: Readonly<Record<string, unknown>>, path: string): unknown {
4
+ let current: unknown = row;
5
+ for (const segment of path.split(".")) {
6
+ if (typeof current !== "object" || current === null || !Object.hasOwn(current, segment)) {
7
+ return undefined;
8
+ }
9
+ current = Object.getOwnPropertyDescriptor(current, segment)?.value;
10
+ }
11
+ return current;
12
+ }
13
+
14
+ // Variables whose path is missing on the row are left out, so the template
15
+ // renders them empty instead of printing "undefined".
16
+ export function mapRecordToVariables(
17
+ mapping: NotificationRecordMapping,
18
+ row: Readonly<Record<string, unknown>>,
19
+ ): Record<string, unknown> {
20
+ const variables: Record<string, unknown> = {};
21
+ for (const [variable, path] of Object.entries(mapping.map)) {
22
+ const value = readPath(row, path);
23
+ if (value !== undefined) variables[variable] = value;
24
+ }
25
+ return variables;
26
+ }
@@ -0,0 +1,47 @@
1
+ import { assertNotSvg, SHARP_INPUT_OPTIONS } from "../shared/index.js";
2
+
3
+ const MAX_RESOURCE_WIDTH = 1200;
4
+
5
+ const CONTENT_TYPE_BY_FORMAT = {
6
+ png: "image/png",
7
+ jpeg: "image/jpeg",
8
+ gif: "image/gif",
9
+ webp: "image/webp",
10
+ } as const;
11
+
12
+ type ResourceImageFormat = keyof typeof CONTENT_TYPE_BY_FORMAT;
13
+
14
+ export type NormalizedResourceImage = {
15
+ readonly data: Uint8Array;
16
+ readonly contentType: (typeof CONTENT_TYPE_BY_FORMAT)[ResourceImageFormat];
17
+ readonly width: number;
18
+ readonly height: number;
19
+ };
20
+
21
+ function isResourceImageFormat(format: string | undefined): format is ResourceImageFormat {
22
+ return format !== undefined && Object.hasOwn(CONTENT_TYPE_BY_FORMAT, format);
23
+ }
24
+
25
+ // Re-encodes an uploaded image: the stored content type comes from the decoded format, never
26
+ // from the client, and re-encoding drops metadata and anything appended to the file.
27
+ // sharp is imported lazily because every app loads template-resolver, few of them need an image decoder.
28
+ export async function normalizeResourceImage(data: Uint8Array): Promise<NormalizedResourceImage> {
29
+ const { default: sharp } = await import("sharp");
30
+ const metadata = await sharp(data, SHARP_INPUT_OPTIONS).metadata();
31
+ assertNotSvg(metadata.format);
32
+ if (!isResourceImageFormat(metadata.format)) {
33
+ throw new Error(`unsupported image format "${metadata.format ?? "unknown"}"`);
34
+ }
35
+ const format = metadata.format;
36
+ const pipeline = sharp(data, SHARP_INPUT_OPTIONS)
37
+ .rotate()
38
+ .resize({ width: MAX_RESOURCE_WIDTH, withoutEnlargement: true })
39
+ .toFormat(format);
40
+ const { data: output, info } = await pipeline.toBuffer({ resolveWithObject: true });
41
+ return {
42
+ data: new Uint8Array(output),
43
+ contentType: CONTENT_TYPE_BY_FORMAT[format],
44
+ width: info.width,
45
+ height: info.height,
46
+ };
47
+ }