@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
@@ -0,0 +1,177 @@
1
+ // Pre-transaction half of the content storage: original and attachments are written while no
2
+ // transaction holds a connection, so a slow or failing file store never pins the database. The
3
+ // handler transaction only creates the fileRef rows (finalizePreparedContent).
4
+
5
+ import { writeFailure } from "@cosmicdrift/kumiko-framework/errors";
6
+ import {
7
+ createFileRefFromUploadedBytes,
8
+ type UploadedFileBytes,
9
+ uploadFileBytesBeforeTransaction,
10
+ } from "@cosmicdrift/kumiko-framework/files";
11
+ import type {
12
+ BeforeTransactionContext,
13
+ BeforeTransactionResult,
14
+ ReservationRelease,
15
+ } from "@cosmicdrift/kumiko-types/handlers";
16
+ import { inboundMessageAggregateId } from "../aggregate-id.ts";
17
+ import { seenMessageTable } from "../entities.ts";
18
+ import { FileStorageWriteFailedError } from "../file-storage-failure.ts";
19
+ import { INBOUND_SKIPPED_ATTACHMENTS_MAX_COUNT } from "../normalize-inbound-message.ts";
20
+ import type { SkippedInboundAttachment } from "../types.ts";
21
+ import type { IngestMessagePayload } from "./ingest-message-schema.ts";
22
+ import {
23
+ contentWithoutFileStorage,
24
+ type InboundContentInput,
25
+ inboundAttachmentMaxSize,
26
+ inboundAttachmentTarget,
27
+ inboundMessageBodyKey,
28
+ originalOf,
29
+ type StoredInboundContent,
30
+ } from "./message-content.ts";
31
+
32
+ export type PreparedInboundContent = {
33
+ readonly bodyRef: string;
34
+ readonly uploads: readonly UploadedFileBytes[];
35
+ readonly skippedAttachments: readonly SkippedInboundAttachment[];
36
+ readonly contentStorage: StoredInboundContent["contentStorage"];
37
+ };
38
+
39
+ export type PreparedIngest =
40
+ | { readonly duplicate: true }
41
+ | { readonly duplicate: false; readonly content: PreparedInboundContent };
42
+
43
+ type PrepareContext = Pick<BeforeTransactionContext, "db" | "services" | "registry" | "user">;
44
+
45
+ function storageFailure(cause: unknown) {
46
+ return writeFailure(new FileStorageWriteFailedError(cause instanceof Error ? cause : undefined));
47
+ }
48
+
49
+ async function releaseAll(releases: readonly ReservationRelease[]): Promise<void> {
50
+ for (const release of releases) await release();
51
+ }
52
+
53
+ // The deterministic original key is never released: a concurrent winner owns it and writing it
54
+ // again is idempotent.
55
+ export async function prepareInboundContent(
56
+ ctx: PrepareContext,
57
+ input: InboundContentInput,
58
+ ): Promise<BeforeTransactionResult<PreparedInboundContent>> {
59
+ const { files } = ctx.services;
60
+ if (!files) {
61
+ const unavailable = contentWithoutFileStorage(input);
62
+ return {
63
+ isSuccess: true,
64
+ data: {
65
+ bodyRef: unavailable.bodyRef,
66
+ uploads: [],
67
+ skippedAttachments: unavailable.skippedAttachments,
68
+ contentStorage: unavailable.contentStorage,
69
+ },
70
+ };
71
+ }
72
+
73
+ let bodyRef = input.bodyRef;
74
+ const original = originalOf(input);
75
+ if (bodyRef === "" && original) {
76
+ bodyRef = inboundMessageBodyKey(
77
+ ctx.user.tenantId,
78
+ input.accountId,
79
+ input.messageAggId,
80
+ original.extension,
81
+ );
82
+ try {
83
+ await files.ref(bodyRef).write(original.data, original.mimeType);
84
+ } catch (err) {
85
+ return storageFailure(err);
86
+ }
87
+ }
88
+
89
+ const uploads: UploadedFileBytes[] = [];
90
+ const releases: ReservationRelease[] = [];
91
+ const skipped = [...(input.skippedAttachments ?? [])];
92
+ const maxSize = inboundAttachmentMaxSize(ctx.registry);
93
+ for (const attachment of input.attachments ?? []) {
94
+ let uploaded: Awaited<ReturnType<typeof uploadFileBytesBeforeTransaction>>;
95
+ try {
96
+ uploaded = await uploadFileBytesBeforeTransaction(ctx, {
97
+ fileName: attachment.fileName,
98
+ mimeType: attachment.mimeType,
99
+ data: attachment.data,
100
+ attachTo: inboundAttachmentTarget(input.messageAggId),
101
+ maxSize,
102
+ });
103
+ } catch (err) {
104
+ await releaseAll(releases);
105
+ return storageFailure(err);
106
+ }
107
+ // A rejected attachment (size, MIME) is a property of the mail, not an outage.
108
+ if (!uploaded.isSuccess) {
109
+ skipped.push({
110
+ mimeType: attachment.mimeType,
111
+ size: attachment.data.byteLength,
112
+ reason: "rejected",
113
+ });
114
+ continue;
115
+ }
116
+ uploads.push(uploaded.data);
117
+ if (uploaded.release) releases.push(uploaded.release);
118
+ }
119
+ return {
120
+ isSuccess: true,
121
+ data: {
122
+ bodyRef,
123
+ uploads,
124
+ skippedAttachments: skipped.slice(0, INBOUND_SKIPPED_ATTACHMENTS_MAX_COUNT),
125
+ contentStorage: "stored",
126
+ },
127
+ release: () => releaseAll(releases),
128
+ };
129
+ }
130
+
131
+ /** Transaction half: creates the fileRef rows for the bytes the prepare phase wrote. */
132
+ export async function finalizePreparedContent(
133
+ ctx: Parameters<typeof createFileRefFromUploadedBytes>[0],
134
+ prepared: PreparedInboundContent,
135
+ ): Promise<StoredInboundContent> {
136
+ const attachmentFileIds: string[] = [];
137
+ const skipped = [...prepared.skippedAttachments];
138
+ for (const upload of prepared.uploads) {
139
+ const created = await createFileRefFromUploadedBytes(ctx, upload);
140
+ if (created.isSuccess) attachmentFileIds.push(created.data.id);
141
+ else skipped.push({ mimeType: upload.mimeType, size: upload.size, reason: "rejected" });
142
+ }
143
+ return {
144
+ bodyRef: prepared.bodyRef,
145
+ attachmentFileIds,
146
+ skippedAttachments: skipped.slice(0, INBOUND_SKIPPED_ATTACHMENTS_MAX_COUNT),
147
+ contentStorage: prepared.contentStorage,
148
+ };
149
+ }
150
+
151
+ /** Dedup read first: a duplicate uploads nothing. The in-transaction dedup stays the correctness anchor. */
152
+ export async function prepareIngestMessage(
153
+ event: { readonly payload: IngestMessagePayload },
154
+ ctx: PrepareContext,
155
+ ): Promise<BeforeTransactionResult<PreparedIngest>> {
156
+ const { payload } = event;
157
+ const seen = await ctx.db.selectMany(seenMessageTable, {
158
+ accountId: payload.accountId,
159
+ providerMessageId: payload.providerMessageId,
160
+ });
161
+ if (seen.length > 0) return { isSuccess: true, data: { duplicate: true } };
162
+ const prepared = await prepareInboundContent(ctx, {
163
+ accountId: payload.accountId,
164
+ messageAggId: inboundMessageAggregateId(payload.accountId, payload.providerMessageId),
165
+ bodyRef: payload.bodyRef,
166
+ rawMime: payload.rawMime,
167
+ bodyHtml: payload.bodyHtml,
168
+ attachments: payload.attachments,
169
+ skippedAttachments: payload.skippedAttachments,
170
+ });
171
+ if (!prepared.isSuccess) return prepared;
172
+ return {
173
+ isSuccess: true,
174
+ data: { duplicate: false, content: prepared.data },
175
+ ...(prepared.release && { release: prepared.release }),
176
+ };
177
+ }
@@ -0,0 +1,42 @@
1
+ import * as z from "zod";
2
+ import { senderAuthenticationFieldSchema } from "../sender-auth.ts";
3
+ import { inboundContentPayloadShape } from "./message-content.ts";
4
+
5
+ // =============================================================================
6
+ // Input schema — the normalized provider output (RawInboundMessage,
7
+ // types.ts) plus the envelope (accountId/providerName/providerCursor). The
8
+ // original and attachments ride in-process as bytes and are persisted to file
9
+ // storage here; the event itself stays blob-free (references only).
10
+ // =============================================================================
11
+
12
+ export const ingestMessageSchema = z.object({
13
+ accountId: z.uuid(),
14
+ /** Scope inherited from the account — the supervisor knows the
15
+ * MailAccountRecord and passes ownerUserId through. */
16
+ ownerUserId: z.string().max(36).nullable(),
17
+ providerName: z.string().min(1).max(50),
18
+ /** Provider-native Message-ID — idempotency anchor with accountId. */
19
+ providerMessageId: z.string().min(1).max(500),
20
+ /** RFC-5322 Message-ID without <>; null → deterministic fallback. */
21
+ messageIdHeader: z.string().min(1).max(500).nullable(),
22
+ providerThreadId: z.string().min(1).max(500).nullable(),
23
+ /** References/In-Reply-To chain, oldest first. */
24
+ references: z.array(z.string().min(1).max(500)).max(200),
25
+ from: z.string().min(1).max(2000),
26
+ to: z.array(z.string().max(2000)).max(200),
27
+ cc: z.array(z.string().max(2000)).max(200),
28
+ subject: z.string().max(4000),
29
+ snippet: z.string().max(4000),
30
+ receivedAtIso: z.string().min(1),
31
+ /** From the app's storeBody hook; empty lets the handler store the original itself. */
32
+ bodyRef: z.string().max(500),
33
+ scope: z.string().min(1).max(200),
34
+ /** Cursor snapshot at ingest time — used for debugging/replay and stored in
35
+ * metadata.headers, never in the payload. */
36
+ providerCursor: z.string().max(2000),
37
+ senderAuthentication: senderAuthenticationFieldSchema.optional(),
38
+ /** Set when the message is imported from its headers only because it sits in quarantine. */
39
+ contentUnavailableReason: z.literal("quarantined").optional(),
40
+ ...inboundContentPayloadShape,
41
+ });
42
+ export type IngestMessagePayload = z.infer<typeof ingestMessageSchema>;
@@ -2,11 +2,14 @@
2
2
  // (watch callback + fetchSince sync). Accepts a normalized
