@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
@@ -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,38 @@ 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,
113
- };
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 }) };
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 } = await ctx.renderMail({
195
+ slug: notificationType,
196
+ locale: message.locale ?? "en",
197
+ variables: message.data ?? {},
198
+ ...(typeof unsubscribeUrl === "string" && { unsubscribeUrl }),
199
+ });
200
+ return { html, subject, text, ...(attachments !== undefined && { attachments }) };
118
201
  }
119
202
 
120
203
  return {
121
- name: "email",
204
+ name: EMAIL_CHANNEL_NAME,
122
205
  mode: "queued",
123
206
 
124
207
  // resolveEmail typically returns the raw stored user.email, which is ciphertext under an active KMS.
@@ -129,13 +212,22 @@ export function createEmailChannel(options: EmailChannelOptions): DeliveryChanne
129
212
  return address === PII_ERASED_SENTINEL ? null : address;
130
213
  },
131
214
 
132
- render(message, _ctx) {
133
- return renderMessage(message);
215
+ render(message, ctx) {
216
+ return renderMessage(message, ctx);
134
217
  },
135
218
 
136
- async send(address, message, _ctx, rendered) {
137
- const { html, subject, text } = rendered ?? (await renderMessage(message));
219
+ async send(address, message, ctx, rendered) {
220
+ const { html, subject, text, attachments } = rendered ?? (await renderMessage(message, ctx));
138
221
  const envelope = emailEnvelopeFrom(message.data);
222
+ const resolvedAttachments = await resolveAttachments(
223
+ [
224
+ ...(attachments ?? []).map((ref): AttachmentSource => ({ ref, origin: "template" })),
225
+ ...fileAttachmentsFrom(message.data, message.locale).map(
226
+ (ref): AttachmentSource => ({ ref, origin: "message" }),
227
+ ),
228
+ ],
229
+ ctx,
230
+ );
139
231
  await transport.send(
140
232
  guardEmailMessage({
141
233
  to: address,
@@ -143,6 +235,7 @@ export function createEmailChannel(options: EmailChannelOptions): DeliveryChanne
143
235
  html,
144
236
  ...(text !== undefined && { text }),
145
237
  ...envelope,
238
+ ...(resolvedAttachments.length > 0 && { attachments: resolvedAttachments }),
146
239
  }),
147
240
  );
148
241
  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) ||
@@ -18,7 +18,7 @@
18
18
 
19
19
  import { createTransport, type Transporter } from "nodemailer";
20
20
  import addressparser from "nodemailer/lib/addressparser";
21
- import type { EmailMessage, EmailTransport } from "./types.js";
21
+ import type { EmailAttachment, EmailMessage, EmailTransport } from "./types.js";
22
22
 
23
23
  export type SmtpTransportOptions = {
24
24
  /** SMTP-Server-Host (z.B. "smtp.gmail.com", "in-v3.mailjet.com",
@@ -74,6 +74,17 @@ function fromHeader(
74
74
  return parsed?.address ? { name, address: parsed.address } : address;
75
75
  }
76
76
 
77
+ function toNodemailerAttachment(attachment: EmailAttachment) {
78
+ const base = {
79
+ filename: attachment.fileName,
80
+ content: Buffer.from(attachment.content),
81
+ contentType: attachment.contentType,
82
+ };
83
+ return attachment.kind === "inline"
84
+ ? { ...base, cid: attachment.cid, contentDisposition: "inline" as const }
85
+ : base;
86
+ }
87
+
77
88
  export function createSmtpTransport(options: SmtpTransportOptions): EmailTransport {
78
89
  const transporter: Transporter = createTransport({
79
90
  host: options.host,
@@ -95,6 +106,9 @@ export function createSmtpTransport(options: SmtpTransportOptions): EmailTranspo
95
106
  ...(message.text !== undefined && { text: message.text }),
96
107
  ...(message.replyTo && { replyTo: message.replyTo }),
97
108
  ...(message.headers && { headers: message.headers }),
109
+ ...(message.attachments && {
110
+ attachments: message.attachments.map(toNodemailerAttachment),
111
+ }),
98
112
  });
99
113
  },
100
114
  };
@@ -1,3 +1,24 @@
1
+ import type {
2
+ EmailAttachmentRef,
3
+ FileAttachmentRef,
4
+ InlineAttachmentRef,
5
+ RenderedPdfAttachmentRef,
6
+ } from "../delivery/index.js";
7
+
8
+ export type { EmailAttachmentRef };
9
+
10
+ // What a transport receives: the reference plus the bytes the send job read. A rendered
11
+ // PDF keeps no variables here, they may be personal data and the transport has no use for them.
12
+ export type EmailAttachment = (
13
+ | InlineAttachmentRef
14
+ | FileAttachmentRef
15
+ | Pick<RenderedPdfAttachmentRef, "kind" | "templateSlug">
16
+ ) & {
17
+ readonly content: Uint8Array;
18
+ readonly contentType: string;
19
+ readonly fileName: string;
20
+ };
21
+
1
22
  // Transport interface — SMTP in prod, InMemory in tests
2
23
  export type EmailMessage = {
3
24
  readonly to: string;
@@ -19,6 +40,8 @@ export type EmailMessage = {
19
40
  // Extra RFC-5322 headers — In-Reply-To / References thread a reply into the
20
41
  // conversation it answers.
21
42
  readonly headers?: Readonly<Record<string, string>>;
43
+ // Refs travel through jobs; the bytes are only read at send time.
44
+ readonly attachments?: readonly EmailAttachment[];
22
45
  };
23
46
 
24
47
  export type EmailTransport = {
@@ -20,7 +20,7 @@ export const markReadWrite = defineWriteHandler({
20
20
  description:
21
21
  "Marks one in-app message of the calling user as read by its numeric id, failing with not-found when no such message belongs to the caller; use it when a user opens a single notification.",
22
22
  handler: async (event, ctx) => {
23
- const rows = await updateMany(
23
+ const rows = await updateMany<{ id: number }>(
24
24
  ctx.db,
25
25
  inAppMessagesTable,
26
26
  { isRead: true, readAt: Temporal.Now.instant() },
@@ -29,6 +29,6 @@ export const markReadWrite = defineWriteHandler({
29
29
  if (rows.length === 0) {
30
30
  return writeFailure(new NotFoundError("inAppMessage", event.payload.id));
31
31
  }
32
- return { isSuccess: true, data: { id: (rows[0] as { id: number } | undefined)?.id } };
32
+ return { isSuccess: true, data: { id: rows[0]?.id } };
33
33
  },
34
34
  });
@@ -164,11 +164,7 @@ export const setWrite = defineWriteHandler({
164
164
  });
165
165
  }
166
166
  // scope="system" was already rejected above — narrow accordingly.
167
- const subject = resolvePiiSubject(
168
- scope as Exclude<typeof scope, "system">, // @cast-boundary runtime-checked above
169
- tenantId,
170
- userId,
171
- );
167
+ const subject = resolvePiiSubject(scope as Exclude<typeof scope, "system">, tenantId, userId);
172
168
  const kmsCtx: KmsContext = {
173
169
  requestId: requestContext.get()?.requestId ?? "config:write:set",
174
170
  tenantId,
@@ -31,9 +31,9 @@ export async function selectFieldDefinitionsForEntity(
31
31
  entityName: string,
32
32
  tenantId: string,
33
33
  ): Promise<readonly { field_key: string; serialized_field: unknown }[]> {
34
- return unsafeReadRetrying(
34
+ return unsafeReadRetrying<{ field_key: string; serialized_field: unknown }>(
35
35
  db,
36
36
  "SELECT field_key, serialized_field FROM read_custom_field_definitions WHERE entity_name = $1 AND tenant_id = $2",
37
37
  [entityName, tenantId],
38
- ) as Promise<readonly { field_key: string; serialized_field: unknown }[]>;
38
+ );
39
39
  }
@@ -3,6 +3,7 @@ import {
3
3
  type FieldDefinition,
4
4
  fieldToZod,
5
5
  } from "@cosmicdrift/kumiko-framework/engine";
6
+ import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
6
7
  import type * as z from "zod";
7
8
  import { SUPPORTED_FIELD_TYPES } from "../constants.js";
8
9
 
@@ -38,9 +39,8 @@ const SUPPORTED_EMBEDDED_SUB_TYPES: ReadonlySet<string> = new Set([
38
39
  const CONSTRAINT_KEYS = ["required", "maxLength", "format", "default"] as const;
39
40
 
40
41
  export function buildCustomFieldValueSchema(parsedField: unknown): z.ZodTypeAny | null {
41
- if (!parsedField || typeof parsedField !== "object") return null;
42
- const obj = parsedField as Record<string, unknown>;
43
- const rawType = obj["type"];
42
+ if (!isPlainObject(parsedField)) return null;
43
+ const rawType = parsedField["type"];
44
44
  if (typeof rawType !== "string") return null;
45
45
  if (!SUPPORTED_TYPES_SET.has(rawType)) return null;
46
46
 
@@ -54,35 +54,34 @@ export function buildCustomFieldValueSchema(parsedField: unknown): z.ZodTypeAny
54
54
  // drops everywhere else.
55
55
  let strippedSubSchema: Record<string, Record<string, unknown>> | undefined;
56
56
  if (rawType === "embedded") {
57
- const schema = obj["schema"];
57
+ const schema = parsedField["schema"];
58
58
  if (!schema || typeof schema !== "object") return null;
59
59
  strippedSubSchema = {};
60
60
  for (const [subKey, sub] of Object.entries(schema)) {
61
- if (!sub || typeof sub !== "object") return null;
62
- const subObj = sub as Record<string, unknown>;
63
- const subType = subObj["type"];
61
+ if (!isPlainObject(sub)) return null;
62
+ const subType = sub["type"];
64
63
  if (typeof subType !== "string" || !SUPPORTED_EMBEDDED_SUB_TYPES.has(subType)) {
65
64
  return null;
66
65
  }
67
- const strippedSub = { ...subObj };
66
+ const strippedSub = { ...sub };
68
67
  for (const k of CONSTRAINT_KEYS) delete strippedSub[k];
69
68
  strippedSubSchema[subKey] = strippedSub;
70
69
  }
71
70
  }
72
71
 
73
- const fieldDef: Record<string, unknown> = { ...obj };
72
+ const fieldDef: Record<string, unknown> = { ...parsedField };
74
73
  for (const k of CONSTRAINT_KEYS) delete fieldDef[k];
75
74
  if (strippedSubSchema !== undefined) fieldDef["schema"] = strippedSubSchema;
76
75
  if (rawType === "enum") {
77
76
  fieldDef["type"] = "select";
78
- fieldDef["options"] = obj["values"] ?? obj["options"] ?? [];
77
+ fieldDef["options"] = parsedField["values"] ?? parsedField["options"] ?? [];
79
78
  }
80
79
 
81
80
  // fieldToZod's money case validates `currency` against the passed list, not
82
81
  // a field-level key — so surface the field's own currency when it declares
83
82
  // one, else fall back to the framework defaults.
84
83
  const currencies =
85
- typeof obj["currency"] === "string" ? [obj["currency"] as string] : DEFAULT_CURRENCIES;
84
+ typeof parsedField["currency"] === "string" ? [parsedField["currency"]] : DEFAULT_CURRENCIES;
86
85
 
87
86
  // @cast-boundary serialized-field is the dehydrated r.field.X() output =
88
87
  // a FieldDefinition; fieldToZod reads only its type-specific keys (the
@@ -3,6 +3,7 @@
3
3
  import { type DbRunner, extractTableName } from "@cosmicdrift/kumiko-framework/db";
4
4
  import type { UserDataExportSnippet, UserDataHookCtx } from "@cosmicdrift/kumiko-framework/engine";
5
5
  import { EXT_USER_DATA, type FeatureRegistrar } from "@cosmicdrift/kumiko-framework/engine";
6
+ import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
6
7
  import { selectCustomFieldsHostRows } from "./db/queries/user-data-rights.js";
7
8
 
8
9
  export interface WireCustomFieldsUserDataRightsOptions {
@@ -17,14 +18,14 @@ interface CustomFieldsHostRow {
17
18
  }
18
19
 
19
20
  function asCustomFieldsHostRow(value: unknown): CustomFieldsHostRow | null {
20
- if (!value || typeof value !== "object" || Array.isArray(value)) return null;
21
- if (!("id" in value) || typeof value.id !== "string") return null;
22
- const record = value as Record<string, unknown>;
23
- const cf = record["custom_fields"] ?? record["customFields"];
21
+ if (!isPlainObject(value)) return null;
22
+ const id = value["id"];
23
+ if (typeof id !== "string") return null;
24
+ const cf = value["custom_fields"] ?? value["customFields"];
24
25
  if (cf === undefined) return null;
25
- if (cf === null) return { id: value.id, customFields: null };
26
+ if (cf === null) return { id, customFields: null };
26
27
  if (!cf || typeof cf !== "object" || Array.isArray(cf)) return null;
27
- return { id: value.id, customFields: Object.fromEntries(Object.entries(cf)) };
28
+ return { id, customFields: Object.fromEntries(Object.entries(cf)) };
28
29
  }
29
30
 
30
31
  // fw#2914 — selectCustomFieldsHostRows runs raw SQL against a dynamic host
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.363.0",
4
+ "type": "improvement",
5
+ "title": "data-retention exports computeCutoff",
6
+ "detail": "computeCutoff(keepFor, now) and its Instant type are exported from @cosmicdrift/kumiko-bundled-features/data-retention, so apps that compute a retention cutoff themselves (for example a backfill) use the same keepFor parsing as the cleanup job instead of a copy."
7
+ },
2
8
  {
3
9
  "version": "0.335.0",
4
10
  "type": "fix",
@@ -18,3 +18,4 @@ export {
18
18
  tenantRetentionOverrideEntity,
19
19
  tenantRetentionOverrideTable,
20
20
  } from "./feature.js";
21
+ export { computeCutoff, type Instant } from "./keep-for.js";
@@ -72,6 +72,7 @@ import {
72
72
  fileRefEntity,
73
73
  fileRefsTable,
74
74
  } from "@cosmicdrift/kumiko-framework/files";
75
+ import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
75
76
  import { runInSubTransaction } from "../shared/index.js";
76
77
  import { computeCutoff, type Instant } from "./keep-for.js";
77
78
  import type { RetentionPresetKey } from "./presets.js";
@@ -347,10 +348,9 @@ function collectSingleFileHolders(registry: Registry): readonly SingleFileHolder
347
348
  if (!entity) continue;
348
349
  const { singleFields } = planFileFields(entity.fields);
349
350
  if (singleFields.length === 0) continue;
350
- const table = proj.table as Record<string, unknown>; // @cast-boundary column-presence probe
351
351
  holders.push({
352
352
  table: proj.table,
353
- tableHasTenantId: table["tenantId"] !== undefined,
353
+ tableHasTenantId: propertyOf(proj.table, "tenantId") !== undefined,
354
354
  singleFields,
355
355
  });
356
356
  }
@@ -699,11 +699,10 @@ export async function runRetentionCleanup(
699
699
  const entity = registry.getEntity(entityName);
700
700
  if (!entity) continue;
701
701
 
702
- const table = proj.table as Record<string, unknown>; // @cast-boundary column-presence probe
703
702
  const declaredReference = policy.reference ?? DEFAULT_REFERENCE_FIELD;
704
703
  const referenceField = FRAMEWORK_REFERENCE_ALIAS[declaredReference] ?? declaredReference;
705
704
 
706
- if (table[referenceField] === undefined) {
705
+ if (propertyOf(proj.table, referenceField) === undefined) {
707
706
  skipped.push({ entityName, reason: "missing_reference_column" });
708
707
  continue;
709
708
  }
@@ -713,7 +712,7 @@ export async function runRetentionCleanup(
713
712
  // Tenant-Scope nur wenn die Tabelle eine tenantId-Spalte hat — identisch zu
714
713
  // soft-delete-cleanup. Ohne diesen Filter wuerde ein Tenant die Rows eines
715
714
  // anderen treffen.
716
- if (table["tenantId"] !== undefined) {
715
+ if (propertyOf(proj.table, "tenantId") !== undefined) {
717
716
  where["tenantId"] = tenantId;
718
717
  }
719
718
 
@@ -762,7 +761,10 @@ export async function runRetentionCleanup(
762
761
  break;
763
762
  }
764
763
  case "softDelete": {
765
- if (table["isDeleted"] === undefined || table["deletedAt"] === undefined) {
764
+ if (
765
+ propertyOf(proj.table, "isDeleted") === undefined ||
766
+ propertyOf(proj.table, "deletedAt") === undefined
767
+ ) {
766
768
  skipped.push({ entityName, reason: "missing_softdelete_columns" });
767
769
  break;
768
770
  }
@@ -0,0 +1,84 @@
1
+ import * as z from "zod";
2
+ import type {
3
+ FileAttachmentRef,
4
+ InlineAttachmentRef,
5
+ RenderedPdfAttachmentRef,
6
+ } from "../renderer-foundation/index.js";
7
+ import { attachmentFileNameSchema } from "../shared/index.js";
8
+ import { isDocumentResourceKey, templateSlugSchema } from "../template-resolver/index.js";
9
+
10
+ export type { FileAttachmentRef, InlineAttachmentRef, RenderedPdfAttachmentRef };
11
+ export type EmailAttachmentRef = InlineAttachmentRef | FileAttachmentRef | RenderedPdfAttachmentRef;
12
+
13
+ export const MAX_EMAIL_ATTACHMENTS = 20;
14
+
15
+ export const MAX_RENDERED_PDF_VARIABLES_BYTES = 64_000;
16
+
17
+ const MAX_ID_LENGTH = 64;
18
+
19
+ const fileRefIdSchema = z.string().min(1).max(MAX_ID_LENGTH);
20
+
21
+ export const inlineAttachmentRefSchema = z
22
+ .object({
23
+ kind: z.literal("inline"),
24
+ cid: z.string().min(1).max(MAX_ID_LENGTH).refine(isDocumentResourceKey),
25
+ fileRefId: fileRefIdSchema,
26
+ })
27
+ .strict();
28
+
29
+ export const fileAttachmentRefSchema = z
30
+ .object({
31
+ kind: z.literal("file"),
32
+ fileRefId: fileRefIdSchema,
33
+ fileName: attachmentFileNameSchema,
34
+ })
35
+ .strict();
36
+
37
+ export const renderedPdfAttachmentRefSchema = z
38
+ .object({
39
+ kind: z.literal("rendered-pdf"),
40
+ templateSlug: templateSlugSchema,
41
+ variables: z
42
+ .record(z.string(), z.unknown())
43
+ .refine(
44
+ (value) => Buffer.byteLength(JSON.stringify(value)) <= MAX_RENDERED_PDF_VARIABLES_BYTES,
45
+ {
46
+ message: `variables must not exceed ${MAX_RENDERED_PDF_VARIABLES_BYTES} bytes as JSON`,
47
+ },
48
+ ),
49
+ fileName: attachmentFileNameSchema.refine((name) => name.toLowerCase().endsWith(".pdf"), {
50
+ message: "file name must end with .pdf",
51
+ }),
52
+ locale: z
53
+ .string()
54
+ .min(2)
55
+ .max(8)
56
+ .regex(/^[a-z]{2}(-[a-z]{2})?$/i, "locale must be ISO 639-1 (e.g. de, en, en-us)")
57
+ .optional(),
58
+ })
59
+ .strict();
60
+
61
+ export const emailAttachmentRefSchema = z.discriminatedUnion("kind", [
62
+ inlineAttachmentRefSchema,
63
+ fileAttachmentRefSchema,
64
+ renderedPdfAttachmentRefSchema,
65
+ ]);
66
+
67
+ // Rebuilds only the contract fields so bytes a caller attached to a ref object
68
+ // can never ride into a job payload.
69
+ export function toAttachmentRef(ref: EmailAttachmentRef): EmailAttachmentRef {
70
+ switch (ref.kind) {
71
+ case "inline":
72
+ return { kind: "inline", cid: ref.cid, fileRefId: ref.fileRefId };
73
+ case "file":
74
+ return { kind: "file", fileRefId: ref.fileRefId, fileName: ref.fileName };
75
+ case "rendered-pdf":
76
+ return {
77
+ kind: "rendered-pdf",
78
+ templateSlug: ref.templateSlug,
79
+ variables: ref.variables,
80
+ fileName: ref.fileName,
81
+ ...(ref.locale !== undefined && { locale: ref.locale }),
82
+ };
83
+ }
84
+ }