@cosmicdrift/kumiko-samples 0.392.2 → 0.394.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 (186) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +15 -5
  3. package/packages/bundled-features/src/account-security/README.md +1 -1
  4. package/packages/bundled-features/src/account-security/changes.json +6 -0
  5. package/packages/bundled-features/src/account-security/feature.ts +9 -1
  6. package/packages/bundled-features/src/audit/event-classification.ts +1 -0
  7. package/packages/bundled-features/src/auth-email-password/changes.json +6 -0
  8. package/packages/bundled-features/src/auth-email-password/web/auth-client.ts +10 -52
  9. package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +4 -4
  10. package/packages/bundled-features/src/auth-email-password/web/auth-redirect.ts +3 -12
  11. package/packages/bundled-features/src/auth-email-password/web/index.ts +2 -0
  12. package/packages/bundled-features/src/auth-email-password/web/login-failure.ts +54 -0
  13. package/packages/bundled-features/src/auth-email-password/web/login-url.ts +19 -0
  14. package/packages/bundled-features/src/auth-email-password/web/static-login.ts +183 -0
  15. package/packages/bundled-features/src/auth-mfa/web/mfa-client.ts +7 -63
  16. package/packages/bundled-features/src/billing-foundation/changes.json +30 -0
  17. package/packages/bundled-features/src/billing-foundation/checkout-core.ts +8 -16
  18. package/packages/bundled-features/src/billing-foundation/events.ts +2 -0
  19. package/packages/bundled-features/src/billing-foundation/feature.ts +1 -1
  20. package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +9 -2
  21. package/packages/bundled-features/src/billing-foundation/handlers/process-payment-event.write.ts +2 -0
  22. package/packages/bundled-features/src/billing-foundation/index.ts +2 -0
  23. package/packages/bundled-features/src/billing-foundation/one-off-checkout-gate.ts +44 -0
  24. package/packages/bundled-features/src/billing-foundation/one-off-customer.ts +17 -0
  25. package/packages/bundled-features/src/billing-foundation/plan-catalog.ts +1 -0
  26. package/packages/bundled-features/src/billing-foundation/types.ts +26 -0
  27. package/packages/bundled-features/src/billing-foundation/web/billing-plans-panel.tsx +1 -0
  28. package/packages/bundled-features/src/billing-foundation/web/plan-switch-confirm-dialog.tsx +8 -2
  29. package/packages/bundled-features/src/billing-foundation/web/plan-switch-recurring.ts +23 -0
  30. package/packages/bundled-features/src/billing-foundation/webhook-handler.ts +1 -0
  31. package/packages/bundled-features/src/cap-overview/changes.json +6 -0
  32. package/packages/bundled-features/src/cap-overview/handlers/caps-usage.query.ts +3 -3
  33. package/packages/bundled-features/src/cap-overview/types.ts +2 -0
  34. package/packages/bundled-features/src/compliance-profiles/web/compliance-profile-catalog.tsx +10 -1
  35. package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +12 -3
  36. package/packages/bundled-features/src/files/changes.json +18 -0
  37. package/packages/bundled-features/src/inbound-mail-foundation/account-health.ts +146 -0
  38. package/packages/bundled-features/src/inbound-mail-foundation/account-poll-schedule.ts +74 -0
  39. package/packages/bundled-features/src/inbound-mail-foundation/account-poll.ts +238 -0
  40. package/packages/bundled-features/src/inbound-mail-foundation/account-record.ts +2 -0
  41. package/packages/bundled-features/src/inbound-mail-foundation/account-wake.ts +21 -0
  42. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +155 -0
  43. package/packages/bundled-features/src/inbound-mail-foundation/connect-callback-verify.ts +65 -0
  44. package/packages/bundled-features/src/inbound-mail-foundation/connect-route-responses.ts +26 -0
  45. package/packages/bundled-features/src/inbound-mail-foundation/connect-routes.ts +93 -62
  46. package/packages/bundled-features/src/inbound-mail-foundation/constants.ts +66 -3
  47. package/packages/bundled-features/src/inbound-mail-foundation/content-refetch.ts +137 -0
  48. package/packages/bundled-features/src/inbound-mail-foundation/describe-error.ts +5 -0
  49. package/packages/bundled-features/src/inbound-mail-foundation/entities.ts +45 -2
  50. package/packages/bundled-features/src/inbound-mail-foundation/events.ts +69 -2
  51. package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +133 -118
  52. package/packages/bundled-features/src/inbound-mail-foundation/file-storage-failure.ts +23 -0
  53. package/packages/bundled-features/src/inbound-mail-foundation/handlers/account-address.ts +79 -0
  54. package/packages/bundled-features/src/inbound-mail-foundation/handlers/acknowledge-quarantine.write.ts +55 -0
  55. package/packages/bundled-features/src/inbound-mail-foundation/handlers/attach-message-content.write.ts +184 -0
  56. package/packages/bundled-features/src/inbound-mail-foundation/handlers/connect-account.write.ts +137 -84
  57. package/packages/bundled-features/src/inbound-mail-foundation/handlers/disconnect-account.write.ts +65 -45
  58. package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message-prepare.ts +177 -0
  59. package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message-schema.ts +42 -0
  60. package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message.write.ts +79 -160
  61. package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-accounts.query.ts +93 -30
  62. package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-messages.query.ts +46 -30
  63. package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-quarantine.query.ts +106 -0
  64. package/packages/bundled-features/src/inbound-mail-foundation/handlers/mark-credential-stored.write.ts +47 -0
  65. package/packages/bundled-features/src/inbound-mail-foundation/handlers/message-content.query.ts +99 -0
  66. package/packages/bundled-features/src/inbound-mail-foundation/handlers/message-content.ts +229 -0
  67. package/packages/bundled-features/src/inbound-mail-foundation/handlers/message-detail.query.ts +34 -0
  68. package/packages/bundled-features/src/inbound-mail-foundation/handlers/reactivate-account.write.ts +129 -0
  69. package/packages/bundled-features/src/inbound-mail-foundation/handlers/retry-quarantined-message.write.ts +49 -0
  70. package/packages/bundled-features/src/inbound-mail-foundation/handlers/scope-visibility.ts +48 -9
  71. package/packages/bundled-features/src/inbound-mail-foundation/handlers/set-trusted-authserv-id.write.ts +66 -0
  72. package/packages/bundled-features/src/inbound-mail-foundation/handlers/thread-rollup.ts +136 -0
  73. package/packages/bundled-features/src/inbound-mail-foundation/handlers/trusted-authserv-id.ts +4 -0
  74. package/packages/bundled-features/src/inbound-mail-foundation/handlers/update-account.write.ts +82 -60
  75. package/packages/bundled-features/src/inbound-mail-foundation/i18n.ts +80 -1
  76. package/packages/bundled-features/src/inbound-mail-foundation/inbound-oauth-flow.ts +48 -0
  77. package/packages/bundled-features/src/inbound-mail-foundation/index.ts +94 -15
  78. package/packages/bundled-features/src/inbound-mail-foundation/ingest-failures.ts +137 -0
  79. package/packages/bundled-features/src/inbound-mail-foundation/ingest-payload.ts +46 -0
  80. package/packages/bundled-features/src/inbound-mail-foundation/mail-roles.ts +132 -0
  81. package/packages/bundled-features/src/inbound-mail-foundation/normalize-inbound-message.ts +119 -0
  82. package/packages/bundled-features/src/inbound-mail-foundation/oauth-access-token.ts +26 -8
  83. package/packages/bundled-features/src/inbound-mail-foundation/outbound-attachment-access.ts +28 -0
  84. package/packages/bundled-features/src/inbound-mail-foundation/outbound-attachments.ts +111 -0
  85. package/packages/bundled-features/src/inbound-mail-foundation/outbound-mail.ts +19 -1
  86. package/packages/bundled-features/src/inbound-mail-foundation/probe.ts +2 -1
  87. package/packages/bundled-features/src/inbound-mail-foundation/projection.ts +33 -2
  88. package/packages/bundled-features/src/inbound-mail-foundation/provider-errors.ts +74 -0
  89. package/packages/bundled-features/src/inbound-mail-foundation/quarantine-entities.ts +56 -0
  90. package/packages/bundled-features/src/inbound-mail-foundation/quarantine-header-import.ts +63 -0
  91. package/packages/bundled-features/src/inbound-mail-foundation/quarantine-retry.ts +137 -0
  92. package/packages/bundled-features/src/inbound-mail-foundation/quarantine-screens.ts +66 -0
  93. package/packages/bundled-features/src/inbound-mail-foundation/quarantine.ts +72 -0
  94. package/packages/bundled-features/src/inbound-mail-foundation/retention-job.ts +78 -0
  95. package/packages/bundled-features/src/inbound-mail-foundation/retention-sweep.ts +43 -8
  96. package/packages/bundled-features/src/inbound-mail-foundation/screens.ts +239 -79
  97. package/packages/bundled-features/src/inbound-mail-foundation/send-from-account.ts +24 -5
  98. package/packages/bundled-features/src/inbound-mail-foundation/sender-auth.ts +215 -0
  99. package/packages/bundled-features/src/inbound-mail-foundation/supervisor-ingest.ts +311 -0
  100. package/packages/bundled-features/src/inbound-mail-foundation/sync-account-now.ts +61 -0
  101. package/packages/bundled-features/src/inbound-mail-foundation/sync-cursor-store.ts +79 -0
  102. package/packages/bundled-features/src/inbound-mail-foundation/syncable-accounts.ts +123 -0
  103. package/packages/bundled-features/src/inbound-mail-foundation/tenant-destroy-hook.ts +5 -1
  104. package/packages/bundled-features/src/inbound-mail-foundation/types.ts +76 -105
  105. package/packages/bundled-features/src/inbound-mail-foundation/wake-consumer.ts +33 -0
  106. package/packages/bundled-features/src/inbound-mail-foundation/watch-catch-up.ts +68 -0
  107. package/packages/bundled-features/src/inbound-mail-foundation/watch-lease.ts +124 -0
  108. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor-types.ts +87 -0
  109. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +143 -433
  110. package/packages/bundled-features/src/inbound-mail-foundation/web/client-plugin.ts +73 -0
  111. package/packages/bundled-features/src/inbound-mail-foundation/web/constants.ts +7 -0
  112. package/packages/bundled-features/src/inbound-mail-foundation/web/index.ts +5 -0
  113. package/packages/bundled-features/src/inbound-provider-imap/changes.json +42 -0
  114. package/packages/bundled-features/src/inbound-provider-imap/connect-mailbox.ts +155 -63
  115. package/packages/bundled-features/src/inbound-provider-imap/connect-my-mailbox.ts +45 -0
  116. package/packages/bundled-features/src/inbound-provider-imap/constants.ts +14 -1
  117. package/packages/bundled-features/src/inbound-provider-imap/credential-document.ts +2 -1
  118. package/packages/bundled-features/src/inbound-provider-imap/download-batch.ts +38 -0
  119. package/packages/bundled-features/src/inbound-provider-imap/feature.ts +75 -44
  120. package/packages/bundled-features/src/inbound-provider-imap/fetch-content.ts +84 -0
  121. package/packages/bundled-features/src/inbound-provider-imap/i18n.ts +28 -2
  122. package/packages/bundled-features/src/inbound-provider-imap/imap-client.ts +35 -84
  123. package/packages/bundled-features/src/inbound-provider-imap/index.ts +1 -2
  124. package/packages/bundled-features/src/inbound-provider-imap/mime-content.ts +85 -0
  125. package/packages/bundled-features/src/inbound-provider-imap/parse-message.ts +149 -0
  126. package/packages/bundled-features/src/inbound-provider-imap/reconnect-account.ts +45 -0
  127. package/packages/bundled-features/src/inbound-provider-imap/reconnect-mailbox.ts +103 -0
  128. package/packages/bundled-features/src/inbound-provider-imap/reconnect-prefill.query.ts +49 -0
  129. package/packages/bundled-features/src/inbound-provider-imap/screens.ts +209 -42
  130. package/packages/bundled-features/src/inbound-provider-imap/send-mail.ts +11 -2
  131. package/packages/bundled-features/src/inbound-provider-imap/sender-auth-headers.ts +36 -0
  132. package/packages/bundled-features/src/inbound-provider-inmemory/feature.ts +75 -3
  133. package/packages/bundled-features/src/inbound-provider-inmemory/index.ts +6 -0
  134. package/packages/bundled-features/src/jobs/changes.json +18 -0
  135. package/packages/bundled-features/src/managed-pages/web/i18n.ts +1 -0
  136. package/packages/bundled-features/src/subscription-paddle/changes.json +15 -0
  137. package/packages/bundled-features/src/subscription-paddle/checkout-config.query.ts +17 -0
  138. package/packages/bundled-features/src/subscription-paddle/checkout-methods.ts +107 -0
  139. package/packages/bundled-features/src/subscription-paddle/checkout-page.ts +154 -0
  140. package/packages/bundled-features/src/subscription-paddle/client.ts +78 -0
  141. package/packages/bundled-features/src/subscription-paddle/constants.ts +32 -0
  142. package/packages/bundled-features/src/subscription-paddle/feature.ts +265 -0
  143. package/packages/bundled-features/src/subscription-paddle/index.ts +17 -0
  144. package/packages/bundled-features/src/subscription-paddle/plan-switch-methods.ts +135 -0
  145. package/packages/bundled-features/src/subscription-paddle/price-methods.ts +148 -0
  146. package/packages/bundled-features/src/subscription-paddle/runtime.ts +203 -0
  147. package/packages/bundled-features/src/subscription-paddle/subscription-state.ts +73 -0
  148. package/packages/bundled-features/src/subscription-paddle/tenant-binding.ts +34 -0
  149. package/packages/bundled-features/src/subscription-paddle/verify-webhook.ts +61 -0
  150. package/packages/bundled-features/src/subscription-paddle/webhook-events.ts +172 -0
  151. package/packages/bundled-features/src/template-resolver/changes.json +12 -0
  152. package/packages/bundled-features/src/template-resolver/handlers/collection-item.query.ts +2 -2
  153. package/packages/bundled-features/src/template-resolver/handlers/collection-list.query.ts +6 -2
  154. package/packages/bundled-features/src/template-resolver/handlers/collection-shared.ts +25 -0
  155. package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +218 -117
  156. package/packages/bundled-features/src/template-resolver/web/i18n.ts +1 -2
  157. package/packages/bundled-features/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +2 -1
  158. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-constants.ts +4 -0
  159. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +11 -5
  160. package/samples/apps/use-all-bundled/e2e-billing-gallery/server.ts +2 -2
  161. package/samples/apps/use-all-bundled/feature-manifest.json +89 -39
  162. package/samples/apps/use-all-bundled/kumiko/migrations/0056_inbound-mail-quarantine-retry.rebuild.json +4 -0
  163. package/samples/apps/use-all-bundled/package.json +1 -0
  164. package/samples/apps/use-all-bundled/playwright.billing-docs.config.ts +15 -0
  165. package/samples/apps/use-all-bundled/src/app/cap-overview-caps.ts +16 -2
  166. package/samples/apps/use-all-bundled/src/app/cap-overview-labels-feature.ts +2 -1
  167. package/samples/apps/use-all-bundled/src/app/client.tsx +2 -0
  168. package/samples/apps/use-all-bundled/src/app/screens-feature.ts +14 -2
  169. package/samples/apps/use-all-bundled/src/app/seed.ts +4 -1
  170. package/samples/apps/use-all-bundled/src/run-config.ts +7 -5
  171. package/samples/recipes/cross-feature-events/README.md +10 -0
  172. package/samples/recipes/cross-feature-events/src/feature.ts +14 -0
  173. package/samples/recipes/dashboard-stats/README.md +8 -0
  174. package/samples/recipes/dashboard-stats/src/feature.ts +91 -1
  175. package/samples/recipes/env-composition/README.md +2 -2
  176. package/samples/recipes/env-composition/src/env.ts +2 -2
  177. package/samples/recipes/inbound-mail-triage/README.md +12 -0
  178. package/samples/recipes/pdf-audit-export/src/feature.ts +71 -48
  179. package/packages/bundled-features/src/subscription-dodo/changes.json +0 -14
  180. package/packages/bundled-features/src/subscription-dodo/client.ts +0 -45
  181. package/packages/bundled-features/src/subscription-dodo/constants.ts +0 -24
  182. package/packages/bundled-features/src/subscription-dodo/feature.ts +0 -207
  183. package/packages/bundled-features/src/subscription-dodo/index.ts +0 -16
  184. package/packages/bundled-features/src/subscription-dodo/plugin-methods.ts +0 -345
  185. package/packages/bundled-features/src/subscription-dodo/runtime.ts +0 -126
  186. package/packages/bundled-features/src/subscription-dodo/verify-webhook.ts +0 -262