3
3
  // RawInboundMessage and performs atomically in ONE TX:
4
4
  //
5
+ // 0. Per-message advisory lock, so a watch/poll race cannot store the same
6
+ // content twice (orphaned FileRefs) before the dedup checks run.
5
7
  // 1. Idempotency check (cheap: store_mail_seen_messages; defense in
6
8
  // depth: stream existence check on the deterministic aggregateId).
7
9
  // 2. threadKey normalization (provider thread ID preferred, otherwise
8
10
  // References chain root, otherwise Message-ID = single-message thread).
9
- // 3. PII encryption (from/to/cc/subject/snippet) BEFORE the append —
11
+ // 3. Content storage (original + attachments, only after dedup) and PII
12
+ // encryption (from/to/cc/subject/snippet/bodyText) BEFORE the append —
10
13
  // both the event log AND projection contain ciphertext.
11
14
  // 4. inbound-message-received append (inline projection writes
12
15
  // read_inbound_messages in the same TX). Savepoint-scoped
@@ -24,90 +27,43 @@ import {
24
27
  configuredPiiSubjectKms,
25
28
  encryptPiiFieldValues,
26
29
  } from "@cosmicdrift/kumiko-framework/crypto";
27
- import { acquireNamespacedAdvisoryLock, countWhere } from "@cosmicdrift/kumiko-framework/db";
28
- import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
29
- import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
30
+ import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
31
+ import type { WriteResult } from "@cosmicdrift/kumiko-types/handlers";
30
32
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
31
- import * as z from "zod";
32
33
  import { inboundMessageAggregateId, mailThreadAggregateId } from "../aggregate-id.ts";
