@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,146 @@
1
+ // Account health: maps what the supervisor observes (repeated poll failures, quarantined mails, file
2
+ // storage, KMS or ingest outage) onto the account status "degraded" plus a lastError code, and back to "active" once
3
+ // nothing is wrong any more. Auth errors and disconnects are never touched here. An update is
4
+ // dispatched only when status or lastError really changes, so a healthy mailbox causes no events.
5
+
6
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
7
+ import {
8
+ type InboundMailAccountStatus,
9
+ InboundMailAccountStatuses,
10
+ InboundMailFoundationHandlers,
11
+ type InboundMailLastError,
12
+ InboundMailLastErrors,
13
+ } from "./constants.ts";
14
+ import { hasOpenQuarantine } from "./quarantine.ts";
15
+ import type {
16
+ DispatchWrite,
17
+ IngestBatchOutcome,
18
+ IngestBlockedReason,
19
+ } from "./supervisor-ingest.ts";
20
+ import type { AccountHealthRef, SyncableAccount } from "./syncable-accounts.ts";
21
+
22
+ const FAILURES_BEFORE_DEGRADED = 3;
23
+ const OUTAGE_LAST_ERRORS: ReadonlySet<string> = new Set([
24
+ InboundMailLastErrors.encryptionUnavailable,
25
+ InboundMailLastErrors.ingestUnavailable,
26
+ InboundMailLastErrors.fileStorageUnavailable,
27
+ ]);
28
+ const SUPERVISOR_AUDIT_REASON = "watch_supervisor";
29
+
30
+ export type PollFailureKind = "transient" | "rate_limited";
31
+
32
+ type AppliedHealth = {
33
+ readonly status: InboundMailAccountStatus;
34
+ readonly lastError: string | null;
35
+ };
36
+
37
+ export type AccountHealth = {
38
+ readonly onPollSuccess: (
39
+ account: SyncableAccount,
40
+ outcome: Pick<IngestBatchOutcome, "storageUnavailable" | "storageHealthy">,
41
+ ) => Promise<void>;
42
+ readonly onPollFailure: (account: SyncableAccount, kind: PollFailureKind) => Promise<void>;
43
+ /** The cursor stayed before a failing mail: either the mail waits for its count or the ingest is down. */
44
+ readonly onIngestBlocked: (
45
+ account: SyncableAccount,
46
+ blocked: IngestBlockedReason,
47
+ outcome: Pick<IngestBatchOutcome, "storageHealthy">,
48
+ ) => Promise<void>;
49
+ /** The address did not decrypt because the key store is down, so the account could not be polled. */
50
+ readonly onKeyStoreUnavailable: (account: AccountHealthRef) => Promise<void>;
51
+ };
52
+
53
+ export function createAccountHealth(deps: {
54
+ readonly db: DbConnection;
55
+ readonly dispatchWrite: DispatchWrite;
56
+ readonly log: (line: string) => void;
57
+ }): AccountHealth {
58
+ const consecutiveFailures = new Map<string, number>();
59
+ const storageUnavailable = new Set<string>();
60
+ // The account snapshot of a tick is stale after our own update; this keeps the "real change" check honest.
61
+ // Per process: a drift between workers costs at most one redundant update-account.
62
+ const applied = new Map<string, AppliedHealth>();
63
+
64
+ async function apply(account: AccountHealthRef, lastError: InboundMailLastError | null) {
65
+ const known = applied.get(account.id) ?? {
66
+ status: account.status,
67
+ lastError: account.lastError,
68
+ };
69
+ const status =
70
+ lastError === null ? InboundMailAccountStatuses.active : InboundMailAccountStatuses.degraded;
71
+ // skip: the stored health already matches, nothing to write.
72
+ if (known.status === status && known.lastError === lastError) return;
73
+ const result = await deps.dispatchWrite({
74
+ handlerQn: InboundMailFoundationHandlers.updateAccount,
75
+ tenantId: account.tenantId,
76
+ payload: { accountId: account.id, status, lastError, reason: SUPERVISOR_AUDIT_REASON },
77
+ });
78
+ if (!result.isSuccess) {
79
+ deps.log(
80
+ `inbound-mail: account ${account.id} health update was rejected (${rejectionCode(result.error)})`,
81
+ );
82
+ // skip: not recorded as applied, so the next tick retries the update.
83
+ return;
84
+ }
85
+ applied.set(account.id, { status, lastError });
86
+ }
87
+
88
+ async function lastErrorWithoutOutage(account: AccountHealthRef) {
89
+ if (await hasOpenQuarantine(deps.db, account.id)) {
90
+ return InboundMailLastErrors.messageQuarantined;
91
+ }
92
+ if (storageUnavailable.has(account.id)) return InboundMailLastErrors.fileStorageUnavailable;
93
+ return null;
94
+ }
95
+
96
+ return {
97
+ async onPollSuccess(account, outcome) {
98
+ consecutiveFailures.delete(account.id);
99
+ if (outcome.storageUnavailable) storageUnavailable.add(account.id);
100
+ else if (outcome.storageHealthy) storageUnavailable.delete(account.id);
101
+ return apply(account, await lastErrorWithoutOutage(account));
102
+ },
103
+ async onIngestBlocked(account, blocked, outcome) {
104
+ consecutiveFailures.delete(account.id);
105
+ if (blocked === "message_failing") {
106
+ deps.log(`inbound-mail: account ${account.id} waits for a failing message to be counted`);
107
+ if (outcome.storageHealthy) storageUnavailable.delete(account.id);
108
+ // A counted failure proves the ingest works again, so a shown outage is over; anything else stays.
109
+ const shown = (applied.get(account.id) ?? account).lastError;
110
+ // skip: no outage is shown, the status stays as it is
111
+ if (shown === null || !OUTAGE_LAST_ERRORS.has(shown)) return;
112
+ return apply(account, await lastErrorWithoutOutage(account));
113
+ }
114
+ // KMS and ingest outages need no memory like storage: the next unblocked poll proves they are over.
115
+ if (blocked === "encryption_unavailable") {
116
+ return apply(account, InboundMailLastErrors.encryptionUnavailable);
117
+ }
118
+ if (blocked === "ingest_unavailable") {
119
+ return apply(account, InboundMailLastErrors.ingestUnavailable);
120
+ }
121
+ storageUnavailable.add(account.id);
122
+ return apply(account, InboundMailLastErrors.fileStorageUnavailable);
123
+ },
124
+ async onKeyStoreUnavailable(account) {
125
+ consecutiveFailures.delete(account.id);
126
+ return apply(account, InboundMailLastErrors.encryptionUnavailable);
127
+ },
128
+ async onPollFailure(account, kind) {
129
+ const count = (consecutiveFailures.get(account.id) ?? 0) + 1;
130
+ consecutiveFailures.set(account.id, count);
131
+ // skip: below the failure threshold the mailbox stays as it is.
132
+ if (count < FAILURES_BEFORE_DEGRADED) return;
133
+ return apply(
134
+ account,
135
+ kind === "rate_limited"
136
+ ? InboundMailLastErrors.rateLimited
137
+ : InboundMailLastErrors.connectionFailed,
138
+ );
139
+ },
140
+ };
141
+ }
142
+
143
+ function rejectionCode(error: unknown): string {
144
+ if (typeof error === "object" && error !== null && "code" in error) return String(error.code);
145
+ return "unknown";
146
+ }
@@ -0,0 +1,74 @@
1
+ // How the supervisor tick spreads its accounts: a bounded number of syncs side by side, and a
2
+ // deadline after which a sync that never answers is abandoned instead of holding the tick.
3
+
4
+ import type { PollAccountOptions, PollAccountResult } from "./sync-account-now.ts";
5
+ import type { SyncableAccount } from "./syncable-accounts.ts";
6
+ import type { InboundMailProviderPlugin } from "./types.ts";
7
+
8
+ export const DEFAULT_POLL_CONCURRENCY = 4;
9
+ export const DEFAULT_ACCOUNT_POLL_DEADLINE_MS = 10 * 60 * 1000;
10
+
11
+ export function pollScheduleOf(options: {
12
+ readonly pollConcurrency?: number;
13
+ readonly accountPollDeadlineMs?: number;
14
+ }): { readonly pollConcurrency: number; readonly accountPollDeadlineMs: number } {
15
+ return {
16
+ pollConcurrency: options.pollConcurrency ?? DEFAULT_POLL_CONCURRENCY,
17
+ accountPollDeadlineMs: options.accountPollDeadlineMs ?? DEFAULT_ACCOUNT_POLL_DEADLINE_MS,
18
+ };
19
+ }
20
+
21
+ type DeadlineOutcome<T> =
22
+ | { readonly timedOut: false; readonly value: T }
23
+ | { readonly timedOut: true };
24
+
25
+ /** Aborts the signal when the deadline passes. The abandoned work may still settle later; its
26
+ * outcome is dropped and a late rejection is swallowed. */
27
+ async function runWithDeadline<T>(
28
+ deadlineMs: number,
29
+ run: (signal: AbortSignal) => Promise<T>,
30
+ ): Promise<DeadlineOutcome<T>> {
31
+ const controller = new AbortController();
32
+ const work = run(controller.signal);
33
+ let timer: ReturnType<typeof setTimeout> | undefined;
34
+ const deadline = new Promise<DeadlineOutcome<T>>((resolve) => {
35
+ timer = setTimeout(() => resolve({ timedOut: true }), deadlineMs);
36
+ });
37
+ try {
38
+ const outcome = await Promise.race([
39
+ work.then((value): DeadlineOutcome<T> => ({ timedOut: false, value })),
40
+ deadline,
41
+ ]);
42
+ if (outcome.timedOut) {
43
+ controller.abort();
44
+ work.catch(() => {});
45
+ }
46
+ return outcome;
47
+ } finally {
48
+ clearTimeout(timer);
49
+ }
50
+ }
51
+
52
+ type PollAccount = (
53
+ account: SyncableAccount,
54
+ plugin: InboundMailProviderPlugin,
55
+ options: PollAccountOptions,
56
+ ) => Promise<PollAccountResult>;
57
+
58
+ /** A sync past the deadline counts as one failed poll and its in-flight slot is freed. */
59
+ export function withPollDeadline(
60
+ pollAccount: PollAccount,
61
+ deadline: {
62
+ readonly ms: number;
63
+ readonly onDeadline: (account: SyncableAccount) => Promise<void>;
64
+ },
65
+ ): PollAccount {
66
+ return async (account, plugin, options) => {
67
+ const outcome = await runWithDeadline(deadline.ms, (signal) =>
68
+ pollAccount(account, plugin, { ...options, signal }),
69
+ );
70
+ if (!outcome.timedOut) return outcome.value;
71
+ await deadline.onDeadline(account);
72
+ return "done";
73
+ };
74
+ }
@@ -0,0 +1,238 @@
1
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
2
+ import { selectMany } from "@cosmicdrift/kumiko-framework/db";
3
+ import type { AccountHealth, PollFailureKind } from "./account-health.ts";
4
+ import { refetchSnippetOnlyMessages } from "./content-refetch.ts";
5
+ import { describeError } from "./describe-error.ts";
6
+ import { seenMessageTable } from "./entities.ts";
7
+ import {
8
+ isInboundAuthError,
9
+ isInboundCredentialNotStoredError,
10
+ isInboundCursorInvalidError,
11
+ isInboundRateLimitError,
12
+ } from "./provider-errors.ts";
13
+ import { quarantinedMessageIds } from "./quarantine.ts";
14
+ import { retryQuarantinedMessages } from "./quarantine-retry.ts";
15
+ import type {
16
+ DispatchWrite,
17
+ IngestBatchOutcome,
18
+ IngestBlockedReason,
19
+ MessageIngester,
20
+ } from "./supervisor-ingest.ts";
21
+ import type { PollAccountOptions, PollAccountResult } from "./sync-account-now.ts";
22
+ import type { SyncCursorStore } from "./sync-cursor-store.ts";
23
+ import type { SyncableAccount } from "./syncable-accounts.ts";
24
+ import type { InboundMailContext, InboundMailProviderPlugin, MailAccountRecord } from "./types.ts";
25
+
26
+ const SEEN_LOOKUP_CHUNK_SIZE = 1000;
27
+
28
+ export type AccountPollerDeps = {
29
+ readonly systemDb: DbConnection;
30
+ readonly cursors: SyncCursorStore;
31
+ readonly ingester: MessageIngester;
32
+ readonly health: AccountHealth;
33
+ readonly providerCtx: InboundMailContext;
34
+ readonly dispatchWrite: DispatchWrite;
35
+ readonly backfillWindowDays: number;
36
+ readonly maxMessagesPerPoll: number;
37
+ readonly resolveAccessToken: (
38
+ account: MailAccountRecord,
39
+ plugin: InboundMailProviderPlugin,
40
+ ) => Promise<string | undefined>;
41
+ /** Returns true when the account may keep running. */
42
+ readonly handleSyncError: (account: MailAccountRecord, err: unknown) => Promise<boolean>;
43
+ readonly log: (line: string) => void;
44
+ };
45
+
46
+ export function createAccountPoller(deps: AccountPollerDeps): {
47
+ readonly pollAccount: (
48
+ account: SyncableAccount,
49
+ plugin: InboundMailProviderPlugin,
50
+ options?: PollAccountOptions,
51
+ ) => Promise<PollAccountResult>;
52
+ } {
53
+ const {
54
+ systemDb,
55
+ cursors,
56
+ ingester,
57
+ health,
58
+ providerCtx,
59
+ backfillWindowDays,
60
+ maxMessagesPerPoll,
61
+ resolveAccessToken,
62
+ handleSyncError,
63
+ log,
64
+ } = deps;
65
+
66
+ function unknownMessageIdsFilterFor(account: MailAccountRecord) {
67
+ return async (providerMessageIds: readonly string[]): Promise<ReadonlySet<string>> => {
68
+ if (providerMessageIds.length === 0) return new Set();
69
+ // One bind parameter per id: a full-mailbox listing would exceed Postgres' 65535 limit.
70
+ const seenIds = new Set<string>();
71
+ for (let start = 0; start < providerMessageIds.length; start += SEEN_LOOKUP_CHUNK_SIZE) {
72
+ const seen = await selectMany<{ providerMessageId: string }>(
73
+ systemDb,
74
+ seenMessageTable,
75
+ {
76
+ tenantId: account.tenantId,
77
+ accountId: account.id,
78
+ providerMessageId: {
79
+ in: providerMessageIds.slice(start, start + SEEN_LOOKUP_CHUNK_SIZE),
80
+ },
81
+ },
82
+ { fields: ["providerMessageId"] },
83
+ );
84
+ for (const row of seen) seenIds.add(row.providerMessageId);
85
+ }
86
+ // A quarantined message counts as seen: re-fetching it would only fail the same way again.
87
+ for (const id of await quarantinedMessageIds(systemDb, account.id, providerMessageIds)) {
88
+ seenIds.add(id);
89
+ }
90
+ return new Set(providerMessageIds.filter((id) => !seenIds.has(id)));
91
+ };
92
+ }
93
+
94
+ function pollFailureKindOf(err: unknown): PollFailureKind | null {
95
+ if (isInboundAuthError(err) || isInboundCursorInvalidError(err)) return null;
96
+ return isInboundRateLimitError(err) ? "rate_limited" : "transient";
97
+ }
98
+
99
+ async function refetchContentSafely(
100
+ account: SyncableAccount,
101
+ plugin: InboundMailProviderPlugin,
102
+ signal: AbortSignal | undefined,
103
+ ): Promise<void> {
104
+ // skip: the provider cannot reload content.
105
+ if (!plugin.fetchContent) return;
106
+ try {
107
+ const accessToken = await resolveAccessToken(account, plugin);
108
+ await refetchSnippetOnlyMessages({
109
+ db: systemDb,
110
+ dispatchWrite: deps.dispatchWrite,
111
+ providerCtx,
112
+ account,
113
+ plugin,
114
+ windowDays: backfillWindowDays,
115
+ accessToken,
116
+ signal,
117
+ });
118
+ } catch (err) {
119
+ // Best effort: the sync itself succeeded, the next tick retries the reload.
120
+ log(`inbound-mail: content reload for account ${account.id} failed: ${describeError(err)}`);
121
+ }
122
+ }
123
+
124
+ async function retryQuarantineSafely(
125
+ account: SyncableAccount,
126
+ plugin: InboundMailProviderPlugin,
127
+ signal: AbortSignal | undefined,
128
+ ): Promise<void> {
129
+ try {
130
+ const accessToken = await resolveAccessToken(account, plugin);
131
+ await retryQuarantinedMessages({
132
+ db: systemDb,
133
+ dispatchWrite: deps.dispatchWrite,
134
+ providerCtx,
135
+ account,
136
+ plugin,
137
+ accessToken,
138
+ signal,
139
+ log,
140
+ });
141
+ } catch (err) {
142
+ // Best effort: the sync itself succeeded, the flag stays and the next tick retries.
143
+ log(`inbound-mail: quarantine retry for account ${account.id} failed: ${describeError(err)}`);
144
+ }
145
+ }
146
+
147
+ // Fetches and ingests batch after batch until the provider has no more or the message budget of
148
+ // this tick is used up. An abandoned sync (deadline) throws out of the loop.
149
+ async function ingestBatches(
150
+ account: SyncableAccount,
151
+ plugin: InboundMailProviderPlugin,
152
+ options: PollAccountOptions,
153
+ ): Promise<{
154
+ storageUnavailable: boolean;
155
+ storageHealthy: boolean;
156
+ ingested: boolean;
157
+ blocked?: IngestBlockedReason;
158
+ }> {
159
+ const { signal } = options;
160
+ let cursor = await cursors.load(account.id);
161
+ let budget = maxMessagesPerPoll;
162
+ const totals = { storageUnavailable: false, storageHealthy: false, ingested: false };
163
+ for (;;) {
164
+ const accessToken = await resolveAccessToken(account, plugin);
165
+ const result = await plugin.fetch(providerCtx, account, cursor, {
166
+ backfillWindowDays,
167
+ maxMessages: budget,
168
+ filterUnknownMessageIds: unknownMessageIdsFilterFor(account),
169
+ ...(accessToken !== undefined && { accessToken }),
170
+ ...(signal && { signal }),
171
+ });
172
+ // The sync was abandoned while the provider was busy; its late result is dropped.
173
+ signal?.throwIfAborted();
174
+ const outcome: IngestBatchOutcome = await ingester.ingestBatch(
175
+ account,
176
+ result.messages,
177
+ result.rejected ?? [],
178
+ JSON.stringify(result.nextCursor),
179
+ {
180
+ ...(options.countBucket !== undefined && { countBucket: options.countBucket }),
181
+ ...(signal && { signal }),
182
+ proof: { ingested: totals.ingested, storageHealthy: totals.storageHealthy },
183
+ },
184
+ );
185
+ totals.storageUnavailable ||= outcome.storageUnavailable;
186
+ totals.storageHealthy ||= outcome.storageHealthy;
187
+ totals.ingested ||= outcome.ingested;
188
+ // Blocked: the cursor stays before the stuck mail, the next tick fetches it again.
189
+ if (outcome.blocked) return { ...totals, blocked: outcome.blocked };
190
+ // A newer poll may already own the account; an abandoned one must not move its cursor back.
191
+ signal?.throwIfAborted();
192
+ await cursors.save(account, result.nextCursor);
193
+ cursor = result.nextCursor;
194
+ budget -= result.messages.length;
195
+ if (!result.hasMore || budget <= 0) return totals;
196
+ }
197
+ }
198
+
199
+ async function pollAccount(
200
+ account: SyncableAccount,
201
+ plugin: InboundMailProviderPlugin,
202
+ options: PollAccountOptions = {},
203
+ ): Promise<PollAccountResult> {
204
+ let totals: Awaited<ReturnType<typeof ingestBatches>>;
205
+ try {
206
+ totals = await ingestBatches(account, plugin, options);
207
+ } catch (err) {
208
+ // skip: an error caused by abandoning the sync is already counted as the deadline failure.
209
+ if (options.signal?.aborted) return "done";
210
+ if (options.fromWake && isInboundCredentialNotStoredError(err)) {
211
+ // The secret is written after the account is created; the next tick picks it up.
212
+ log(`inbound-mail: account ${account.id} credential not stored yet, wake sync skipped`);
213
+ return "credential_pending";
214
+ }
215
+ const stillRunning = await handleSyncError(account, err);
216
+ const failureKind = pollFailureKindOf(err);
217
+ if (stillRunning && failureKind) await health.onPollFailure(account, failureKind);
218
+ return "done";
219
+ }
220
+ // skip: abandoned during the last batch; the deadline already counted this poll as failed.
221
+ if (options.signal?.aborted) return "done";
222
+ if (totals.blocked) {
223
+ await health.onIngestBlocked(account, totals.blocked, totals);
224
+ // A retry asked for by an admin does not depend on the cursor, so a stuck mail must not hold it.
225
+ // During an outage it would fail the same way; the flag stays and runs after the recovery.
226
+ if (totals.blocked === "message_failing") {
227
+ await retryQuarantineSafely(account, plugin, options.signal);
228
+ }
229
+ return "done";
230
+ }
231
+ await health.onPollSuccess(account, totals);
232
+ await retryQuarantineSafely(account, plugin, options.signal);
233
+ await refetchContentSafely(account, plugin, options.signal);
234
+ return "done";
235
+ }
236
+
237
+ return { pollAccount };
238
+ }
@@ -16,6 +16,7 @@ export type MailAccountRow = {
16
16
  readonly displayName?: string | null;
17
17
  readonly status?: string | null;
18
18
  readonly watchState?: string | null;
19
+ readonly trustedAuthservId?: string | null;
19
20
  };