@@ -1,52 +1,28 @@
1
1
  // kumiko-feature-version: 1
2
- //
3
- // inbound-mail-foundation — Plugin host for inbound e-mail
4
- // (inbox federation). Counterpart to mail-foundation (outbound): separate
5
- // extension points, separate state — architectural model is
6
- // file-foundation (the foundation owns state), not mail-transport.
7
- //
8
- // **Multi-provider from day one** (as with billing-foundation): NO global
9
- // `provider` config key. A tenant can connect IMAP and (later) M365/Gmail
10
- // mailboxes in parallel; the provider is specified per MailAccount.
2
+ // inbound-mail-foundation: plugin host for inbound e-mail (inbox federation), the counterpart to
3
+ // mail-foundation (outbound) with its own extension points and state. Modelled on file-foundation
4
+ // (the foundation owns state), not on mail-transport.
11
5
  //
12
6
  // **What this foundation provides:**
13
- // 1. **Plugin API** for inbound providers via r.extendsRegistrar
14
- // ("inboundMailProvider"): verify/fetch/oauth?/watch? (types.ts).
7
+ // 1. **Plugin API** for providers via r.extendsRegistrar: verify/fetch/oauth?/watch? (types.ts).
15
8
  // 2. **3 event-sourced streams** — mail-account (lifecycle),