33
- import {
34
- INBOUND_MESSAGE_PII_FIELDS,
35
- inboundMessageEntity,
36
- MAIL_THREAD_PII_FIELDS,
37
- mailThreadEntity,
38
- seenMessageTable,
39
- } from "../entities.ts";
34
+ import { INBOUND_MESSAGE_PII_FIELDS, inboundMessageEntity, seenMessageTable } from "../entities.ts";
40
35
  import {
41
36
  INBOUND_MESSAGE_AGGREGATE_TYPE,
42
37
  INBOUND_MESSAGE_RECEIVED_EVENT_QN,
43
38
  type InboundMessageEventHeaders,
44
39
  type InboundMessageEventPayload,
45
- MAIL_THREAD_AGGREGATE_TYPE,
46
- MAIL_THREAD_UPDATED_EVENT_QN,
47
- type MailThreadEventPayload,
48
40
  } from "../events.ts";
49
- import { inboundMessagesProjectionTable } from "../projection.ts";
50
-
51
- // =============================================================================
52
- // Input schema — the normalized provider output (RawInboundMessage,
53
- // types.ts) plus the envelope (accountId/providerName/providerCursor). rawMime
54
- // is intentionally excluded: the caller persists the body to
55
- // file-foundation BEFORE the ingest call and passes only bodyRef — the
56
- // handler payload remains blob-free (event-store discipline).
57
- // =============================================================================
58
-
59
- export const ingestMessageSchema = z.object({
60
- accountId: z.uuid(),
61
- /** Scope inherited from the account — the supervisor knows the
62
- * MailAccountRecord and passes ownerUserId through. */
63
- ownerUserId: z.string().max(36).nullable(),
64
- providerName: z.string().min(1).max(50),
65
- /** Provider-native Message-ID — idempotency anchor with accountId. */
66
- providerMessageId: z.string().min(1).max(500),
67
- /** RFC-5322 Message-ID without <>; null → deterministic fallback. */
68
- messageIdHeader: z.string().min(1).max(500).nullable(),
69
- providerThreadId: z.string().min(1).max(500).nullable(),
70
- /** References/In-Reply-To chain, oldest first. */
71
- references: z.array(z.string().min(1).max(500)).max(200),
72
- from: z.string().min(1).max(2000),
73
- to: z.array(z.string().max(2000)).max(200),
74
- cc: z.array(z.string().max(2000)).max(200),
75
- subject: z.string().max(4000),
76
- snippet: z.string().max(4000),
77
- receivedAtIso: z.string().min(1),
78
- /** file-foundation-Ref; leer im snippet-only-Mode. */
79
- bodyRef: z.string().max(500),
80
- scope: z.string().min(1).max(200),
81
- /** Cursor snapshot at ingest time — used for debugging/replay and stored in
82
- * metadata.headers, never in the payload. */
83
- providerCursor: z.string().max(2000),
84
- });
85
- type IngestMessagePayload = z.infer<typeof ingestMessageSchema>;
86
-
87
- /**
88
- * Normalizes threadKey:
89
- * 1. Use the provider thread ID when present — prefix it with the provider
90
- * so IMAP References keys and Graph conversationIds never collide.
91
- * 2. Otherwise use the root of the References chain (the oldest Message-ID,
92
- * which is the thread anchor shared by all replies).
93
- * 3. Otherwise use the own Message-ID → single-message thread; a later
94
- * reply carries it as the References root and joins the same thread.
95
- */
96
- function buildThreadKey(p: IngestMessagePayload, effectiveMessageIdHeader: string): string {
97
- if (p.providerThreadId) return `${p.providerName}:${p.providerThreadId}`.slice(0, 500);
98
- const referencesRoot = p.references[0];
99
- if (referencesRoot) return `mid:${referencesRoot}`.slice(0, 500);
100
- return `mid:${effectiveMessageIdHeader}`.slice(0, 500);
101
- }
41
+ import {
42
+ finalizePreparedContent,
43
+ type PreparedIngest,
44
+ prepareIngestMessage,
45
+ } from "./ingest-message-prepare.ts";
46
+ import { ingestMessageSchema } from "./ingest-message-schema.ts";
47
+ import {
48
+ contentStateOf,
49
+ MESSAGE_LOCK_NAMESPACE,
50
+ storeInboundMessageContent,
51
+ } from "./message-content.ts";
52
+ import { appendThreadRollup, buildThreadKey } from "./thread-rollup.ts";
102
53
 