20
21
 
21
22
  function isAccountStatus(value: unknown): value is InboundMailAccountStatus {
@@ -44,5 +45,6 @@ export async function toMailAccountRecord(
44
45
  displayName: row.displayName ?? "",
45
46
  status: isAccountStatus(row.status) ? row.status : InboundMailAccountStatuses.active,
46
47
  watchState: row.watchState ?? "idle",
48
+ trustedAuthservId: row.trustedAuthservId ?? null,
47
49
  };
48
50
  }
@@ -0,0 +1,21 @@
1
+ // Process-local wake channel between the account-event consumer (feature.ts) and the sync supervisor.
2
+ // A freshly connected, reconnected or re-activated mailbox is synced right away instead of waiting
3
+ // for the next poll tick. The consumer runs "per-instance", so every process wakes its own supervisor.
4
+ //
5
+ // Without a running event dispatcher in the process (or without a supervisor) nothing wakes anyone;
6
+ // the regular poll tick then picks the account up, so this is purely a latency optimisation.
7
+
8
+ export type MailAccountWakeListener = (tenantId: string, accountId: string) => void;
9
+
10
+ const listeners = new Set<MailAccountWakeListener>();
11
+
12
+ export function registerMailAccountWakeListener(listener: MailAccountWakeListener): () => void {
13
+ listeners.add(listener);
14
+ return () => {
15
+ listeners.delete(listener);
16
+ };
17
+ }
18
+
19
+ export function wakeMailAccount(tenantId: string, accountId: string): void {
20
+ for (const listener of listeners) listener(tenantId, accountId);
21
+ }