16
9
  // inbound-message (exactly ONE received event per message, deterministic
17
10
  // aggregateId ⇒ exactly-once despite at-least-once ingest),
18
- // mail-thread (rollup). Full replayable history for later
19
- // consumers (business processes: ticket, receipt, auto-reply).
20
- // 3. **Inline projections** into read_mail_accounts /
21
- // read_inbound_messages / read_mail_threads (read-your-own-write).
11
+ // mail-thread (rollup), fully replayable for later consumers.
12
+ // 3. **Inline projections** into read_mail_accounts/_inbound_messages/_mail_threads (RYOW).
22
13
  // 4. **Write/query handlers**: connect/update/disconnect-account,
23
14
  // ingest-message (programmatic, SystemAdmin), account:list +
24
- // message:list (PII-decrypted, scope-filtered).
25
- // 5. **createInboundMailConnectRoutes**: OAuth connect + anonymous
26
- // callback (outside /api/*), with HMAC-signed state.
27
- // 6. **createInboundMailSupervisor**: app-wired watch (IDLE)
28
- // supervisor with backoff + reconciliation poll.
15
+ // message:list (PII-decrypted; personal mail only for its owner).
29
16
  //
30
- // **What this foundation does NOT do:**
31
- // - No business semantics (intent, ticket, assignment, read/
32
- // completed) — those belong to the app consumer (for example, via an
33
- // event job on inbound-message-received + its own projection).
34
- // - No body blob in the event store: rawMime → file-foundation via
35
- // the Supervisor-storeBody-Hook; the event carries only bodyRef.
36
- // - No IMAP flag write-back (V1) — inbox status belongs to the consumer.
17
+ // **Not here:** business semantics (intent, ticket, assignment, read/completed) belong to the
18
+ // app consumer, for example an event job on inbound-message-received with its own projection.
37
19
  //
