@cosmicdrift/kumiko-samples 0.362.0 → 0.364.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (321) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +23 -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/audit/changes.json +6 -0
  10. package/packages/bundled-features/src/audit/handlers/list.query.ts +10 -5
  11. package/packages/bundled-features/src/auth-email-password/bootstrap.ts +29 -8
  12. package/packages/bundled-features/src/auth-email-password/changes.json +51 -0
  13. package/packages/bundled-features/src/auth-email-password/email-rate-limits.ts +16 -0
  14. package/packages/bundled-features/src/auth-email-password/email-templates.ts +12 -138
  15. package/packages/bundled-features/src/auth-email-password/feature.ts +30 -9
  16. package/packages/bundled-features/src/auth-email-password/handlers/change-password.write.ts +6 -2
  17. package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +4 -5
  18. package/packages/bundled-features/src/auth-email-password/handlers/logout.write.ts +7 -2
  19. package/packages/bundled-features/src/auth-email-password/handlers/request-account-unlock.write.ts +2 -5
  20. package/packages/bundled-features/src/auth-email-password/handlers/request-email-verification.write.ts +2 -5
  21. package/packages/bundled-features/src/auth-email-password/handlers/request-password-reset.write.ts +2 -5
  22. package/packages/bundled-features/src/auth-email-password/handlers/signup-confirm.write.ts +3 -4
  23. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +2 -1
  24. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +5 -13
  25. package/packages/bundled-features/src/auth-email-password/index.ts +2 -17
  26. package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +30 -22
  27. package/packages/bundled-features/src/auth-email-password/mail-notifications.ts +158 -0
  28. package/packages/bundled-features/src/auth-email-password/signup-activation.ts +2 -3
  29. package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +24 -5
  30. package/packages/bundled-features/src/auth-foundation/types.ts +8 -18
  31. package/packages/bundled-features/src/auth-mfa/feature.ts +8 -10
  32. package/packages/bundled-features/src/auth-mfa/hmac-token-codec.ts +4 -4
  33. package/packages/bundled-features/src/auth-mfa/mfa-challenge-token.ts +3 -3
  34. package/packages/bundled-features/src/auth-mfa/mfa-preauth-setup-token.ts +3 -3
  35. package/packages/bundled-features/src/auth-mfa/mfa-setup-token.ts +8 -8
  36. package/packages/bundled-features/src/billing-foundation/changes.json +43 -0
  37. package/packages/bundled-features/src/billing-foundation/constants.ts +5 -4
  38. package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +90 -44
  39. package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +22 -1
  40. package/packages/bundled-features/src/billing-foundation/consumer-protection/issue-confirmation.ts +3 -3
  41. package/packages/bundled-features/src/billing-foundation/consumer-protection/mail-notifications.ts +118 -0
  42. package/packages/bundled-features/src/billing-foundation/consumer-protection/register-consent.ts +2 -0
  43. package/packages/bundled-features/src/billing-foundation/consumer-protection/register-termination.ts +11 -1
  44. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +39 -33
  45. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-notify.ts +2 -2
  46. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-pages.ts +1 -4
  47. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-receipt-limit.ts +41 -0
  48. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +10 -1
  49. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +36 -20
  50. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +9 -0
  51. package/packages/bundled-features/src/billing-foundation/consumer-protection/withdrawal-period.ts +35 -0
  52. package/packages/bundled-features/src/billing-foundation/get-subscription-for-tenant.ts +2 -2
  53. package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +11 -3
  54. package/packages/bundled-features/src/billing-foundation/handlers/create-portal-session.write.ts +4 -9
  55. package/packages/bundled-features/src/billing-foundation/handlers/list-subscriptions.query.ts +1 -1
  56. package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +12 -6
  57. package/packages/bundled-features/src/billing-foundation/handlers/process-payment-event.write.ts +1 -1
  58. package/packages/bundled-features/src/billing-foundation/i18n.ts +3 -0
  59. package/packages/bundled-features/src/billing-foundation/subscription-tier-sync.ts +20 -11
  60. package/packages/bundled-features/src/billing-foundation/types.ts +7 -2
  61. package/packages/bundled-features/src/cap-counter/calendar-cap-usage-reservation.ts +2 -0
  62. package/packages/bundled-features/src/cap-counter/changes.json +6 -0
  63. package/packages/bundled-features/src/cap-counter/enforce-cap.ts +75 -5
  64. package/packages/bundled-features/src/cap-counter/index.ts +1 -0
  65. package/packages/bundled-features/src/cap-counter/with-cap-enforcement.ts +3 -0
  66. package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +2 -1
  67. package/packages/bundled-features/src/cap-overview/web/cap-usage-cell.tsx +6 -4
  68. package/packages/bundled-features/src/channel-email/changes.json +19 -0
  69. package/packages/bundled-features/src/channel-email/email-channel.ts +135 -29
  70. package/packages/bundled-features/src/channel-email/feature.ts +6 -3
  71. package/packages/bundled-features/src/channel-email/index.ts +7 -1
  72. package/packages/bundled-features/src/channel-email/pii-guard.ts +13 -0
  73. package/packages/bundled-features/src/channel-email/smtp-transport.ts +15 -1
  74. package/packages/bundled-features/src/channel-email/types.ts +23 -0
  75. package/packages/bundled-features/src/channel-in-app/handlers/mark-read.write.ts +2 -2
  76. package/packages/bundled-features/src/config/changes.json +6 -0
  77. package/packages/bundled-features/src/config/handlers/set.write.ts +3 -7
  78. package/packages/bundled-features/src/config/resolver.ts +15 -12
  79. package/packages/bundled-features/src/config/write-helpers.ts +23 -0
  80. package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
  81. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +48 -27
  82. package/packages/bundled-features/src/custom-fields/changes.json +6 -0
  83. package/packages/bundled-features/src/custom-fields/db/queries/user-data-rights.ts +2 -2
  84. package/packages/bundled-features/src/custom-fields/handlers/update-tenant-field.write.ts +0 -1
  85. package/packages/bundled-features/src/custom-fields/lib/value-schema.ts +10 -11
  86. package/packages/bundled-features/src/custom-fields/wire-user-data-rights.ts +7 -6
  87. package/packages/bundled-features/src/data-retention/changes.json +6 -0
  88. package/packages/bundled-features/src/data-retention/index.ts +1 -0
  89. package/packages/bundled-features/src/data-retention/run-retention-cleanup.ts +8 -6
  90. package/packages/bundled-features/src/delivery/attachment-ref.ts +105 -0
  91. package/packages/bundled-features/src/delivery/changes.json +66 -0
  92. package/packages/bundled-features/src/delivery/channel-context.ts +152 -4
  93. package/packages/bundled-features/src/delivery/constants.ts +2 -0
  94. package/packages/bundled-features/src/delivery/delivery-service.ts +104 -26
  95. package/packages/bundled-features/src/delivery/feature.ts +1 -1
  96. package/packages/bundled-features/src/delivery/index.ts +19 -2
  97. package/packages/bundled-features/src/delivery/jobs.ts +97 -17
  98. package/packages/bundled-features/src/delivery/notify-factory.ts +14 -9
  99. package/packages/bundled-features/src/delivery/testing.ts +9 -1
  100. package/packages/bundled-features/src/delivery/types.ts +34 -18
  101. package/packages/bundled-features/src/delivery/unsubscribe.ts +5 -2
  102. package/packages/bundled-features/src/derivatives-sharp/render.ts +1 -22
  103. package/packages/bundled-features/src/document-ingest-foundation/pages.ts +6 -3
  104. package/packages/bundled-features/src/feature-toggles/toggle-sync-signal.ts +4 -4
  105. package/packages/bundled-features/src/file-derivatives/handlers/public-variant.query.ts +1 -1
  106. package/packages/bundled-features/src/file-foundation/changes.json +6 -0
  107. package/packages/bundled-features/src/file-foundation/index.ts +7 -0
  108. package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +89 -0
  109. package/packages/bundled-features/src/files-tenant-data/changes.json +6 -0
  110. package/packages/bundled-features/src/files-tenant-data/hooks.ts +4 -0
  111. package/packages/bundled-features/src/folders/web/folder-manager.tsx +5 -2
  112. package/packages/bundled-features/src/form-draft/db/queries/cleanup.ts +11 -11
  113. package/packages/bundled-features/src/form-draft/db/queries/draft-count.ts +2 -2
  114. package/packages/bundled-features/src/form-draft/db/queries/owned-file-refs.ts +2 -2
  115. package/packages/bundled-features/src/form-draft/release-file-refs.ts +3 -2
  116. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +6 -0
  117. package/packages/bundled-features/src/inbound-mail-foundation/handlers/connect-account.write.ts +1 -1
  118. package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message.write.ts +6 -6
  119. package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-accounts.query.ts +1 -1
  120. package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-messages.query.ts +1 -1
  121. package/packages/bundled-features/src/inbound-mail-foundation/handlers/scope-visibility.ts +2 -2
  122. package/packages/bundled-features/src/inbound-mail-foundation/oauth-access-token.ts +13 -8
  123. package/packages/bundled-features/src/inbound-mail-foundation/oauth-state.ts +7 -7
  124. package/packages/bundled-features/src/inbound-mail-foundation/retention-sweep.ts +2 -6
  125. package/packages/bundled-features/src/inbound-mail-foundation/tenant-destroy-hook.ts +5 -13
  126. package/packages/bundled-features/src/inbound-mail-foundation/types.ts +4 -6
  127. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +42 -25
  128. package/packages/bundled-features/src/inbound-provider-imap/imap-client.ts +4 -3
  129. package/packages/bundled-features/src/jobs/changes.json +19 -0
  130. package/packages/bundled-features/src/jobs/db/queries/stale-run-sweep.ts +12 -4
  131. package/packages/bundled-features/src/jobs/feature.ts +7 -4
  132. package/packages/bundled-features/src/jobs/handlers/catalog.query.ts +1 -1
  133. package/packages/bundled-features/src/jobs/handlers/list.query.ts +3 -1
  134. package/packages/bundled-features/src/jobs/handlers/stale-run-sweep.job.ts +5 -2
  135. package/packages/bundled-features/src/jobs/job-run-logger.ts +52 -17
  136. package/packages/bundled-features/src/jobs/tenant-job-run-table.ts +7 -2
  137. package/packages/bundled-features/src/ledger/changes.json +7 -0
  138. package/packages/bundled-features/src/ledger/entity.ts +3 -1
  139. package/packages/bundled-features/src/ledger/recurring.ts +1 -3
  140. package/packages/bundled-features/src/managed-pages/branding.ts +33 -9
  141. package/packages/bundled-features/src/managed-pages/changes.json +6 -0
  142. package/packages/bundled-features/src/managed-pages/feature.ts +1 -0
  143. package/packages/bundled-features/src/managed-pages/i18n.ts +1 -0
  144. package/packages/bundled-features/src/managed-pages/screens/branding-screen.ts +8 -0
  145. package/packages/bundled-features/src/page-render/branding.ts +3 -0
  146. package/packages/bundled-features/src/page-render/markdown.ts +1 -1
  147. package/packages/bundled-features/src/personal-access-tokens/feature.ts +2 -4
  148. package/packages/bundled-features/src/personal-access-tokens/handlers/list.query.ts +2 -1
  149. package/packages/bundled-features/src/personal-access-tokens/index.ts +1 -1
  150. package/packages/bundled-features/src/personal-access-tokens/revoke-for-user.ts +2 -1
  151. package/packages/bundled-features/src/renderer-foundation/README.md +11 -10
  152. package/packages/bundled-features/src/renderer-foundation/api.ts +14 -14
  153. package/packages/bundled-features/src/renderer-foundation/changes.json +39 -0
  154. package/packages/bundled-features/src/renderer-foundation/constants.ts +6 -8
  155. package/packages/bundled-features/src/renderer-foundation/feature.ts +7 -7
  156. package/packages/bundled-features/src/renderer-foundation/index.ts +13 -2
  157. package/packages/bundled-features/src/renderer-foundation/types.ts +134 -35
  158. package/packages/bundled-features/src/renderer-pdf/changes.json +12 -0
  159. package/packages/bundled-features/src/renderer-pdf/feature.ts +7 -1
  160. package/packages/bundled-features/src/renderer-pdf/render-table-pdf.ts +162 -82
  161. package/packages/bundled-features/src/secrets/changes.json +6 -0
  162. package/packages/bundled-features/src/secrets/feature.ts +4 -3
  163. package/packages/bundled-features/src/secrets/secrets-context.ts +23 -3
  164. package/packages/bundled-features/src/sessions/db/queries/cleanup.ts +2 -2
  165. package/packages/bundled-features/src/sessions/feature.ts +2 -4
  166. package/packages/bundled-features/src/sessions/handlers/list.query.ts +2 -1
  167. package/packages/bundled-features/src/shared/attachment-file-name.ts +39 -0
  168. package/packages/bundled-features/src/shared/changes.json +6 -0
  169. package/packages/bundled-features/src/shared/chunked-entity-migration.ts +4 -1
  170. package/packages/bundled-features/src/shared/index.ts +6 -0
  171. package/packages/bundled-features/src/shared/is-tenant-db.ts +1 -1
  172. package/packages/bundled-features/src/shared/mail-template-body.ts +42 -0
  173. package/packages/bundled-features/src/shared/sharp-input-limits.ts +26 -0
  174. package/packages/bundled-features/src/shared/signup-handover.ts +4 -4
  175. package/packages/bundled-features/src/subscription-stripe/changes.json +6 -0
  176. package/packages/bundled-features/src/subscription-stripe/plugin-methods.ts +19 -11
  177. package/packages/bundled-features/src/subscription-stripe/verify-webhook.ts +3 -1
  178. package/packages/bundled-features/src/tags/handlers/assign-tag.write.ts +5 -1
  179. package/packages/bundled-features/src/tags/web/tag-chip.tsx +2 -1
  180. package/packages/bundled-features/src/template-resolver/README.md +66 -8
  181. package/packages/bundled-features/src/template-resolver/api.ts +85 -9
  182. package/packages/bundled-features/src/template-resolver/changes.json +186 -0
  183. package/packages/bundled-features/src/template-resolver/compile-document-body.ts +278 -0
  184. package/packages/bundled-features/src/template-resolver/constants.ts +49 -11
  185. package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +335 -0
  186. package/packages/bundled-features/src/template-resolver/document-schema.ts +345 -0
  187. package/packages/bundled-features/src/template-resolver/feature.ts +59 -6
  188. package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +188 -0
  189. package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +30 -2
  190. package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +160 -0
  191. package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +33 -0
  192. package/packages/bundled-features/src/template-resolver/handlers/discard-draft.write.ts +52 -0
  193. package/packages/bundled-features/src/template-resolver/handlers/export-bundle.query.ts +138 -0
  194. package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +9 -6
  195. package/packages/bundled-features/src/template-resolver/handlers/import-bundle-system.write.ts +42 -0
  196. package/packages/bundled-features/src/template-resolver/handlers/import-bundle.write.ts +22 -0
  197. package/packages/bundled-features/src/template-resolver/handlers/layout-source.query.ts +151 -0
  198. package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +19 -18
  199. package/packages/bundled-features/src/template-resolver/handlers/override-diff.ts +49 -0
  200. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +323 -0
  201. package/packages/bundled-features/src/template-resolver/handlers/preview.query.ts +25 -0
  202. package/packages/bundled-features/src/template-resolver/handlers/reset-to-system.write.ts +58 -0
  203. package/packages/bundled-features/src/template-resolver/handlers/resource-link-pdf.write.ts +34 -0
  204. package/packages/bundled-features/src/template-resolver/handlers/resource-remove.write.ts +50 -0
  205. package/packages/bundled-features/src/template-resolver/handlers/resource-shared.ts +24 -0
  206. package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +135 -0
  207. package/packages/bundled-features/src/template-resolver/handlers/send-test.write.ts +135 -0
  208. package/packages/bundled-features/src/template-resolver/handlers/set.write.ts +32 -3
  209. package/packages/bundled-features/src/template-resolver/handlers/shared.ts +7 -2
  210. package/packages/bundled-features/src/template-resolver/handlers/template-gates.ts +196 -0
  211. package/packages/bundled-features/src/template-resolver/handlers/template-write.ts +233 -0
  212. package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +137 -0
  213. package/packages/bundled-features/src/template-resolver/handlers/toggle-status.write.ts +91 -17
  214. package/packages/bundled-features/src/template-resolver/handlers/upsert-system.write.ts +19 -71
  215. package/packages/bundled-features/src/template-resolver/handlers/upsert-tenant.write.ts +16 -77
  216. package/packages/bundled-features/src/template-resolver/handlers/usage.query.ts +31 -0
  217. package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +116 -0
  218. package/packages/bundled-features/src/template-resolver/index.ts +111 -0
  219. package/packages/bundled-features/src/template-resolver/layout-appearance.ts +40 -0
  220. package/packages/bundled-features/src/template-resolver/layout-regions.ts +206 -0
  221. package/packages/bundled-features/src/template-resolver/linked-resources.ts +102 -0
  222. package/packages/bundled-features/src/template-resolver/pdf-constants.ts +12 -0
  223. package/packages/bundled-features/src/template-resolver/preview-display-html.ts +36 -0
  224. package/packages/bundled-features/src/template-resolver/print-document.ts +58 -0
  225. package/packages/bundled-features/src/template-resolver/qualified-names.ts +34 -0
  226. package/packages/bundled-features/src/template-resolver/record-mapping.ts +26 -0
  227. package/packages/bundled-features/src/template-resolver/resource-image.ts +47 -0
  228. package/packages/bundled-features/src/template-resolver/seeding.ts +171 -20
  229. package/packages/bundled-features/src/template-resolver/store-template-resource.ts +416 -0
  230. package/packages/bundled-features/src/template-resolver/table.ts +27 -1
  231. package/packages/bundled-features/src/template-resolver/template-bundle.ts +79 -0
  232. package/packages/bundled-features/src/template-resolver/template-diff.ts +78 -0
  233. package/packages/bundled-features/src/template-resolver/template-draft.ts +106 -0
  234. package/packages/bundled-features/src/template-resolver/template-meta.ts +192 -0
  235. package/packages/bundled-features/src/template-resolver/template-preview.ts +676 -0
  236. package/packages/bundled-features/src/template-resolver/template-resources.ts +114 -0
  237. package/packages/bundled-features/src/template-resolver/template-usage.ts +68 -0
  238. package/packages/bundled-features/src/template-resolver/template-validation.ts +230 -0
  239. package/packages/bundled-features/src/template-resolver/testing.ts +5 -5
  240. package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +20 -13
  241. package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +215 -0
  242. package/packages/bundled-features/src/template-resolver/web/document-editor.tsx +37 -0
  243. package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +1065 -0
  244. package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +72 -0
  245. package/packages/bundled-features/src/template-resolver/web/editor-handle.ts +26 -0
  246. package/packages/bundled-features/src/template-resolver/web/i18n.ts +121 -0
  247. package/packages/bundled-features/src/template-resolver/web/index.ts +26 -0
  248. package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor-lazy.tsx +36 -0
  249. package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +176 -0
  250. package/packages/bundled-features/src/template-resolver/web/placeholder-text.ts +37 -0
  251. package/packages/bundled-features/src/template-resolver/web/use-stable-by-key.ts +14 -0
  252. package/packages/bundled-features/src/tenant/handlers/team-list.query.ts +3 -2
  253. package/packages/bundled-features/src/tenant/seeding.ts +41 -7
  254. package/packages/bundled-features/src/tenant-lifecycle/boot-checks.ts +1 -1
  255. package/packages/bundled-features/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +2 -1
  256. package/packages/bundled-features/src/tier-engine/changes.json +6 -0
  257. package/packages/bundled-features/src/tier-engine/feature.ts +11 -3
  258. package/packages/bundled-features/src/user/changes.json +6 -0
  259. package/packages/bundled-features/src/user/db/queries/stream-tenant-backfill.ts +4 -4
  260. package/packages/bundled-features/src/user/handlers/detail.query.ts +3 -5
  261. package/packages/bundled-features/src/user/resolve-display-names.ts +8 -15
  262. package/packages/bundled-features/src/user/roles-input-schema.ts +2 -2
  263. package/packages/bundled-features/src/user/seeding.ts +19 -5
  264. package/packages/bundled-features/src/user-data-rights/changes.json +13 -0
  265. package/packages/bundled-features/src/user-data-rights/db/queries/forget-cleanup.ts +2 -2
  266. package/packages/bundled-features/src/user-data-rights/email-templates.ts +5 -149
  267. package/packages/bundled-features/src/user-data-rights/feature.ts +72 -35
  268. package/packages/bundled-features/src/user-data-rights/handlers/cancel-deletion.write.ts +2 -1
  269. package/packages/bundled-features/src/user-data-rights/handlers/request-deletion.write.ts +5 -11
  270. package/packages/bundled-features/src/user-data-rights/lib/default-mailers.ts +134 -53
  271. package/packages/bundled-features/src/user-data-rights/lib/update-user-lifecycle.ts +2 -2
  272. package/packages/bundled-features/src/user-data-rights/mail-notifications.ts +157 -0
  273. package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +1 -1
  274. package/packages/bundled-features/src/user-data-rights/web/privacy-center-screen.tsx +2 -1
  275. package/packages/bundled-features/src/user-profile/changes.json +1 -1
  276. package/packages/bundled-features/src/user-profile/web/profile-screen.tsx +2 -1
  277. package/packages/bundled-features/src/waitlist/changes.json +7 -0
  278. package/packages/bundled-features/src/waitlist/feature.ts +17 -0
  279. package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +4 -5
  280. package/packages/bundled-features/src/waitlist/mail.ts +69 -18
  281. package/packages/bundled-features/src/waitlist/options.ts +1 -1
  282. package/packages/bundled-features/src/workflow-runner/changes.json +6 -0
  283. package/packages/bundled-features/src/workflow-runner/event-trigger.ts +3 -3
  284. package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +7 -1
  285. package/samples/apps/ui-walkthrough/package.json +11 -0
  286. package/samples/apps/ui-walkthrough/playwright.config.ts +40 -7
  287. package/samples/apps/ui-walkthrough/src/app/auth-constants.ts +9 -0
  288. package/samples/apps/ui-walkthrough/src/app/client.tsx +6 -0
  289. package/samples/apps/ui-walkthrough/src/app/seed-templates.ts +239 -0
  290. package/samples/apps/ui-walkthrough/src/app/server.ts +5 -1
  291. package/samples/apps/ui-walkthrough/src/app/shell.tsx +12 -1
  292. package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +570 -0
  293. package/samples/apps/ui-walkthrough/src/features/billing/feature.ts +72 -0
  294. package/samples/apps/ui-walkthrough/src/features/billing/i18n.ts +18 -0
  295. package/samples/apps/ui-walkthrough/src/features/billing/index.ts +2 -0
  296. package/samples/apps/ui-walkthrough/src/features/billing/web/index.ts +7 -0
  297. package/samples/apps/ui-walkthrough/src/run-config.ts +41 -1
  298. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +0 -2
  299. package/samples/apps/use-all-bundled/feature-manifest.json +118 -21
  300. package/samples/apps/use-all-bundled/kumiko/migrations/0045_template-resource-meta.rebuild.json +4 -0
  301. package/samples/apps/use-all-bundled/kumiko/migrations/0046_pdf-rendered.rebuild.json +4 -0
  302. package/samples/apps/use-all-bundled/package.json +2 -0
  303. package/samples/apps/use-all-bundled/playwright.config.ts +38 -3
  304. package/samples/apps/use-all-bundled/schema/generate.ts +13 -3
  305. package/samples/apps/use-all-bundled/schema/schema.generated.ts +3 -0
  306. package/samples/apps/use-all-bundled/scripts/check-coverage.ts +3 -0
  307. package/samples/apps/use-all-bundled/src/app/auth-constants.ts +3 -0
  308. package/samples/apps/use-all-bundled/src/app/client.tsx +4 -0
  309. package/samples/apps/use-all-bundled/src/app/seed-documents.ts +128 -0
  310. package/samples/apps/use-all-bundled/src/app/seed.ts +4 -0
  311. package/samples/apps/use-all-bundled/src/run-config.ts +11 -11
  312. package/samples/recipes/delivery-notifications/README.md +26 -25
  313. package/samples/recipes/delivery-notifications/package.json +2 -1
  314. package/samples/recipes/delivery-notifications/src/feature.ts +64 -58
  315. package/samples/recipes/seed-migration/README.md +1 -1
  316. package/packages/bundled-features/src/renderer-simple/changes.json +0 -27
  317. package/packages/bundled-features/src/renderer-simple/feature.ts +0 -66
  318. package/packages/bundled-features/src/renderer-simple/index.ts +0 -8
  319. package/packages/bundled-features/src/renderer-simple/mail-blocks.ts +0 -138
  320. package/packages/bundled-features/src/renderer-simple/resolve-variables.ts +0 -57
  321. package/packages/bundled-features/src/renderer-simple/simple-renderer.ts +0 -304
