@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,137 @@
1
+ // Quarantine retry: mails flagged by retry-quarantined-message are loaded from the provider again
2
+ // after a successful sync. A mail that now goes through leaves the quarantine; one that fails again
3
+ // stays in it (flag reset, so it is not retried on every tick). An outage (provider, KMS, file
4
+ // storage) is not the mail's failure: the flag stays and the retry runs after the recovery.
5
+
6
+ import { ENCRYPTION_UNAVAILABLE_CODE } from "@cosmicdrift/kumiko-framework/crypto";
7
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
8
+ import { deleteMany, selectMany, updateMany } from "@cosmicdrift/kumiko-framework/db";
9
+ import { errorCodeOf } from "@cosmicdrift/kumiko-types/type-guards";
10
+ import { inboundMessageAggregateId } from "./aggregate-id.ts";
11
+ import { InboundMailFoundationHandlers } from "./constants.ts";
12
+ import { describeError } from "./describe-error.ts";
13
+ import { isFileStorageWriteFailure } from "./file-storage-failure.ts";
14
+ import { clearIngestFailures } from "./ingest-failures.ts";
15
+ import { ingestPayloadOf } from "./ingest-payload.ts";
16
+ import {
17
+ type NormalizedInboundMessage,
18
+ normalizeRawInboundMessage,
19
+ } from "./normalize-inbound-message.ts";
20
+ import { inboundMessagesProjectionTable } from "./projection.ts";
21
+ import { quarantineTable } from "./quarantine-entities.ts";
22
+ import type { DispatchWrite } from "./supervisor-ingest.ts";
23
+ import type { InboundMailContext, InboundMailProviderPlugin, MailAccountRecord } from "./types.ts";
24
+
25
+ const RETRY_BUDGET_PER_TICK = 20;
26
+
27
+ function isOutageFailure(error: unknown): boolean {
28
+ return isFileStorageWriteFailure(error) || errorCodeOf(error) === ENCRYPTION_UNAVAILABLE_CODE;
29
+ }
30
+
31
+ // A mail with a header-only entry gets its content attached, one without any entry is ingested in full.
32
+ function reloadMessage(
33
+ dispatchWrite: DispatchWrite,
34
+ account: MailAccountRecord,
35
+ msg: NormalizedInboundMessage,
36
+ hasHeaderRow: boolean,
37
+ ): ReturnType<DispatchWrite> {
38
+ return hasHeaderRow
39
+ ? dispatchWrite({
40
+ handlerQn: InboundMailFoundationHandlers.attachMessageContent,
41
+ tenantId: account.tenantId,
42
+ payload: {
43
+ accountId: account.id,
44
+ providerMessageId: msg.providerMessageId,
45
+ outcome: "fetched",
46
+ bodyText: msg.bodyText,
47
+ bodyTextTruncated: msg.bodyTextTruncated,
48
+ ...(msg.rawMime != null && { rawMime: msg.rawMime }),
49
+ ...(msg.bodyHtml !== null && { bodyHtml: msg.bodyHtml }),
50
+ attachments: msg.attachments,
51
+ skippedAttachments: msg.skippedAttachments,
52
+ },
53
+ })
54
+ : dispatchWrite({
55
+ handlerQn: InboundMailFoundationHandlers.ingestMessage,
56
+ tenantId: account.tenantId,
57
+ payload: ingestPayloadOf(account, msg, {
58
+ providerCursor: "quarantine-retry",
59
+ bodyRef: "",
60
+ }),
61
+ });
62
+ }
63
+
64
+ export async function retryQuarantinedMessages(args: {
65
+ readonly db: DbConnection;
66
+ readonly dispatchWrite: DispatchWrite;
67
+ readonly providerCtx: InboundMailContext;
68
+ readonly account: MailAccountRecord;
69
+ readonly plugin: InboundMailProviderPlugin;
70
+ readonly accessToken?: string | undefined;
71
+ readonly signal?: AbortSignal | undefined;
72
+ readonly log: (line: string) => void;
73
+ }): Promise<void> {
74
+ const { db, account, plugin, dispatchWrite, log } = args;
75
+ // skip: the provider cannot reload single mails.
76
+ if (!plugin.fetchContent) return;
77
+ const requested = await selectMany<{ providerMessageId: string }>(
78
+ db,
79
+ quarantineTable,
80
+ { accountId: account.id, retryRequestedAt: { ne: null } },
81
+ { fields: ["providerMessageId"], limit: RETRY_BUDGET_PER_TICK },
82
+ );
83
+ // skip: nothing was asked for.
84
+ if (requested.length === 0) return;
85
+ const ids = requested.map((row) => row.providerMessageId);
86
+ const done = new Set<string>();
87
+ const held = new Set<string>();
88
+ try {
89
+ const result = await plugin.fetchContent(args.providerCtx, account, ids, {
90
+ ...(args.accessToken !== undefined && { accessToken: args.accessToken }),
91
+ ...(args.signal !== undefined && { signal: args.signal }),
92
+ });
93
+ const existing = new Set(
94
+ (
95
+ await selectMany<{ id: string }>(
96
+ db,
97
+ inboundMessagesProjectionTable,
98
+ {
99
+ tenantId: account.tenantId,
100
+ id: { in: ids.map((id) => inboundMessageAggregateId(account.id, id)) },
101
+ },
102
+ { fields: ["id"] },
103
+ )
104
+ ).map((row) => row.id),
105
+ );
106
+ for (const raw of result.messages) {
107
+ const msg = normalizeRawInboundMessage(raw);
108
+ const hasHeaderRow = existing.has(
109
+ inboundMessageAggregateId(account.id, msg.providerMessageId),
110
+ );
111
+ const outcome = await reloadMessage(dispatchWrite, account, msg, hasHeaderRow);
112
+ if (outcome.isSuccess) done.add(msg.providerMessageId);
113
+ else if (isOutageFailure(outcome.error)) held.add(msg.providerMessageId);
114
+ }
115
+ } catch (err) {
116
+ log(`inbound-mail: account ${account.id} quarantine retry failed: ${describeError(err)}`);
117
+ for (const id of ids) held.add(id);
118
+ }
119
+ const doneIds = [...done];
120
+ if (doneIds.length > 0) {
121
+ await deleteMany(db, quarantineTable, {
122
+ accountId: account.id,
123
+ providerMessageId: { in: doneIds },
124
+ });
125
+ await clearIngestFailures(db, account.id, doneIds);
126
+ }
127
+ const failed = ids.filter((id) => !done.has(id) && !held.has(id));
128
+ if (failed.length > 0) {
129
+ await updateMany(
130
+ db,
131
+ quarantineTable,
132
+ { retryRequestedAt: null },
133
+ { accountId: account.id, providerMessageId: { in: failed } },
134
+ );
135
+ log(`inbound-mail: account ${account.id} ${failed.length} quarantined mail(s) still failing`);
136
+ }
137
+ }
@@ -0,0 +1,66 @@
1
+ import {
2
+ i18nKey,
3
+ MAX_LIST_LIMIT,
4
+ type ProjectionListScreenDefinition,
5
+ } from "@cosmicdrift/kumiko-framework/engine";
6
+ import { InboundMailFoundationHandlers, InboundMailFoundationQueries } from "./constants.ts";
7
+ import type { InboundMailRoles } from "./mail-roles.ts";
8
+ import { MAIL_QUARANTINE_SCREEN_ID } from "./screens.ts";
9
+
10
+ export const QUARANTINE_REASON_OPTION_KEY_PREFIX = "inboundMail.quarantine.reason.";
11
+
12
+ // Same roles as the handlers; the bound query shows each caller only the mailboxes they may manage.
13
+ // dormant: reached through the row action "Quarantine" of the mailbox list.
14
+ export function createMailQuarantineScreen({
15
+ memberRoles,
16
+ }: InboundMailRoles): ProjectionListScreenDefinition {
17
+ return {
18
+ id: MAIL_QUARANTINE_SCREEN_ID,
19
+ type: "projectionList",
20
+ agent: { expose: false },
21
+ query: InboundMailFoundationQueries.listQuarantine,
22
+ pagination: false,
23
+ pageSize: MAX_LIST_LIMIT,
24
+ columns: [
25
+ { field: "sender", label: i18nKey("inboundMail.quarantine.col.sender"), sortable: false },
26
+ { field: "subject", label: i18nKey("inboundMail.quarantine.col.subject"), sortable: false },
27
+ {
28
+ field: "reason",
29
+ label: i18nKey("inboundMail.quarantine.col.reason"),
30
+ sortable: false,
31
+ renderer: { format: "enumOption", keyPrefix: QUARANTINE_REASON_OPTION_KEY_PREFIX },
32
+ },
33
+ {
34
+ field: "accountName",
35
+ label: i18nKey("inboundMail.quarantine.col.mailbox"),
36
+ sortable: false,
37
+ hideOnNarrow: true,
38
+ },
39
+ {
40
+ field: "quarantinedAt",
41
+ label: i18nKey("inboundMail.quarantine.col.date"),
42
+ sortable: false,
43
+ renderer: { format: "timestamp" },
44
+ },
45
+ ],
46
+ rowActions: [
47
+ {
48
+ kind: "writeHandler",
49
+ id: "retry",
50
+ label: i18nKey("inboundMail.quarantine.retry"),
51
+ handler: InboundMailFoundationHandlers.retryQuarantinedMessage,
52
+ payload: { map: { accountId: "accountId", providerMessageId: "providerMessageId" } },
53
+ visible: { field: "retryRequestedAt", eq: null },
54
+ },
55
+ {
56
+ kind: "writeHandler",
57
+ id: "acknowledge",
58
+ label: i18nKey("inboundMail.quarantine.acknowledge"),
59
+ handler: InboundMailFoundationHandlers.acknowledgeQuarantine,
60
+ payload: { map: { accountId: "accountId", providerMessageId: "providerMessageId" } },
61
+ },
62
+ ],
63
+ access: { roles: [...memberRoles] },
64
+ dormant: true,
65
+ };
66
+ }
@@ -0,0 +1,72 @@
1
+ // Quarantine of poison mails: messages the supervisor cannot ingest (unparseable MIME, payload the
2
+ // ingest handler rejects for good, or repeated hard failures). A quarantined id counts as seen, so the
3
+ // cursor moves on instead of retrying the same mail forever. Rows carry ids only, no mail content.
4
+
5
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
6
+ import { fetchOne, insertOne, selectMany } from "@cosmicdrift/kumiko-framework/db";
7
+ import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
8
+ import { type QuarantineReasonCode, quarantineTable } from "./quarantine-entities.ts";
9
+
10
+ const LOOKUP_CHUNK_SIZE = 1000;
11
+ export const QUARANTINE_ACKNOWLEDGED_RETENTION_DAYS = 90;
12
+
13
+ type QuarantineTarget = {
14
+ readonly tenantId: string;
15
+ readonly accountId: string;
16
+ readonly providerMessageId: string;
17
+ };
18
+
19
+ /** Returns true when a new row was written, false when the message was already quarantined. */
20
+ export async function quarantineMessage(
21
+ db: DbConnection,
22
+ target: QuarantineTarget,
23
+ reasonCode: QuarantineReasonCode,
24
+ ): Promise<boolean> {
25
+ const key = { accountId: target.accountId, providerMessageId: target.providerMessageId };
26
+ if (await fetchOne<{ id: string }>(db, quarantineTable, key)) return false;
27
+ try {
28
+ await insertOne(db, quarantineTable, {
29
+ id: crypto.randomUUID(),
30
+ tenantId: target.tenantId,
31
+ ...key,
32
+ reasonCode,
33
+ quarantinedAt: Temporal.Now.instant().toString(),
34
+ });
35
+ return true;
36
+ } catch (err) {
37
+ // A concurrent worker quarantined the same message between the check and the insert.
38
+ if (await fetchOne<{ id: string }>(db, quarantineTable, key)) return false;
39
+ throw err;
40
+ }
41
+ }
42
+
43
+ export async function quarantinedMessageIds(
44
+ db: DbConnection,
45
+ accountId: string,
46
+ providerMessageIds: readonly string[],
47
+ ): Promise<ReadonlySet<string>> {
48
+ const ids = new Set<string>();
49
+ for (let start = 0; start < providerMessageIds.length; start += LOOKUP_CHUNK_SIZE) {
50
+ const rows = await selectMany<{ providerMessageId: string }>(
51
+ db,
52
+ quarantineTable,
53
+ {
54
+ accountId,
55
+ providerMessageId: { in: providerMessageIds.slice(start, start + LOOKUP_CHUNK_SIZE) },
56
+ },
57
+ { fields: ["providerMessageId"] },
58
+ );
59
+ for (const row of rows) ids.add(row.providerMessageId);
60
+ }
61
+ return ids;
62
+ }
63
+
64
+ export async function hasOpenQuarantine(db: DbConnection, accountId: string): Promise<boolean> {
65
+ const rows = await selectMany<{ id: string }>(
66
+ db,
67
+ quarantineTable,
68
+ { accountId, acknowledgedAt: null },
69
+ { fields: ["id"], limit: 1 },
70
+ );
71
+ return rows.length > 0;
72
+ }
@@ -0,0 +1,78 @@
1
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
2
+ import type { FeatureRegistrar } from "@cosmicdrift/kumiko-framework/engine";
3
+ import {
4
+ createFileProviderForTenant,
5
+ deleteFileRefsBoundToRecord,
6
+ type FileStorageProvider,
7
+ } from "@cosmicdrift/kumiko-framework/files";
8
+ import { INBOUND_MESSAGE_AGGREGATE_TYPE } from "./events.ts";
9
+ import { runInboundMailRetention } from "./retention-sweep.ts";
10
+
11
+ export function registerInboundMailRetentionJob(r: FeatureRegistrar<"inbound-mail-foundation">) {
12
+ // Data-Retention — perTenant fan-out (one run per tenant, like
13
+ // data-retention:retention-cleanup): runInboundMailRetention's raw query
14
+ // helpers need the unfiltered runner, so the handler declares escapeHatch
15
+ // and reaches for ctx.db.unsafeRaw(). Without this cron, read_inbound_messages
16
+ // + the bodies grow unbounded (GDPR Art. 5).
17
+ const INBOUND_MAIL_RETENTION_REASON =
18
+ "inbound-mail retention runs raw deletes; every statement filters by this run's tenantId";
19
+ r.job({
20
+ name: "inbound-mail-retention",
21
+ trigger: { cron: "0 3 * * *" },
22
+ perTenant: true,
23
+ concurrency: "skip",
24
+ escapeHatch: { reason: INBOUND_MAIL_RETENTION_REASON },
25
+ handler: async (_payload, ctx) => {
26
+ if (!ctx.registry) {
27
+ throw new Error("inbound-mail-retention: ctx.registry required (JobContext incomplete)");
28
+ }
29
+ const tenantId = ctx.systemUser?.tenantId ?? ctx._tenantId;
30
+ if (tenantId === undefined) {
31
+ // skip: cron fired without a perTenant fan-out tenant — nothing scoped
32
+ return;
33
+ }
34
+ const T = (await import("@cosmicdrift/kumiko-framework/time")).getTemporal();
35
+ const retentionDb = ctx.db.unsafeRaw() as DbConnection; // @cast-boundary db-operator — jobs never run inside a DbTx
36
+
37
+ // Original (.eml/.html) objects live in file-foundation; the provider is lazy and
38
+ // memoized so a tenant without purgeable mails never builds it.
39
+ let providerPromise: Promise<FileStorageProvider> | null = null;
40
+ const deleteBodyObject = async (bodyRef: string): Promise<void> => {
41
+ if (!providerPromise) {
42
+ providerPromise = createFileProviderForTenant(
43
+ { config: ctx.config, registry: ctx.registry, secrets: ctx.services.secrets },
44
+ tenantId,
45
+ );
46
+ }
47
+ const provider = await providerPromise;
48
+ await provider.delete(bodyRef);
49
+ };
50
+
51
+ const { systemUser } = ctx;
52
+ const deleteAttachments = systemUser
53
+ ? async (messageId: string): Promise<void> => {
54
+ await deleteFileRefsBoundToRecord({
55
+ db: ctx.db,
56
+ user: systemUser,
57
+ entityType: INBOUND_MESSAGE_AGGREGATE_TYPE,
58
+ entityId: messageId,
59
+ });
60
+ }
61
+ : undefined;
62
+
63
+ const report = await runInboundMailRetention({
64
+ db: retentionDb,
65
+ tenantId,
66
+ now: T.Now.instant(),
67
+ deleteBodyObject,
68
+ ...(deleteAttachments ? { deleteAttachments } : {}),
69
+ });
70
+ if (report.bodyObjectErrors > 0) {
71
+ // biome-ignore lint/suspicious/noConsole: operator-visibility for failed body deletes
72
+ console.warn(
73
+ `[inbound-mail-foundation:inbound-mail-retention] tenant=${tenantId} bodyObjectErrors=${report.bodyObjectErrors}`,
74
+ );
75
+ }
76
+ },
77
+ });
78
+ }
@@ -20,6 +20,18 @@
20
20
  // directly through the table's tenant_id column (table-builder convention),