103
- const THREAD_ROLLUP_MAX_ATTEMPTS = 5;
104
- // pg_advisory_xact_lock namespace (int4) for the step-5 thread-rollup lock
105
- // — 'inbm' as ASCII, keeps it disjoint from the framework's other fixed
106
- // single-int advisory-lock keys (schema bootstrap, es-ops boot).
107
- const THREAD_ROLLUP_LOCK_NAMESPACE = 0x696e626d;
108
54
  const THREAD_ROLLUP_ADVISORY_LOCK_REASON =
109
55
  "takes the per-thread advisory lock and counts the caller tenant's thread messages; TenantDb has no lock/count API";
110
- export const ingestMessageHandler: WriteHandlerDef = {
56
+ type IngestMessageData =
57
+ | { readonly duplicate: true; readonly inboundMessageAggregateId: string }
58
+ | {
59
+ readonly duplicate: false;
60
+ readonly inboundMessageAggregateId: string;
61
+ readonly threadAggregateId: string;
62
+ readonly threadKey: string;
63
+ readonly contentStorage: "stored" | "unavailable";
64
+ };
65
+
66
+ export const ingestMessageHandler = defineWriteHandler({
111
67
  name: "ingest-message",
112
68
  schema: ingestMessageSchema,
113
69
  access: { roles: ["SystemAdmin"] },
@@ -116,12 +72,16 @@ export const ingestMessageHandler: WriteHandlerDef = {
116
72
  grants: ["unsafeRaw"],
117
73
  reason: THREAD_ROLLUP_ADVISORY_LOCK_REASON,
118
74
  },
119
- handler: async (event, ctx) => {
120
- // @cast-boundary engine-payload — dispatcher-zod-validated payload
121
- const payload = event.payload as IngestMessagePayload;
75
+ // Nested (an app handler dispatching ingest-message) there is no phase; the handler then stores in its own transaction.
76
+ beforeTransactionWhenNested: "skip",
77
+ beforeTransaction: prepareIngestMessage,
78
+ handler: async (event, ctx): Promise<WriteResult<IngestMessageData>> => {
79
+ const { payload } = event;
122
80
  const tenantId = event.user.tenantId;
123
81
  const messageAggId = inboundMessageAggregateId(payload.accountId, payload.providerMessageId);
124
82
 
83
+ await ctx.db.advisoryXactLock(MESSAGE_LOCK_NAMESPACE, messageAggId);
84
+
125
85
  // ---------------------------------------------------------------
126
86
  // 1. Idempotency. Use the deduplication anchor row first because it is
127
87
  // cheap. The stream check afterward provides defense in depth if
@@ -168,9 +128,26 @@ export const ingestMessageHandler: WriteHandlerDef = {
168
128
  // (primitives only, upcaster-safe). If no KMS is configured, pass
169
129
  // through plaintext (engine-off behavior).
170
130
  // ---------------------------------------------------------------
131
+ const prepared: PreparedIngest | undefined = event.prepared;
132
+ const content =
133
+ prepared?.duplicate === false
134
+ ? await finalizePreparedContent(ctx, prepared.content)
135
+ : await storeInboundMessageContent(ctx, {
136
+ accountId: payload.accountId,
137
+ messageAggId,
138
+ bodyRef: payload.bodyRef,
139
+ rawMime: payload.rawMime,
140
+ bodyHtml: payload.bodyHtml,
141
+ attachments: payload.attachments,
142
+ skippedAttachments: payload.skippedAttachments,
143
+ });
144
+ const contentUnavailableReason =
145
+ payload.contentUnavailableReason ??
146
+ (content.contentStorage === "unavailable" ? ("no_file_storage" as const) : null);
171
147
  const piiKms = configuredPiiSubjectKms();
172
148
  const messagePlainPii = {
173
149
  tenantId,
150
+ bodyText: payload.bodyText,
174
151
  from: payload.from,
175
152
  to: JSON.stringify(payload.to),
176
153
  cc: JSON.stringify(payload.cc),
@@ -203,8 +180,22 @@ export const ingestMessageHandler: WriteHandlerDef = {
203
180
  subject: encryptedMessageFields.subject,
204
181
  snippet: encryptedMessageFields.snippet,
205
182
  receivedAtIso: payload.receivedAtIso,
206
- bodyRef: payload.bodyRef,
183
+ bodyRef: content.bodyRef,
207
184
  scope: payload.scope,
185
+ senderAuthentication: payload.senderAuthentication ?? null,
186
+ bodyText: encryptedMessageFields.bodyText,
187
+ bodyTextTruncated: payload.bodyTextTruncated,
188
+ attachmentFileIds: content.attachmentFileIds,
189
+ skippedAttachments: content.skippedAttachments,
190
+ contentState:
191
+ payload.contentUnavailableReason !== undefined
192
+ ? "unavailable"
193
+ : contentStateOf({
194
+ bodyText: payload.bodyText,
195
+ hasOriginal: payload.rawMime !== undefined || payload.bodyHtml !== undefined,
196
+ contentStorage: content.contentStorage,
197
+ }),
198
+ contentUnavailableReason,
208
199
  };
209
200
  const messageHeaders: InboundMessageEventHeaders = {
210
201
  providerMessageId: payload.providerMessageId,
@@ -230,88 +221,15 @@ export const ingestMessageHandler: WriteHandlerDef = {
230
221
  };
231
222
  }
232
223
 
233
- // ---------------------------------------------------------------
234
- // 5. Thread-Rollup. New snapshot (live COUNT + max(lastMessageAt)) via
235
- // ctx.events.tryAppend in a bounded retry loop — the apply stays a
236
- // dumb, replay-idempotent UPSERT. lastMessageAtIso is not PII (a
237
- // timestamp), so it stays plaintext-comparable; subject is
238
- // encrypted once up front (constant across retries, doesn't
239
- // depend on the thread's prior state).
240
- //
241
- // messageCount is a live COUNT, not previousCount+1, so two
242
- // concurrent ingests on the same thread (Watch-Push vs.
243
- // Poll-Reconciliation overlap, or two different messages of the
244
- // same thread arriving at once) converge on the true count.
245
- // acquireNamespacedAdvisoryLock below serializes this whole
246
- // countWhere+append section per threadAggId — transaction-scoped,
247
- // so it survives events.tryAppend's inner SAVEPOINT and releases at
248
- // the enclosing TX's commit/rollback. Without it, a concurrent
249
- // commit landing between countWhere and events.tryAppend's own fresh
250
- // version-read makes the append succeed with a stale count instead
251
- // of a VersionConflictError, so the retry loop below never sees it
252
- // as a conflict to retry (the original gap). Unlike step 4, a
253
- // lost version-conflict race here must NOT be treated as a no-op
254
- // duplicate — skipping would leave this message's contribution to
255
- // messageCount/lastMessageAt out of the thread forever. Exhausting
256
- // all attempts throws, rolling back the whole TX (including the
257
- // step-4 message append) for a clean re-ingest.
258
- // ---------------------------------------------------------------
259
- const threadPlainPii = { tenantId, subject: payload.subject };
260
- const encryptedThreadFields = piiKms
261
- ? await encryptPiiFieldValues(
262
- threadPlainPii,
263
- mailThreadEntity,
264
- MAIL_THREAD_PII_FIELDS,
265
- piiKms,
266
- {
267
- requestId: `inbound-mail-foundation:ingest-message:thread:${threadAggId}`,
268
- tenantId,
269
- },
270
- { tenantId, entityName: MAIL_THREAD_AGGREGATE_TYPE },
271
- )
272
- : threadPlainPii;
273
-
274
- const threadRollupRunner = ctx.db.unsafeRaw();
275
- await acquireNamespacedAdvisoryLock(
276
- threadRollupRunner,
277
- THREAD_ROLLUP_LOCK_NAMESPACE,
224
+ await appendThreadRollup(ctx, {
225
+ tenantId,
278
226
  threadAggId,
279
- );
280
-
281
- let threadAppendOk = false;
282
- for (let attempt = 1; attempt <= THREAD_ROLLUP_MAX_ATTEMPTS && !threadAppendOk; attempt++) {
283
- const threadEvents = await ctx.events.load(threadAggId);
284
- const lastThreadEvent = threadEvents[threadEvents.length - 1];
285
- // @cast-boundary engine-payload — eigene events, shape via defineEvent
286
- const previousThread = lastThreadEvent?.payload as MailThreadEventPayload | undefined;
287
- const previousLastAt = previousThread?.lastMessageAtIso ?? "";
288
- const newLastAt =
289
- payload.receivedAtIso > previousLastAt ? payload.receivedAtIso : previousLastAt;
290
- const messageCount = await countWhere(threadRollupRunner, inboundMessagesProjectionTable, {
291
- tenantId,
292
- threadKey,
293
- });
294
-
295
- const threadEventPayload: MailThreadEventPayload = {
296
- threadKey,
297
- subject: encryptedThreadFields.subject,
298
- lastMessageAtIso: newLastAt,
299
- messageCount,
300
- };
301
- const threadAppend = await ctx.events.tryAppend({
302
- aggregateId: threadAggId,
303
- aggregateType: MAIL_THREAD_AGGREGATE_TYPE,
304
- type: MAIL_THREAD_UPDATED_EVENT_QN,
305
- payload: threadEventPayload,
306
- headers: messageHeaders,
307
- });
308
- threadAppendOk = threadAppend.ok;
309
- }
310
- if (!threadAppendOk) {
311
- throw new InternalError({
312
- message: `ingest-message: thread-rollup append for "${threadAggId}" lost the version-conflict race ${THREAD_ROLLUP_MAX_ATTEMPTS} times in a row — aborting for a clean re-ingest instead of persisting a stale messageCount.`,
313
- });
314
- }
227
+ threadKey,
228
+ subject: payload.subject,
229
+ receivedAtIso: payload.receivedAtIso,
230
+ headers: messageHeaders,
231
+ piiKms,
232
+ });
315
233
 
316
234
  // ---------------------------------------------------------------
317
235
  // 6. Deduplication anchor. It runs in the same TX as the appends, so
@@ -331,7 +249,8 @@ export const ingestMessageHandler: WriteHandlerDef = {
331
249
  inboundMessageAggregateId: messageAggId,
332
250
  threadAggregateId: threadAggId,
333
251
  threadKey,
252
+ contentStorage: content.contentStorage,
334
253
  },
335
254
  };
336
255
  },
337
- };
256
+ });
@@ -3,8 +3,8 @@
3
3
  // billing-foundation list-subscriptions pattern.
4
4
  //
5
5
  // **Scope visibility:** authorized users can see shared mailboxes
6
- // (ownerUserId=null); personal mailboxes are visible only to their owner,
7
- // TenantAdmin, or SystemAdmin for compliance access. Filtering runs in memory
6
+ // (ownerUserId=null); personal mailboxes are listed (metadata only) for their owner and the
7
+ // admin roles, but only the owner sees their quarantine. Filtering runs in memory
8
8
  // after the tenant-scoped select because a tenant has only a single- to
9
9
  // double-digit number of connected mailboxes.
10
10
  import {
@@ -13,39 +13,102 @@ import {
13
13
  } from "@cosmicdrift/kumiko-framework/crypto";
14
14
  import { MAX_LIST_LIMIT, type QueryHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
15
15
  import * as z from "zod";
16
+ import {
17
+ IMAP_PROVIDER_KEY,
18
+ InboundMailAccountStatuses,
19
+ InboundMailAuthMethods,
20
+ type MailAccountReactivationPath,
21
+ MailAccountReactivationPaths,
22
+ } from "../constants.ts";
16
23
  import { MAIL_ACCOUNT_PII_FIELDS } from "../entities.ts";
24
+ import type { InboundMailRoles } from "../mail-roles.ts";
17
25
  import { mailAccountsProjectionTable } from "../projection.ts";
18
- import { isVisibleToCaller } from "./scope-visibility.ts";
26
+ import { quarantineTable } from "../quarantine-entities.ts";
27
+ import {
28
+ isAccountListableBy,
29
+ mayManageQuarantine,
30
+ mayReactivateMailbox,
31
+ } from "./scope-visibility.ts";
32
+
33
+ const REACTIVATABLE_STATUSES: ReadonlySet<unknown> = new Set([
34
+ InboundMailAccountStatuses.disconnected,
35
+ InboundMailAccountStatuses.authError,
36
+ ]);
37
+
38
+ // Null where no way back exists for this caller, so no row action leads into a refusal.
39
+ function reactivationPathOf(
40
+ row: Readonly<Record<string, unknown>>,
41
+ user: { readonly id: string; readonly roles: readonly string[] },
42
+ adminRoles: readonly string[],
43
+ ): MailAccountReactivationPath | null {
44
+ if (!REACTIVATABLE_STATUSES.has(row["status"])) return null;
45
+ if (!mayReactivateMailbox(row["ownerUserId"], user, adminRoles)) return null;
46
+ if (row["authMethod"] === InboundMailAuthMethods.oauth)
47
+ return MailAccountReactivationPaths.oauthFlow;
48
+ const isPasswordImap =
49
+ row["provider"] === IMAP_PROVIDER_KEY && row["authMethod"] === InboundMailAuthMethods.password;
50
+ return isPasswordImap ? MailAccountReactivationPaths.passwordForm : null;
51
+ }
19
52
 
20
53
  // The projectionList screen always sends `limit`; unknown keys stay rejected.
21
54
  const listAccountsSchema = z
22
55
  .object({ limit: z.number().int().min(1).max(MAX_LIST_LIMIT).default(MAX_LIST_LIMIT) })
23
56
  .strict();
24
57
 
25
- export const listAccountsQuery: QueryHandlerDef = {
26
- name: "account:list",
27
- schema: listAccountsSchema,
28
- access: { roles: ["SystemAdmin", "TenantAdmin", "User"] },
29
- description:
30
- "Lists the connected mailboxes of the caller's tenant with the mailbox address decrypted, hiding personal mailboxes the caller neither owns nor administers; use it for the mail connect and settings views.",
31
- handler: async (query, ctx) => {
32
- const { limit } = listAccountsSchema.parse(query.payload);
33
- const allRows = await ctx.db.selectMany(mailAccountsProjectionTable, {
34
- tenantId: ctx.user.tenantId,
35
- });
36
- const rows = allRows
37
- .filter((row) => isVisibleToCaller(row, ctx.user))
38
- .sort((a, b) => String(a["id"]).localeCompare(String(b["id"])))
39
- .slice(0, limit);
40
- const piiKms = configuredPiiSubjectKms();
41
- if (!piiKms) return { rows };
42
- const decryptedRows = await Promise.all(
43
- rows.map((row) =>
44
- decryptPiiFieldValues(row, MAIL_ACCOUNT_PII_FIELDS, piiKms, {
45
- requestId: `inbound-mail-foundation:list-accounts:${ctx.user.tenantId}`,
46
- }),
47
- ),
48
- );
49
- return { rows: decryptedRows };
50
- },
51
- };
58
+ export function createListAccountsQuery({
59
+ memberRoles,
60
+ adminRoles,
61
+ }: InboundMailRoles): QueryHandlerDef {
62
+ return {
63
+ name: "account:list",
64
+ schema: listAccountsSchema,
65
+ access: { roles: [...memberRoles] },
66
+ description:
67
+ "Lists the connected mailboxes of the caller's tenant with the mailbox address decrypted, hiding personal mailboxes the caller neither owns nor administers and the quarantine of personal mailboxes the caller does not own; use it for the mail connect and settings views.",
68
+ handler: async (query, ctx) => {
69
+ const { limit } = listAccountsSchema.parse(query.payload);
70
+ const allRows = await ctx.db.selectMany(mailAccountsProjectionTable, {
71
+ tenantId: ctx.user.tenantId,
72
+ });
73
+ const rows = allRows
74
+ .filter((row) => isAccountListableBy(row, ctx.user, adminRoles))
75
+ .sort((a, b) => String(a["id"]).localeCompare(String(b["id"])))
76
+ .slice(0, limit);
77
+ // One grouped count for all mailboxes instead of a query per row.
78
+ const openQuarantine = await ctx.db.aggregate(
79
+ quarantineTable,
80
+ { measure: { fn: "count" }, groupBy: [{ field: "accountId" }] },
81
+ { acknowledgedAt: null },
82
+ );
83
+ const quarantinedCountOf = new Map(
84
+ openQuarantine.map((group) => [String(group.keys[0]), group.value ?? 0]),
85
+ );
86
+ // The screen's row condition cannot compare a count or combine fields, so it gets booleans.
87
+ const withQuarantine = (row: Record<string, unknown>) => {
88
+ const owner = row["ownerUserId"];
89
+ const mayManage = mayManageQuarantine(
90
+ typeof owner === "string" && owner !== "" ? owner : null,
91
+ ctx.user,
92
+ adminRoles,
93
+ );
94
+ const quarantinedCount = mayManage ? (quarantinedCountOf.get(String(row["id"])) ?? 0) : 0;
95
+ return {
96
+ ...row,
97
+ quarantinedCount,
98
+ hasOpenQuarantine: quarantinedCount > 0,
99
+ reactivateVia: reactivationPathOf(row, ctx.user, adminRoles),
100
+ };
101
+ };
102
+ const piiKms = configuredPiiSubjectKms();
103
+ if (!piiKms) return { rows: rows.map(withQuarantine) };
104
+ const decryptedRows = await Promise.all(
105
+ rows.map((row) =>
106
+ decryptPiiFieldValues(row, MAIL_ACCOUNT_PII_FIELDS, piiKms, {
107
+ requestId: `inbound-mail-foundation:list-accounts:${ctx.user.tenantId}`,
108
+ }),
109
+ ),
110
+ );
111
+ return { rows: decryptedRows.map(withQuarantine) };
112
+ },
113
+ };
114
+ }