@@ -14,6 +14,7 @@ import type {
14
14
  SignatureExtraRouteDeps,
15
15
  } from "@cosmicdrift/kumiko-framework/api";
16
16
  import type { TenantId, WriteResult } from "@cosmicdrift/kumiko-framework/engine";
17
+ import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
17
18
  import { subscriptionAggregateId } from "./aggregate-id.js";
18
19
  import { SubscriptionFoundationQueries, SubscriptionStatuses } from "./constants.js";
19
20
  import { createSubscriptionWebhookRoute } from "./webhook-handler.js";
@@ -37,6 +38,10 @@ export type SubscriptionTierSyncDeps<TTier extends string> = {
37
38
  readonly onSyncError?: "log" | "fail-webhook";
38
39
  };
39
40
 
41
+ // A manual grant that differs from what billing would assign is reported with this
42
+ // code but never fails the webhook: a provider retry cannot resolve it.
43
+ export const TIER_SYNC_MANUAL_GRANT_SKIPPED = "tier_sync_manual_grant_skipped";
44
+
40
45
  export function effectiveTierFromSubscription<TTier extends string>(
41
46
  status: string | undefined,
42
47
  tier: string | undefined,
@@ -52,12 +57,8 @@ export function effectiveTierFromSubscription<TTier extends string>(
52
57
  // (handler bug, or the handlerQn resolving to a non-list query) fails
53
58
  // loudly instead of crashing on `.find` against `undefined`.
54
59
  function asRows(result: unknown): ReadonlyArray<Record<string, unknown>> {
55
- if (
56
- typeof result === "object" &&
57
- result !== null &&
58
- Array.isArray((result as { rows?: unknown }).rows)
59
- ) {
60
- return (result as { rows: ReadonlyArray<Record<string, unknown>> }).rows;
60
+ if (isPlainObject(result) && Array.isArray(result["rows"])) {
61
+ return result["rows"];
61
62
  }
62
63
  throw new Error("expected a { rows: [...] } list-query result");
63
64
  }
@@ -85,7 +86,7 @@ export function createSubscriptionTierSync<TTier extends string>(
85
86
  ) {
86
87
  async function syncTierFromSubscription(
87
88
  tenantId: TenantId,
88
- routeDeps: SignatureExtraRouteDeps,
89
+ routeDeps: Pick<SignatureExtraRouteDeps, "dispatchSystemQuery" | "dispatchSystemWrite">,
89
90
  ): Promise<{ code: string; message: string } | null> {
90
91
  try {
91
92
  const subscriptionId = subscriptionAggregateId(tenantId);
@@ -132,8 +133,13 @@ export function createSubscriptionTierSync<TTier extends string>(
132
133
  }
133
134
  return null;
134
135
  }
135
- if (assignment.source === TierAssignmentSources.manual) return null;
136
136
  if (assignment.tier === effective) return null;
137
+ if (assignment.source === TierAssignmentSources.manual) {
138
+ return {
139
+ code: TIER_SYNC_MANUAL_GRANT_SKIPPED,
140
+ message: `manual tier grant "${String(assignment.tier)}" kept although the subscription yields "${effective}"`,
141
+ };
142
+ }
137
143
 
138
144
  const result = await routeDeps.dispatchSystemWrite({
139
145
  handlerQn: TierEngineHandlers.update,
@@ -172,9 +178,12 @@ export function createSubscriptionTierSync<TTier extends string>(
172
178
  if (syncError) {
173
179
  // biome-ignore lint/suspicious/noConsole: operator visibility for a post-commit sync failure
174
180
  console.warn(
175
- `[subscription-tier-sync] tier sync failed for tenant ${tenantId} after successful webhook write: ${syncError.code} ${syncError.message}`,
181
+ `[subscription-tier-sync] tier sync for tenant ${tenantId} after successful webhook write: ${syncError.code} ${syncError.message}`,
176
182
  );
177
- if (deps.onSyncError === "fail-webhook") {
183
+ if (
184
+ deps.onSyncError === "fail-webhook" &&
185
+ syncError.code !== TIER_SYNC_MANUAL_GRANT_SKIPPED
186
+ ) {
178
187
  return {
179
188
  isSuccess: false,
180
189
  error: {
@@ -191,5 +200,5 @@ export function createSubscriptionTierSync<TTier extends string>(
191
200
  });
192
201
  }
193
202
 
194
- return { createWebhookRoute };
203
+ return { createWebhookRoute, syncTierFromSubscription };
195
204
  }
@@ -396,7 +396,10 @@ export function isLegalLinkSet(
396
396
 
397
397
  /** Consumer-protection consent gate (§ 312j / § 356 BGB style). When set,
398
398
  * `start-plan-checkout` and `create-checkout-session` require a `consent`
399
- * payload and record it as an event before the buyer reaches the provider. */
399
+ * payload and record it as an event before the buyer reaches the provider.
400
+ * Every provider used with `create-checkout-session` must implement
401
+ * `retrievePrices`: the live price is part of the consent record, and a
402
+ * provider without it fails the checkout with an InternalError. */
400
403
  export type ConsumerProtectionOptions = {
401
404
  /** Slug of the template-resolver `text-block` holding the terms text; its
402
405
  * content hash is stored with every recorded consent. */
@@ -411,7 +414,9 @@ export type ConsumerProtectionOptions = {
411
414
  readonly terminationScope?: TerminationScope;
412
415
  /** Names the item of a one-off payment (mode "payment") for the consent record
413
416
  * and the confirmation mail; the label key is resolved through the registry
414
- * i18n, so it must be qualified with the registering feature. */
417
+ * i18n, so it must be qualified with the registering feature. The label key
418
+ * is 1-200 characters, string params at most 100; a violation fails the
419
+ * checkout with an InternalError before the provider session is created. */
415
420
  readonly oneOffItemLabel?: (
416
421
  ctx: HandlerContext,
417
422
  priceId: string,
@@ -44,6 +44,8 @@ export async function reserveEnforcedCalendarCap(
44
44
  profile: cap.profile,
45
45
  notify: cap.notify,
46
46
  amount: cap.amount,
47
+ ...(cap.periodEndIso !== undefined && { periodEndIso: cap.periodEndIso }),
48
+ ...(cap.i18nKey !== undefined && { i18nKey: cap.i18nKey }),
47
49
  markSoftWarnedOutsideTransaction: outsideTransaction,
48
50
  });
49
51
  }
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.364.0",
4
+ "type": "breaking",
5
+ "title": "CapExceededError carries resetAt and retryAfterSeconds, API sets Retry-After",
6
+ "migration": "Periodic caps no longer need RateLimitError({ windowSeconds: 0, ... }). Where you catch CapExceededError from enforceCap and rethrow it as RateLimitError, drop the catch and let CapExceededError pass: the response stays 429 with a correct Retry-After, error.code becomes cap_exceeded instead of rate_limited, and error.details carries resetAt and retryAfterSeconds. Keep a feature-specific message with enforceCap({ ..., i18nKey }) and pass a period end other than the calendar month with periodEndIso (e.g. a budget's resetsAtIso). Clients matching on rate_limited for these caps must also accept cap_exceeded. Any KumikoError with retryAfterSeconds (including RateLimitError thrown in a handler) now gets a Retry-After header from /api/write, /api/batch and /api/query."
7
+ },
2
8
  {
3
9
  "version": "0.361.0",
4
10
  "type": "improvement",
@@ -66,14 +66,38 @@ export function assertBelowHardCap(
66
66
  readonly capName: string;
67
67
  readonly limit: number;
68
68
  readonly profile: CapToleranceProfileName;
69
+ // Rolling caps have no fixed period, so they pass neither period field.
70
+ readonly periodStartIso?: string;
71
+ readonly periodEndIso?: string;
72
+ readonly i18nKey?: string;
69
73
  },
70
74
  ): void {
71
75
  const tolerance = CAP_TOLERANCES[cap.profile];
72
76
  if (valueSeenByLastUnit >= cap.limit * tolerance.hard) {
73
- throw new CapExceededError(cap.capName, cap.limit, valueSeenByLastUnit, tolerance);
77
+ const resetAt = resolvePeriodEndIso(cap.periodStartIso, cap.periodEndIso);
78
+ throw new CapExceededError(cap.capName, cap.limit, valueSeenByLastUnit, tolerance, {
79
+ ...(resetAt !== undefined && { resetAt }),
80
+ ...(cap.i18nKey !== undefined && { i18nKey: cap.i18nKey }),
81
+ });
74
82
  }
75
83
  }
76
84
 
85
+ function resolvePeriodEndIso(
86
+ periodStartIso: string | undefined,
87
+ periodEndIso: string | undefined,
88
+ ): string | undefined {
89
+ if (periodStartIso === undefined) return periodEndIso;
90
+ if (periodEndIso === undefined) return calendarMonthPeriodEndIso(periodStartIso);
91
+ const start = Temporal.Instant.from(periodStartIso);
92
+ const end = Temporal.Instant.from(periodEndIso);
93
+ if (Temporal.Instant.compare(end, start) <= 0) {
94
+ throw new Error(
95
+ `cap-counter: periodEndIso (${periodEndIso}) must be after periodStartIso (${periodStartIso})`,
96
+ );
97
+ }
98
+ return end.toString();
99
+ }
100
+
77
101
  // =============================================================================
78
102
  // Enforce-Cap helper
79
103
  // =============================================================================
@@ -109,6 +133,9 @@ export async function enforceCap(
109
133
  // Units the caller is about to book in one go (default 1), checked with
110
134
  // the same outcome as booking them one by one.
111
135
  readonly amount?: number;
136
+ // Default: end of the calendar month that starts at periodStartIso.
137
+ readonly periodEndIso?: string;
138
+ readonly i18nKey?: string;
112
139
  },
113
140
  ): Promise<EnforceCapResult> {
114
141
  const { user, readDb } = resolveCapCounterScope(ctx, false);
@@ -211,23 +238,54 @@ export async function enforceRollingCap(
211
238
  * Thrown by enforceCap/enforceRollingCap when value ≥ hard-threshold.
212
239
  * Extends `KumikoError` — the dispatcher auto-maps this to HTTP 429
213
240
  * with `code: "cap_exceeded"`, no caller-side catch needed.
241
+ *
242
+ * Calendar caps carry `resetAt` (end of the period) and `retryAfterSeconds`, which the API
243
+ * answers as Retry-After. Rolling caps have no fixed reset instant, so both stay undefined.
214
244
  */
215
245
  export class CapExceededError extends KumikoError {
216
246
  readonly code = "cap_exceeded" as const;
217
247
  readonly httpStatus = 429;
248
+ readonly resetAt: string | undefined;
218
249
 
219
250
  constructor(
220
251
  readonly capName: string,
221
252
  readonly limit: number,
222
253
  readonly currentValue: number,
223
254
  readonly tolerance: CapToleranceProfile,
255
+ options?: {
256
+ readonly resetAt?: string;
257
+ readonly i18nKey?: string;
258
+ readonly cause?: Error;
259
+ // Deterministic tests only.
260
+ readonly now?: Temporal.Instant;
261
+ },
224
262
  ) {
263
+ const resetAt = options?.resetAt;
264
+ const retryAfterSeconds =
265
+ resetAt === undefined
266
+ ? undefined
267
+ : Math.max(
268
+ 0,
269
+ Math.ceil(
270
+ Temporal.Instant.from(resetAt).epochMilliseconds / 1000 -
271
+ (options?.now ?? Temporal.Now.instant()).epochMilliseconds / 1000,
272
+ ),
273
+ );
225
274
  super({
226
- message: `Cap "${capName}" exceeded: current=${currentValue}, limit=${limit}, hard-threshold=${limit * tolerance.hard}. Upgrade tier or wait for next period reset.`,
227
- i18nKey: "errors.cap.exceeded",
228
- i18nParams: { capName, limit, currentValue },
229
- details: { capName, limit, currentValue, tolerance },
275
+ message: `Cap "${capName}" exceeded: current=${currentValue}, limit=${limit}, hard-threshold=${limit * tolerance.hard}. Upgrade tier or wait for next period reset.${resetAt === undefined ? "" : ` Resets at ${resetAt}.`}`,
276
+ i18nKey: options?.i18nKey ?? "errors.cap.exceeded",
277
+ i18nParams: { capName, limit, currentValue, ...(resetAt !== undefined && { resetAt }) },
278
+ details: {
279
+ capName,
280
+ limit,
281
+ currentValue,
282
+ tolerance,
283
+ ...(resetAt !== undefined && { resetAt, retryAfterSeconds }),
284
+ },
285
+ ...(retryAfterSeconds !== undefined && { retryAfterSeconds }),
286
+ ...(options?.cause && { cause: options.cause }),
230
287
  });
288
+ this.resetAt = resetAt;
231
289
  }
232
290
  }
233
291
 
@@ -257,6 +315,14 @@ export function currentCalendarMonthStartIso(
257
315
  return start.toInstant().toString();
258
316
  }
259
317
 
318
+ export function calendarMonthPeriodEndIso(periodStartIso: string): string {
319
+ return Temporal.Instant.from(periodStartIso)
320
+ .toZonedDateTimeISO("UTC")
321
+ .add({ months: 1 })
322
+ .toInstant()
323
+ .toString();
324
+ }
325
+
260
326
  // =============================================================================
261
327
  // Notification-Wiring helpers — convenience-wrapper für enforceCap +
262
328
  // enforceRollingCap, die einen Caller-supplied delivery-emit beim
@@ -299,6 +365,8 @@ export async function enforceCapAndMaybeNotify(
299
365
  readonly profile: CapToleranceProfileName;
300
366
  readonly notify: SoftHitNotifier;
301
367
  readonly amount?: number;
368
+ readonly periodEndIso?: string;
369
+ readonly i18nKey?: string;
302
370
  readonly markSoftWarnedOutsideTransaction?: boolean;
303
371
  },
304
372
  ): Promise<EnforceCapResult> {
@@ -308,6 +376,8 @@ export async function enforceCapAndMaybeNotify(
308
376
  limit: options.limit,
309
377
  profile: options.profile,
310
378
  ...(options.amount !== undefined && { amount: options.amount }),
379
+ ...(options.periodEndIso !== undefined && { periodEndIso: options.periodEndIso }),
380
+ ...(options.i18nKey !== undefined && { i18nKey: options.i18nKey }),
311
381
  });
312
382
 
313
383
  if (result.state === "soft-hit" && result.crossed) {
@@ -29,6 +29,7 @@ export {
29
29
  CapExceededError,
30
30
  type CapToleranceProfile,
31
31
  type CapToleranceProfileName,
32
+ calendarMonthPeriodEndIso,
32
33
  currentCalendarMonthStartIso,
33
34
  type EnforceCapResult,
34
35
  enforceCap,
@@ -53,6 +53,9 @@ import { chainReservations } from "./reservation-chain.js";
53
53
  export type CalendarCapDef = {
54
54
  readonly capName: string;
55
55
  readonly periodStartIso: string;
56
+ // Default: end of the calendar month that starts at periodStartIso.
57
+ readonly periodEndIso?: string;
58
+ readonly i18nKey?: string;
56
59
  readonly limit: number;
57
60
  readonly profile: CapToleranceProfileName;
58
61
  /** Increment-amount post-success. Default 1. */
@@ -7,6 +7,7 @@ import {
7
7
  type TenantId,
8
8
  } from "@cosmicdrift/kumiko-framework/engine";
9
9
  import { InternalError, ValidationError } from "@cosmicdrift/kumiko-framework/errors";
10
+ import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
10
11
  import * as z from "zod";
11
12
  import { subscriptionsProjectionTable } from "../../billing-foundation/index.js";
12
13
  import type { CapLimitContext } from "../../cap-counter/index.js";
@@ -79,7 +80,7 @@ async function resolveLimitsByTenantAndCap(
79
80
  }
80
81
 
81
82
  function isSortableField(field: string): field is SortableField {
82
- return (SORTABLE_FIELDS as readonly string[]).includes(field);
83
+ return isOneOf(SORTABLE_FIELDS, field);
83
84
  }
84
85
 
85
86
  // tier is a plain string with no defined ordering — lt/gt have no meaning
@@ -1,17 +1,19 @@
1
1
  // @runtime client
2
+
2
3
  import type { ColumnRendererProps } from "@cosmicdrift/kumiko-renderer";
4
+ import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
3
5
  import type { ReactNode } from "react";
4
6
  import type { CapUsage } from "../types.js";
5
7
  import { CapUsageBar } from "./cap-usage-bar.js";
6
8
 
7
9
  function isCapUsage(value: unknown): value is CapUsage {
8
- if (typeof value !== "object" || value === null) return false;
9
- const used = (value as { used?: unknown }).used;
10
- const limit = (value as { limit?: unknown }).limit;
10
+ if (!isPlainObject(value)) return false;
11
+ const used = value["used"];
12
+ const limit = value["limit"];
11
13
  return (
12
14
  (typeof used === "number" || used === null) &&
13
15
  (typeof limit === "number" || limit === null) &&
14
- typeof (value as { fraction?: unknown }).fraction === "number"
16
+ typeof value["fraction"] === "number"
15
17
  );
16
18
  }
17
19
 
@@ -1,4 +1,23 @@
1
1
  [
2
+ {
3
+ "version": "0.363.0",
4
+ "type": "breaking",
5
+ "title": "A notification without a mail template fails on the email channel",
6
+ "detail": "The renderer option of createChannelEmailFeature is removed. A notification routed to the email channel without template + meta fails its delivery permanently with \"has no mail template, declare template + meta on r.notification\". r.notification declares no channels, so this is not checked at boot; an app's own mail tests over the inmemory transport catch it.",
7
+ "migration": "Drop the renderer option. Declare template + meta (or localizedTemplate) and variables on every r.notification that reaches the email channel, or accept that its email delivery fails. Run the app's notification tests after the bump: every email notification without a template now fails there."
8
+ },
9
+ {
10
+ "version": "0.363.0",
11
+ "type": "improvement",
12
+ "title": "Email channel sends rendered-pdf attachments",
13
+ "detail": "The channel resolves rendered-pdf entries from notify data next to file entries; without a locale the message locale is used. A render failure fails the send job, so the retry runs it again."
14
+ },
15
+ {
16
+ "version": "0.363.0",
17
+ "type": "improvement",
18
+ "title": "Email channel sends file attachments and inline cid images",
19
+ "detail": "EmailMessage.attachments is sent through the SMTP transport (inline images with Content-ID). notify data.attachments with kind file entries are resolved through the file store; the PII guard rejects ciphertext file names."
20
+ },
2
21
  {
3
22
  "version": "0.350.0",
4
23
  "type": "improvement",
@@ -1,17 +1,23 @@
1
1
  import { PII_ERASED_SENTINEL } from "@cosmicdrift/kumiko-framework/crypto";
2
- import type { DbRow } from "@cosmicdrift/kumiko-framework/db";
3
2
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
3
+ import * as z from "zod";
4
4
  import {
5
+ type AttachmentOrigin,
6
+ type ChannelContext,
5
7
  type ChannelMessage,
6
8
  DELIVERY_UNSUBSCRIBE_ONE_CLICK_HEADER_VALUE,
7
9
  DELIVERY_UNSUBSCRIBE_PATH,
8
10
  type DeliveryChannel,
9
- type NotificationRenderer,
11
+ EMAIL_CHANNEL_NAME,
12
+ type EmailAttachmentRef,
13
+ fileAttachmentRefSchema,
14
+ MAX_EMAIL_ATTACHMENTS,
10
15
  type RenderedMessage,
16
+ renderedPdfAttachmentRefSchema,
11
17
  } from "../delivery/index.js";
12
18
  import { decryptStoredPii } from "../shared/index.js";
13
19
  import { guardEmailMessage } from "./pii-guard.js";
14
- import type { EmailTransport } from "./types.js";
20
+ import type { EmailAttachment, EmailTransport } from "./types.js";
15
21
 
16
22
  // Envelope (From / Reply-To / threading headers) rides on the channel data —
17
23
  // the notification's email template echoes it out of the notify() call, since
@@ -83,9 +89,80 @@ function emailEnvelopeFrom(data: Readonly<Record<string, unknown>> | undefined):
83
89
  };
84
90
  }
85
91
 
92
+ const fileAttachmentsSchema = z
93
+ .array(z.discriminatedUnion("kind", [fileAttachmentRefSchema, renderedPdfAttachmentRefSchema]))
94
+ .max(MAX_EMAIL_ATTACHMENTS);
95
+
96
+ // Only kinds "file" and "rendered-pdf" may come from notify data; inline images are created by the renderer alone.
97
+ // Invalid input throws instead of dropping the attachment silently.
98
+ export function fileAttachmentsFrom(
99
+ data: Readonly<Record<string, unknown>> | undefined,
100
+ messageLocale?: string,
101
+ ): readonly EmailAttachmentRef[] {
102
+ const raw = data?.["attachments"];
103
+ if (raw === undefined) return [];
104
+ return fileAttachmentsSchema
105
+ .parse(raw)
106
+ .map((ref) =>
107
+ ref.kind === "rendered-pdf" && ref.locale === undefined && messageLocale !== undefined
108
+ ? { ...ref, locale: messageLocale }
109
+ : ref,
110
+ );
111
+ }
112
+
113
+ const INLINE_EXTENSION_BY_CONTENT_TYPE: Readonly<Record<string, string>> = {
114
+ "image/png": "png",
115
+ "image/jpeg": "jpg",
116
+ "image/gif": "gif",
117
+ "image/webp": "webp",
118
+ };
119
+
120
+ type AttachmentSource = { readonly ref: EmailAttachmentRef; readonly origin: AttachmentOrigin };
121
+
122
+ async function resolveAttachments(
123
+ sources: readonly AttachmentSource[],
124
+ ctx: ChannelContext,
125
+ ): Promise<readonly EmailAttachment[]> {
126
+ if (sources.length === 0) return [];
127
+ if (ctx.readAttachment === undefined) {
128
+ throw new Error(
129
+ "email channel: the message has attachments but the channel context cannot read files",
130
+ );
131
+ }
132
+ const resolved: EmailAttachment[] = [];
133
+ for (const { ref, origin } of sources) {
134
+ const found = await ctx.readAttachment(ref, origin);
135
+ if (ref.kind === "rendered-pdf") {
136
+ if (found === null) {
137
+ throw new Error(`email channel: rendering attachment "${ref.fileName}" returned nothing`);
138
+ }
139
+ resolved.push({
140
+ kind: "rendered-pdf",
141
+ templateSlug: ref.templateSlug,
142
+ fileName: ref.fileName,
143
+ content: found.content,
144
+ contentType: found.contentType,
145
+ });
146
+ continue;
147
+ }
148
+ if (found === null) {
149
+ // A missing inline image only degrades the look; a missing file is content the recipient expects.
150
+ if (ref.kind === "inline") continue;
151
+ throw new Error(`email channel: attachment file ${ref.fileRefId} was not found`);
152
+ }
153
+ const fileName =
154
+ ref.kind === "file"
155
+ ? ref.fileName
156
+ : INLINE_EXTENSION_BY_CONTENT_TYPE[found.contentType]
157
+ ? `${ref.cid}.${INLINE_EXTENSION_BY_CONTENT_TYPE[found.contentType]}`
158
+ : ref.cid;
159
+ resolved.push({ ...ref, fileName, content: found.content, contentType: found.contentType });
160
+ }
161
+ return resolved;
162
+ }
163
+
86
164
  export type EmailChannelOptions = {
87
165
  readonly transport: EmailTransport;
88
- readonly renderer: NotificationRenderer;
89
166
  readonly resolveEmail: (
90
167
  userId: string,
91
168
  ctx: { db: unknown; tenantId: TenantId },
@@ -93,32 +170,45 @@ export type EmailChannelOptions = {
93
170
  };
94
171
 
95
172
  export function createEmailChannel(options: EmailChannelOptions): DeliveryChannel {
96
- const { transport, renderer, resolveEmail } = options;
173
+ const { transport, resolveEmail } = options;
97
174
 
98
- // Render is the expensive step (template engine, possibly a remote service)
99
- // and runs in the delivery.render job, decoupled from the SMTP send so each
100
- // can retry independently. Extracted so the inline fallback (no job runner)
175
+ // Render runs in the delivery.render job, decoupled from the SMTP send so
176
+ // each can retry independently. Extracted so the inline path (no job runner)
101
177
  // can reuse it without going through the channel's own render() indirection.
102
- async function renderMessage(message: ChannelMessage): Promise<RenderedMessage> {
103
- // Build renderer input: per-channel template data (if any) or fall back
104
- // to title/body from the message. Renderer handles both cases.
105
- const variables = (message.data as DbRow) ?? {
106
- title: message.title,
107
- body: message.body,
108
- };
109
- const rendererInput = {
110
- template: message.notificationType,
111
- variables,
112
- locale: message.locale,
178
+ async function renderMessage(
179
+ message: ChannelMessage,
180
+ ctx: ChannelContext,
181
+ ): Promise<RenderedMessage> {
182
+ const notificationType = message.notificationType;
183
+ if (ctx.registry.getAllNotifications().get(notificationType)?.template === undefined) {
184
+ throw new Error(
185
+ `email channel: notification "${notificationType}" has no mail template, declare template + meta on r.notification`,
186
+ );
187
+ }
188
+ if (ctx.renderMail === undefined) {
189
+ throw new Error(
190
+ `email channel: notification "${notificationType}" has a mail template but the channel context carries no renderMail`,
191
+ );
192
+ }
193
+ const unsubscribeUrl = message.data?.["unsubscribeUrl"];
194
+ const { subject, html, text, attachments, fromName, replyTo } = await ctx.renderMail({
195
+ slug: notificationType,
196
+ locale: message.locale ?? "en",
197
+ variables: message.data ?? {},
198
+ ...(typeof unsubscribeUrl === "string" && { unsubscribeUrl }),
199
+ });
200
+ return {
201
+ html,
202
+ subject,
203
+ text,
204
+ ...(fromName !== undefined && { fromName }),
205
+ ...(replyTo !== undefined && { replyTo }),
206
+ ...(attachments !== undefined && { attachments }),
113
207
  };
114
- const html = await renderer.render(rendererInput);
115
- const text = renderer.renderText ? await renderer.renderText(rendererInput) : undefined;
116
- const subject = (variables["subject"] as string) ?? message.title; // @cast-boundary dynamic-key
117
- return { html, subject, ...(text !== undefined && { text }) };
118
208
  }
119
209
 
120
210
  return {
121
- name: "email",
211
+ name: EMAIL_CHANNEL_NAME,
122
212
  mode: "queued",
123
213
 
124
214
  // resolveEmail typically returns the raw stored user.email, which is ciphertext under an active KMS.
@@ -129,13 +219,28 @@ export function createEmailChannel(options: EmailChannelOptions): DeliveryChanne
129
219
  return address === PII_ERASED_SENTINEL ? null : address;
130
220
  },
131
221
 
132
- render(message, _ctx) {
133
- return renderMessage(message);
222
+ render(message, ctx) {
223
+ return renderMessage(message, ctx);
134
224
  },
135
225
 
136
- async send(address, message, _ctx, rendered) {
137
- const { html, subject, text } = rendered ?? (await renderMessage(message));
138
- const envelope = emailEnvelopeFrom(message.data);
226
+ async send(address, message, ctx, rendered) {
227
+ const { html, subject, text, attachments, fromName, replyTo } =
228
+ rendered ?? (await renderMessage(message, ctx));
229
+ // Template-level sender defaults; notify data wins.
230
+ const envelope = {
231
+ ...(fromName && { fromName }),
232
+ ...(replyTo && { replyTo }),
233
+ ...emailEnvelopeFrom(message.data),
234
+ };
235
+ const resolvedAttachments = await resolveAttachments(
236
+ [
237
+ ...(attachments ?? []).map((ref): AttachmentSource => ({ ref, origin: "template" })),
238
+ ...fileAttachmentsFrom(message.data, message.locale).map(
239
+ (ref): AttachmentSource => ({ ref, origin: "message" }),
240
+ ),
241
+ ],
242
+ ctx,
243
+ );
139
244
  await transport.send(
140
245
  guardEmailMessage({
141
246
  to: address,
@@ -143,6 +248,7 @@ export function createEmailChannel(options: EmailChannelOptions): DeliveryChanne
143
248
  html,
144
249
  ...(text !== undefined && { text }),
145
250
  ...envelope,
251
+ ...(resolvedAttachments.length > 0 && { attachments: resolvedAttachments }),
146
252
  }),
147
253
  );
148
254
  return { status: "sent", address };
@@ -1,4 +1,7 @@
1
- import { DELIVERY_CHANNEL_EXTENSION } from "@cosmicdrift/kumiko-bundled-features/delivery";
1
+ import {
2
+ DELIVERY_CHANNEL_EXTENSION,
3
+ EMAIL_CHANNEL_NAME,
4
+ } from "@cosmicdrift/kumiko-bundled-features/delivery";
2
5
  import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
3
6
  import { createEmailChannel, type EmailChannelOptions } from "./email-channel.js";
4
7
 
@@ -7,7 +10,7 @@ export function createChannelEmailFeature(options: EmailChannelOptions): Feature
7
10
 
8
11
  return defineFeature("channel-email", (r) => {
9
12
  r.describe(
10
- "Wires an `EmailTransport` (typically `mail-transport-smtp` in production, `createInMemoryTransport()` in tests) into the delivery system as the `email` channel. Requires `delivery`; pass an `EmailChannelOptions` with a `transport`, a `renderer: NotificationRenderer` (e.g. backed by `renderer-simple`), and a `resolveEmail` function that maps a user ID to their email address.",
13
+ "Wires an `EmailTransport` (typically `mail-transport-smtp` in production, `createInMemoryTransport()` in tests) into the delivery system as the `email` channel. Requires `delivery`; pass an `EmailChannelOptions` with a `transport` and a `resolveEmail` function that maps a user ID to their email address. Mails render from stored templates (`renderer-mail-html`): a notification routed to this channel needs `template` + `meta` on `r.notification`, otherwise its delivery fails.",
11
14
  );
12
15
  r.uiHints({
13
16
  displayLabel: "Email Channel",
@@ -17,7 +20,7 @@ export function createChannelEmailFeature(options: EmailChannelOptions): Feature
17
20
  r.requires("delivery");
18
21
 
19
22
  r.translations({ keys: { "delivery.channel.email": { en: "Email" } } });
20
- r.useExtension(DELIVERY_CHANNEL_EXTENSION, "email", {
23
+ r.useExtension(DELIVERY_CHANNEL_EXTENSION, EMAIL_CHANNEL_NAME, {
21
24
  mode: channel.mode,
22
25
  resolve: channel.resolve,
23
26
  render: channel.render,
@@ -7,4 +7,10 @@ export {
7
7
  type SmtpEnv,
8
8
  type SmtpTransportOptions,
9
9
  } from "./smtp-transport.js";
10
- export { createInMemoryTransport, type EmailMessage, type EmailTransport } from "./types.js";
10
+ export {
11
+ createInMemoryTransport,
12
+ type EmailAttachment,
13
+ type EmailAttachmentRef,
14
+ type EmailMessage,
15
+ type EmailTransport,
16
+ } from "./types.js";
@@ -11,6 +11,18 @@ const CIPHERTEXT_RE = /kumiko-pii:v\d+:[^"\s<>\\]*/g;
11
11
  // is always refused (the address is garbage — better no mail than a leaked
12
12
  // blob); ciphertext in subject/body fails loud in dev and is redacted+logged
13
13
  // in prod.
14
+ function assertAttachmentNamesArePlain(message: EmailMessage): void {
15
+ for (const attachment of message.attachments ?? []) {
16
+ const fields = [attachment.fileName, attachment.kind === "inline" ? attachment.cid : undefined];
17
+ if (fields.some((field) => field?.includes(CIPHERTEXT_MARKER))) {
18
+ throw new Error(
19
+ `[channel-email] refusing to send: attachment file name is a PII ciphertext ` +
20
+ `("${PII_CIPHERTEXT_PREFIX}…") — decrypt the stored value before mailing (decryptStoredPii).`,
21
+ );
22
+ }
23
+ }
24
+ }
25
+
14
26
  export function guardEmailMessage(message: EmailMessage): EmailMessage {
15
27
  // Every envelope address, not just `to`: a ciphertext From, From-Name or Reply-To is
16
28
  // the same garbage as a ciphertext recipient — refuse rather than mail it.
@@ -35,6 +47,7 @@ export function guardEmailMessage(message: EmailMessage): EmailMessage {
35
47
  );
36
48
  }
37
49
  }
50
+ assertAttachmentNamesArePlain(message);
38
51
  const leaking =
39
52
  message.subject.includes(CIPHERTEXT_MARKER) ||
40
53
  message.html.includes(CIPHERTEXT_MARKER) ||