21
21
  // without an Account join.
22
22
  //
23
+ // store_mail_ingest_failures (unmanaged): counters of mails the ingest could not take. A counter
24
+ // whose mail never failed again (it went through or was quarantined, which clear it) is stale
25
+ // after the seen retention.
26
+ //
27
+ // store_mail_quarantine (unmanaged): acknowledged entries are purged after
28
+ // QUARANTINE_ACKNOWLEDGED_RETENTION_DAYS; unacknowledged ones stay, they
29
+ // keep the mailbox flagged until someone looked at them.
30
+ //
31
+ // Attachments are fileRefs bound to the mail row: they go with it (soft delete through the
32
+ // fileRef executor; the generic soft-delete cleanup removes the bytes). A failure keeps the Row
33
+ // for the next sweep, like a failed body delete.
34
+ //
23
35
  // LIMITATION: the PII in the message events remains decryptable under the
24
36
  // TENANT subject key—per-Row retention does not shred a key. Actual Art.-17
25
37
  // erasure of event payloads happens only during tenant-destroy (key erasure).
@@ -35,7 +47,10 @@ import {
35
47
  import type { getTemporal } from "@cosmicdrift/kumiko-framework/time";
36
48
  import { seenMessageTable } from "./entities.ts";
37
49
  import { INBOUND_MESSAGE_AGGREGATE_TYPE } from "./events.ts";
50
+ import { ingestFailureTable } from "./ingest-failures.ts";
38
51
  import { inboundMessagesProjectionTable } from "./projection.ts";
52
+ import { QUARANTINE_ACKNOWLEDGED_RETENTION_DAYS } from "./quarantine.ts";
53
+ import { quarantineTable } from "./quarantine-entities.ts";
39
54
 
40
55
  // Provides Temporal.Instant without forcing the caller to obtain it from
41
56
  // globalThis (same pattern as data-retention/keep-for.ts).
@@ -59,6 +74,8 @@ export interface InboundRetentionArgs {
59
74
  /** Deletes a file-storage body object by bodyRef key. Wired lazily by the job
60
75
  * (createFileProviderForTenant); if absent, no body is deleted. */
61
76
  readonly deleteBodyObject?: (bodyRef: string) => Promise<void>;
77
+ /** Removes the fileRefs bound to an expired mail (its attachments); wired by the job. */
78
+ readonly deleteAttachments?: (messageId: string) => Promise<void>;
62
79
  }
63
80
 
64
81
  export interface InboundRetentionReport {
@@ -66,6 +83,8 @@ export interface InboundRetentionReport {
66
83
  readonly bodyObjectsDeleted: number;
67
84
  readonly bodyObjectErrors: number;
68
85
  readonly seenPurged: number;
86
+ readonly quarantinePurged: number;
87
+ readonly ingestFailuresPurged: number;
69
88
  }
70
89
 
71
90
  export async function runInboundMailRetention(
@@ -79,7 +98,20 @@ export async function runInboundMailRetention(
79
98
  const messages = await purgeExpiredMessages(args, messageCutoff);
80
99
  const seenPurged = await purgeExpiredSeenAnchors(args.db, args.tenantId, seenCutoff);
81
100
 
82
- return { ...messages, seenPurged };
101
+ const quarantineCutoff = args.now.subtract({
102
+ hours: QUARANTINE_ACKNOWLEDGED_RETENTION_DAYS * 24,
103
+ });
104
+ const quarantinePurged = await deleteMany(args.db, quarantineTable, {
105
+ tenantId: args.tenantId,
106
+ acknowledgedAt: { lt: quarantineCutoff },
107
+ });
108
+
109
+ const ingestFailuresPurged = await deleteMany(args.db, ingestFailureTable, {
110
+ tenantId: args.tenantId,
111
+ lastFailedAt: { lt: seenCutoff },
112
+ });
113
+
114
+ return { ...messages, seenPurged, quarantinePurged, ingestFailuresPurged };
83
115
  }
84
116
 
85
117
  type ExpiredMessageRow = { readonly id: string; readonly bodyRef: string | null };
@@ -87,7 +119,9 @@ type ExpiredMessageRow = { readonly id: string; readonly bodyRef: string | null
87
119
  async function purgeExpiredMessages(
88
120
  args: InboundRetentionArgs,
89
121
  cutoff: Instant,
90
- ): Promise<Omit<InboundRetentionReport, "seenPurged">> {
122
+ ): Promise<
123
+ Omit<InboundRetentionReport, "seenPurged" | "quarantinePurged" | "ingestFailuresPurged">
124
+ > {
91
125
  let messagesPurged = 0;
92
126
  let bodyObjectsDeleted = 0;
93
127
  let bodyObjectErrors = 0;
@@ -125,15 +159,16 @@ async function purgeExpiredMessages(
125
159
  archivedBy: ARCHIVED_BY,
126
160
  reason: REASON,
127
161
  });
128
- if (row.bodyRef && args.deleteBodyObject) {
129
- try {
162
+ try {
163
+ if (row.bodyRef && args.deleteBodyObject) {
130
164
  await args.deleteBodyObject(row.bodyRef);
131
165
  bodyObjectsDeleted++;
132
- } catch {
133
- bodyObjectErrors++;
134
- failedIds.add(row.id);
135
- continue;
136
166
  }
167
+ await args.deleteAttachments?.(row.id);
168
+ } catch {
169
+ bodyObjectErrors++;
170
+ failedIds.add(row.id);
171
+ continue;
137
172
  }
138
173
  ids.push(row.id);
139
174
  }