38
- // **PII/GDPR:** address/from/to/cc/subject/snippet are `personal: "tenant"`
39
- // — encrypted BEFORE every event append (the only write path), event log AND
40
- // projections carry ciphertext; tenant-destroy shreds the subject key
41
- // (crypto-shredding, the same pattern as billing-foundation). The destroy
42
- // hook additionally archives all streams + deletes the rows.
20
+ // **PII/GDPR:** address/from/to/cc/subject/snippet are `personal: "tenant"`, encrypted BEFORE every
21
+ // event append (the only write path), so event log AND projections carry ciphertext; tenant-destroy
22
+ // shreds the subject key (crypto-shredding, as in billing-foundation) and its hook additionally
23
+ // archives all streams and deletes the rows.
43
24
 
44
- import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
45
25
  import { defineFeature, EXT_TENANT_DATA } from "@cosmicdrift/kumiko-framework/engine";
46
- import {
47
- createFileProviderForTenant,
48
- type FileStorageProvider,
49
- } from "@cosmicdrift/kumiko-framework/files";
50
26
  import { INBOUND_MAIL_FOUNDATION_FEATURE, INBOUND_MAIL_PROVIDER_EXTENSION } from "./constants.ts";
51
27
  import {
52
28
  inboundMessageEntity,
@@ -57,8 +33,11 @@ import {
57
33
  } from "./entities.ts";
58
34
  import {
59
35
  INBOUND_MESSAGE_AGGREGATE_TYPE,
36
+ INBOUND_MESSAGE_CONTENT_FETCHED_EVENT_QN,
37
+ INBOUND_MESSAGE_CONTENT_FETCHED_EVENT_SHORT,
60
38
  INBOUND_MESSAGE_RECEIVED_EVENT_QN,
61
39
  INBOUND_MESSAGE_RECEIVED_EVENT_SHORT,
40
+ inboundMessageContentFetchedEventPayloadSchema,
62
41
  inboundMessageEventPayloadSchema,
63
42
  MAIL_ACCOUNT_AGGREGATE_TYPE,
64
43
  MAIL_ACCOUNT_CONNECTED_EVENT_QN,
@@ -73,15 +52,31 @@ import {
73
52
  mailAccountEventPayloadSchema,
74
53
  mailThreadEventPayloadSchema,
75
54
  } from "./events.ts";
76
- import { connectAccountHandler } from "./handlers/connect-account.write.ts";
77
- import { disconnectAccountHandler } from "./handlers/disconnect-account.write.ts";
55
+ import { createAcknowledgeQuarantineHandler } from "./handlers/acknowledge-quarantine.write.ts";
56
+ import { attachMessageContentHandler } from "./handlers/attach-message-content.write.ts";
57
+ import { createConnectAccountHandler } from "./handlers/connect-account.write.ts";
58
+ import { createDisconnectAccountHandler } from "./handlers/disconnect-account.write.ts";
78
59
  import { ingestMessageHandler } from "./handlers/ingest-message.write.ts";
79
- import { listAccountsQuery } from "./handlers/list-accounts.query.ts";
80
- import { listMessagesQuery } from "./handlers/list-messages.query.ts";
81
- import { updateAccountHandler } from "./handlers/update-account.write.ts";
60
+ import { createListAccountsQuery } from "./handlers/list-accounts.query.ts";
61
+ import { createListMessagesQuery } from "./handlers/list-messages.query.ts";
62
+ import { createListQuarantineQuery } from "./handlers/list-quarantine.query.ts";
63
+ import { markCredentialStoredHandler } from "./handlers/mark-credential-stored.write.ts";
64
+ import { createMessageContentQuery } from "./handlers/message-content.query.ts";
65
+ import { createMessageDetailQuery } from "./handlers/message-detail.query.ts";
66
+ import { createReactivateAccountHandler } from "./handlers/reactivate-account.write.ts";
67
+ import { createRetryQuarantinedMessageHandler } from "./handlers/retry-quarantined-message.write.ts";
68
+ import { createSetTrustedAuthservIdHandler } from "./handlers/set-trusted-authserv-id.write.ts";
69
+ import { createUpdateAccountHandler } from "./handlers/update-account.write.ts";
82
70
  import { INBOUND_MAIL_FOUNDATION_I18N } from "./i18n.ts";
71
+ import { ingestFailureTable } from "./ingest-failures.ts";
72
+ import {
73
+ type InboundMailRoleOptions,
74
+ resolveInboundMailRoles,
75
+ roleDedupeOptions,
76
+ } from "./mail-roles.ts";
83
77
  import { registerMailboxProbe } from "./probe.ts";
84
78
  import {
79
+ applyInboundMessageContentFetched,
85
80
  applyInboundMessageReceived,
86
81
  applyMailAccountConnected,
87
82
  applyMailAccountDisconnected,
@@ -91,16 +86,20 @@ import {
91
86
  mailAccountsProjectionTable,
92
87
  mailThreadsProjectionTable,
93
88
  } from "./projection.ts";
94
- import { runInboundMailRetention } from "./retention-sweep.ts";
95
- import { mailAccountEditScreen, mailAccountListScreen } from "./screens.ts";
89
+ import { quarantineTable } from "./quarantine-entities.ts";
90
+ import { createMailQuarantineScreen } from "./quarantine-screens.ts";
91
+ import { registerInboundMailRetentionJob } from "./retention-job.ts";
92
+ import { createMailAccountScreens } from "./screens.ts";
96
93
  import {
97
94
  INBOUND_MAIL_TENANT_DESTROY_ARCHIVE_REASON,
98
95
  inboundMessageTenantDestroyHook,
99
96
  mailAccountTenantDestroyHook,
100
97
  mailThreadTenantDestroyHook,
101
98
  } from "./tenant-destroy-hook.ts";
99
+ import { registerMailAccountWakeConsumer } from "./wake-consumer.ts";
102
100
 
103
- export function createInboundMailFoundationFeature() {
101
+ export function createInboundMailFoundationFeature(options: InboundMailRoleOptions = {}) {
102
+ const roles = resolveInboundMailRoles(options);
104
103
  return defineFeature(
105
104
  INBOUND_MAIL_FOUNDATION_FEATURE,
106
105
  (r) => {
@@ -116,28 +115,84 @@ export function createInboundMailFoundationFeature() {
116
115
  // The EXT_TENANT_DATA hook requires the tenant-lifecycle host.
117
116
  r.requires({ features: ["tenant-lifecycle"] });
118
117
 
119
- // 5 fine-grained domain-events (Payload-Schemas: events.ts).
120
118
  // piiFields: "none" — address/subject/snippet are already ciphertext under the tenant subject key before append (see events.ts).
121
119
  // "displayName" stays plaintext (often the mailbox owner's real name for
122
120
  // personal accounts); encrypting it would break the entity-projection read
123
121
  // path that carries it as `personal: false`.
122
+ // v2: lastError, trustedAuthservId. Defaults are shared references across upcast events, so none of them is mutated.
123
+ const mailAccountMigrations = [
124
+ {
125
+ fromVersion: 1,
126
+ toVersion: 2,
127
+ transform: { default: { lastError: null, trustedAuthservId: null } },
128
+ },
129
+ ] as const;
124
130
  r.defineEvent(MAIL_ACCOUNT_CONNECTED_EVENT_SHORT, mailAccountEventPayloadSchema, {
125
131
  piiFields: "none",
132
+ version: 2,
133
+ migrations: mailAccountMigrations,
126
134
  });
127
135
  r.defineEvent(MAIL_ACCOUNT_UPDATED_EVENT_SHORT, mailAccountEventPayloadSchema, {
128
136
  piiFields: "none",
137
+ version: 2,
138
+ migrations: mailAccountMigrations,
129
139
  });
130
140
  r.defineEvent(MAIL_ACCOUNT_DISCONNECTED_EVENT_SHORT, mailAccountEventPayloadSchema, {
131
141
  piiFields: "none",
142
+ version: 2,
143
+ migrations: mailAccountMigrations,
132
144
  });
145
+ // v1 mails carry the snippet only: bodyText "" and contentState "snippet" let the content
146
+ // refetch find them. v3 adds the reason a message has no content.
133
147
  r.defineEvent(INBOUND_MESSAGE_RECEIVED_EVENT_SHORT, inboundMessageEventPayloadSchema, {
134
148
  piiFields: "none",
149
+ version: 3,
150
+ migrations: [
151
+ {
152
+ fromVersion: 1,
153
+ toVersion: 2,
154
+ transform: {
155
+ default: {
156
+ bodyText: "",
157
+ bodyTextTruncated: false,
158
+ attachmentFileIds: [],
159
+ skippedAttachments: [],
160
+ contentState: "snippet",
161
+ senderAuthentication: null,
162
+ },
163
+ },
164
+ },
165
+ {
166
+ fromVersion: 2,
167
+ toVersion: 3,
168
+ transform: { default: { contentUnavailableReason: null } },
169
+ },
170
+ ],
135
171
  });
172
+ r.defineEvent(
173
+ INBOUND_MESSAGE_CONTENT_FETCHED_EVENT_SHORT,
174
+ inboundMessageContentFetchedEventPayloadSchema,
175
+ {
176
+ piiFields: "none",
177
+ version: 3,
178
+ migrations: [
179
+ {
180
+ fromVersion: 1,
181
+ toVersion: 2,
182
+ transform: { default: { contentUnavailableReason: null } },
183
+ },
184
+ {
185
+ fromVersion: 2,
186
+ toVersion: 3,
187
+ transform: { default: { accountId: null, ownerUserId: null } },
188
+ },
189
+ ],
190
+ },
191
+ );
136
192
  r.defineEvent(MAIL_THREAD_UPDATED_EVENT_SHORT, mailThreadEventPayloadSchema, {
137
193
  piiFields: "none",
138
194
  });
139
195
 
140
- // Inline projections — apply in the same transaction as the append.
141
196
  r.projection({
142
197
  name: "mail-account",
143
198
  source: MAIL_ACCOUNT_AGGREGATE_TYPE,
@@ -156,6 +211,7 @@ export function createInboundMailFoundationFeature() {
156
211
  entity: inboundMessageEntity,
157
212
  apply: {
158
213
  [INBOUND_MESSAGE_RECEIVED_EVENT_QN]: applyInboundMessageReceived,
214
+ [INBOUND_MESSAGE_CONTENT_FETCHED_EVENT_QN]: applyInboundMessageContentFetched,
159
215
  },
160
216
  });
161
217
  r.projection({
@@ -168,6 +224,8 @@ export function createInboundMailFoundationFeature() {
168
224
  },
169
225
  });
170
226
 
227
+ registerMailAccountWakeConsumer(r);
228
+
171
229
  // Plugin extension point — providers register via
172
230
  // r.useExtension("inboundMailProvider", "<key>", plugin).
173
231
  r.extendsRegistrar(INBOUND_MAIL_PROVIDER_EXTENSION, {
@@ -187,22 +245,34 @@ export function createInboundMailFoundationFeature() {
187
245
  reason: "read_side.mail_seen_messages_direct_write",
188
246
  });
189
247
 
190
- // Write-Handler:
191
- // - ingest-message: programmatic (Supervisor/Poll, SystemAdmin)
192
- // - connect/update/disconnect-account: Lifecycle
248
+ r.storeTable(quarantineTable, {
249
+ reason: "read_side.mail_quarantine_direct_write",
250
+ });
251
+ r.storeTable(ingestFailureTable, {
252
+ reason: "read_side.mail_ingest_failures_direct_write",
253
+ });
254
+
255
+ // ingest-message is programmatic (supervisor/poll, SystemAdmin); the rest is account lifecycle.
193
256
  r.writeHandler(ingestMessageHandler);
194
- r.writeHandler(connectAccountHandler);
195
- r.writeHandler(updateAccountHandler);
196
- r.writeHandler(disconnectAccountHandler);
257
+ r.writeHandler(createConnectAccountHandler(roles));
258
+ r.writeHandler(createUpdateAccountHandler(roles));
259
+ r.writeHandler(createDisconnectAccountHandler(roles));
260
+ r.writeHandler(createReactivateAccountHandler(roles));
261
+ r.writeHandler(createSetTrustedAuthservIdHandler(roles));
262
+ r.writeHandler(createAcknowledgeQuarantineHandler(roles));
263
+ r.writeHandler(createRetryQuarantinedMessageHandler(roles));
264
+ r.writeHandler(attachMessageContentHandler);
265
+ r.writeHandler(markCredentialStoredHandler);
197
266
 
198
- // List-Queries (PII-decrypted, scope-gefiltert).
199
- r.queryHandler(listAccountsQuery);
200
- r.queryHandler(listMessagesQuery);
267
+ r.queryHandler(createListAccountsQuery(roles));
268
+ r.queryHandler(createListMessagesQuery(roles));
269
+ r.queryHandler(createListQuarantineQuery(roles));
270
+ r.queryHandler(createMessageContentQuery(roles));
271
+ r.queryHandler(createMessageDetailQuery(roles));
201
272
 
202
- // Declarative mailbox admin: list (rename, disconnect) and rename form.
203
- // The app places `mailAccountListScreen` via r.nav in its settings area.
204
- r.screen(mailAccountListScreen);
205
- r.screen(mailAccountEditScreen);
273
+ // The app places the mailbox lists via r.nav (admins in settings, users next to their connect flow).
274
+ for (const screen of createMailAccountScreens(roles)) r.screen(screen);
275
+ r.screen(createMailQuarantineScreen(roles));
206
276
  r.translations({ keys: INBOUND_MAIL_FOUNDATION_I18N });
207
277
  registerMailboxProbe(r);
208
278
 
@@ -221,64 +291,9 @@ export function createInboundMailFoundationFeature() {
221
291
  escapeHatch: { reason: INBOUND_MAIL_TENANT_DESTROY_ARCHIVE_REASON },
222
292
  });
223
293
 
224
- // Data-Retention — perTenant fan-out (one run per tenant, like
225
- // data-retention:retention-cleanup): runInboundMailRetention's raw query
226
- // helpers need the unfiltered runner, so the handler declares escapeHatch
227
- // and reaches for ctx.db.unsafeRaw(). Without this cron, read_inbound_messages
228
- // + the bodies grow unbounded (GDPR Art. 5).
229
- const INBOUND_MAIL_RETENTION_REASON =
230
- "inbound-mail retention runs raw deletes; every statement filters by this run's tenantId";
231
- r.job({
232
- name: "inbound-mail-retention",
233
- trigger: { cron: "0 3 * * *" },
234
- perTenant: true,
235
- concurrency: "skip",
236
- escapeHatch: { reason: INBOUND_MAIL_RETENTION_REASON },
237
- handler: async (_payload, ctx) => {
238
- if (!ctx.registry) {
239
- throw new Error(
240
- "inbound-mail-retention: ctx.registry required (JobContext incomplete)",
241
- );
242
- }
243
- const tenantId = ctx.systemUser?.tenantId ?? ctx._tenantId;
244
- if (tenantId === undefined) {
245
- // skip: cron fired without a perTenant fan-out tenant — nothing scoped
246
- return;
247
- }
248
- const T = (await import("@cosmicdrift/kumiko-framework/time")).getTemporal();
249
- const retentionDb = ctx.db.unsafeRaw() as DbConnection; // @cast-boundary db-operator — jobs never run inside a DbTx
250
-
251
- // Body objects are stored in file-foundation. The provider is lazy and memoized —
252
- // ingest currently writes no bodyRef (the storeBody hook is not wired),
253
- // so the provider is never built. Once storeBody is wired, retention
254
- // also cleans up the bodies.
255
- let providerPromise: Promise<FileStorageProvider> | null = null;
256
- const deleteBodyObject = async (bodyRef: string): Promise<void> => {
257
- if (!providerPromise) {
258
- providerPromise = createFileProviderForTenant(
259
- { config: ctx.config, registry: ctx.registry, secrets: ctx.services.secrets },
260
- tenantId,
261
- );
262
- }
263
- const provider = await providerPromise;
264
- await provider.delete(bodyRef);
265
- };
266
-
267
- const report = await runInboundMailRetention({
268
- db: retentionDb,
269
- tenantId,
270
- now: T.Now.instant(),
271
- deleteBodyObject,
272
- });
273
- if (report.bodyObjectErrors > 0) {
274
- // biome-ignore lint/suspicious/noConsole: operator-visibility for failed body deletes
275
- console.warn(
276
- `[inbound-mail-foundation:inbound-mail-retention] tenant=${tenantId} bodyObjectErrors=${report.bodyObjectErrors}`,
277
- );
278
- }
279
- },
280
- });
294
+ registerInboundMailRetentionJob(r);
295
+ return { roles };
281
296
  },
282
- { dedupeOptions: {} },
297
+ { dedupeOptions: roleDedupeOptions(roles) },
283
298
  );
284
299
  }
@@ -0,0 +1,23 @@
1
+ import { KumikoError } from "@cosmicdrift/kumiko-framework/errors";
2
+ import { errorCodeOf } from "@cosmicdrift/kumiko-types/type-guards";
3
+
4
+ export const FILE_STORAGE_WRITE_FAILED_CODE = "file_storage_write_failed";
5
+
6
+ // Own code on purpose: the supervisor treats validation/unprocessable as a poison mail and
7
+ // quarantines it, but a storage outage says nothing about the mail.
8
+ export class FileStorageWriteFailedError extends KumikoError {
9
+ readonly code = FILE_STORAGE_WRITE_FAILED_CODE;
10
+ readonly httpStatus = 503;
11
+
12
+ constructor(cause?: Error) {
13
+ super({
14
+ message: "inbound mail content could not be written to file storage",
15
+ i18nKey: "errors.internal",
16
+ ...(cause && { cause }),
17
+ });
18
+ }
19
+ }
20
+
21
+ export function isFileStorageWriteFailure(error: unknown): boolean {
22
+ return errorCodeOf(error) === FILE_STORAGE_WRITE_FAILED_CODE;
23
+ }
@@ -0,0 +1,79 @@
1
+ // Address lookup across the tenant's mailboxes. Addresses are ciphertext in the projection, so the
2
+ // comparison happens in memory after decrypting (a tenant has a handful of mailboxes).
3
+
4
+ import {
5
+ configuredPiiSubjectKms,
6
+ decryptPiiFieldValues,
7
+ } from "@cosmicdrift/kumiko-framework/crypto";
8
+ import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
9
+ import { InboundMailAccountStatuses, InboundMailAuthMethods } from "../constants.ts";
10
+ import { MAIL_ACCOUNT_PII_FIELDS } from "../entities.ts";
11
+ import { mailAccountsProjectionTable } from "../projection.ts";
12
+
13
+ export type AddressMatch = {
14
+ readonly id: string;
15
+ readonly provider: string;
16
+ readonly authMethod: string;
17
+ readonly ownerUserId: string | null;
18
+ readonly status: string;
19
+ };
20
+
21
+ export function normalizeMailAddress(address: string): string {
22
+ return address.trim().toLowerCase();
23
+ }
24
+
25
+ function textOf(value: unknown): string {
26
+ return typeof value === "string" ? value : "";
27
+ }
28
+
29
+ // A live mailbox wins over a disconnected one: connecting next to a live mailbox is always a duplicate.
30
+ export async function findMailboxByAddress(
31
+ ctx: Pick<HandlerContext, "db">,
32
+ tenantId: string,
33
+ address: string,
34
+ ): Promise<AddressMatch | undefined> {
35
+ const wanted = normalizeMailAddress(address);
36
+ const rows = await ctx.db.selectMany(mailAccountsProjectionTable, { tenantId });
37
+ const piiKms = configuredPiiSubjectKms();
38
+ const matches: AddressMatch[] = [];
39
+ for (const row of rows) {
40
+ const readable = piiKms
41
+ ? await decryptPiiFieldValues(row, MAIL_ACCOUNT_PII_FIELDS, piiKms, {
42
+ requestId: `inbound-mail-foundation:connect-account:${tenantId}`,
43
+ })
44
+ : row;
45
+ if (normalizeMailAddress(textOf(readable["address"])) !== wanted) continue;
46
+ matches.push({
47
+ id: textOf(row["id"]),
48
+ provider: textOf(row["provider"]),
49
+ authMethod: textOf(row["authMethod"]),
50
+ ownerUserId: typeof row["ownerUserId"] === "string" ? row["ownerUserId"] : null,
51
+ status: textOf(row["status"]),
52
+ });
53
+ }
54
+ return matches.find((m) => m.status !== InboundMailAccountStatuses.disconnected) ?? matches[0];
55
+ }
56
+
57
+ export type ConnectConflict = "already_connected" | "address_disconnected" | "reopen_in_place";
58
+
59
+ // A disconnected or sign-in-failed OAuth mailbox of the same provider and owner is reopened (the
60
+ // callback writes the new token into the same slot); everything else must not silently create a second stream for one address.
61
+ export function connectConflictOf(
62
+ existing: AddressMatch,
63
+ wanted: {
64
+ readonly authMethod: string;
65
+ readonly provider: string;
66
+ readonly ownerUserId: string | null;
67
+ },
68
+ ): ConnectConflict {
69
+ const disconnected = existing.status === InboundMailAccountStatuses.disconnected;
70
+ // A sign-in error of an OAuth mailbox is healed by signing in again, so it reopens like a disconnect.
71
+ const signInFailed = existing.status === InboundMailAccountStatuses.authError;
72
+ if (!disconnected && !signInFailed) return "already_connected";
73
+ const bothOauth =
74
+ wanted.authMethod === InboundMailAuthMethods.oauth &&
75
+ existing.authMethod === InboundMailAuthMethods.oauth &&
76
+ existing.provider === wanted.provider;
77
+ if (!bothOauth) return disconnected ? "address_disconnected" : "already_connected";
78
+ return existing.ownerUserId === wanted.ownerUserId ? "reopen_in_place" : "already_connected";
79
+ }
@@ -0,0 +1,55 @@
1
+ // acknowledge-quarantine — the mailbox owner (an admin for a shared mailbox) has seen quarantined mails. Open entries (all of
2
+ // the mailbox, or just one when providerMessageId is given) are marked acknowledged; once none is left
3
+ // the supervisor returns the mailbox to active.
4
+
5
+ import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
6
+ import {
7
+ AccessDeniedError,
8
+ failNotFound,
9
+ writeFailure,
10
+ } from "@cosmicdrift/kumiko-framework/errors";
11
+ import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
12
+ import * as z from "zod";
13
+ import type { InboundMailRoles } from "../mail-roles.ts";
14
+ import { quarantineTable } from "../quarantine-entities.ts";
15
+ import { loadCurrentMailAccountPayload } from "./account-state.ts";
16
+ import { mayManageQuarantine } from "./scope-visibility.ts";
17
+
18
+ export const acknowledgeQuarantineSchema = z.object({
19
+ accountId: z.uuid(),
20
+ providerMessageId: z.string().min(1).max(500).optional(),
21
+ });
22
+
23
+ export function createAcknowledgeQuarantineHandler({
24
+ memberRoles,
25
+ adminRoles,
26
+ }: InboundMailRoles): WriteHandlerDef {
27
+ return {
28
+ name: "acknowledge-quarantine",
29
+ schema: acknowledgeQuarantineSchema,
30
+ access: { roles: [...memberRoles] },
31
+ agent: { expose: false },
32
+ description:
33
+ "Marks the open quarantine entries of a mailbox as acknowledged, all of them or only the one mail named by providerMessageId, i.e. the mails the import could not take have been looked at; the mailbox leaves the interrupted state on the next sync. The owner of a personal mailbox or, for a shared one, an admin may call it. Returns the number of acknowledged entries.",
34
+ handler: async (event, ctx) => {
35
+ const payload = acknowledgeQuarantineSchema.parse(event.payload);
36
+ const account = await loadCurrentMailAccountPayload(ctx, payload.accountId);
37
+ if (!account) return failNotFound("mail-account", payload.accountId);
38
+ if (!mayManageQuarantine(account.ownerUserId, event.user, adminRoles)) {
39
+ return writeFailure(new AccessDeniedError({ details: { reason: "not_mailbox_owner" } }));
40
+ }
41
+ const updated = await ctx.db.updateMany(
42
+ quarantineTable,
43
+ { acknowledgedAt: Temporal.Now.instant().toString() },
44
+ {
45
+ accountId: payload.accountId,
46
+ acknowledgedAt: null,
47
+ ...(payload.providerMessageId !== undefined && {
48
+ providerMessageId: payload.providerMessageId,
49
+ }),
50
+ },
51
+ );
52
+ return { isSuccess: true as const, data: { acknowledged: updated.length } };
53
+ },
54
+ };
55
+ }