@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
@@ -43,45 +43,42 @@
43
43
  // a Redis outage or expired TTL) tears down the local watcher instead of
44
44
  // racing a new holder.
45
45
 
46
- import {
47
- configuredPiiSubjectKms,
48
- decryptPiiFieldValues,
49
- } from "@cosmicdrift/kumiko-framework/crypto";
50
- import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
51
- import { fetchOne, insertOne, selectMany, updateMany } from "@cosmicdrift/kumiko-framework/db";
52
46
  import { SYSTEM_USER_ID } from "@cosmicdrift/kumiko-framework/engine";
53
47
  import {
54
48
  systemScopedRunner,
55
49
  tenantScopedSecrets,
56
50
  } from "@cosmicdrift/kumiko-framework/internal/row-level-security";
57
- import type { DistributedLock } from "@cosmicdrift/kumiko-framework/pipeline";
58
- import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
59
- import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
51
+ import { mapWithConcurrency } from "../shared/index.ts";
52
+ import { createAccountHealth } from "./account-health.ts";
53
+ import { createAccountPoller } from "./account-poll.ts";
54
+ import { pollScheduleOf, withPollDeadline } from "./account-poll-schedule.ts";
55
+ import { registerMailAccountWakeListener } from "./account-wake.ts";
60
56
  import { InboundMailAccountStatuses, InboundMailFoundationHandlers } from "./constants.ts";
61
- import { MAIL_ACCOUNT_PII_FIELDS, seenMessageTable, syncCursorTable } from "./entities.ts";
62
- import {
63
- createOAuthAccessTokenManager,
64
- type OAuthAccessTokenManager,
65
- usesFoundationManagedOAuth,
66
- } from "./oauth-access-token.ts";
67
- import { mailAccountsProjectionTable } from "./projection.ts";
68
- import { resolveInboundProviderForAccount } from "./provider-factory.ts";
57
+ import { describeError } from "./describe-error.ts";
58
+ import { currentCountBucket } from "./ingest-failures.ts";
59
+ import { createOAuthAccessTokenManager, usesFoundationManagedOAuth } from "./oauth-access-token.ts";
69
60
  import {
70
- type InboundMailContext,
71
- type InboundMailProviderPlugin,
72
61
  isInboundAuthError,
73
62
  isInboundCursorInvalidError,
74
63
  isInboundRateLimitError,
75
- type MailAccountRecord,
76
- type RawInboundMessage,
77
- type SyncCursorPayload,
78
- } from "./types.ts";
79
-
80
- // Provider errors can embed token or response data in `message`; logs carry
81
- // only the error class.
82
- function describeError(err: unknown): string {
83
- return err instanceof Error ? err.name : typeof err;
84
- }
64
+ } from "./provider-errors.ts";
65
+ import { resolveInboundProviderForAccount } from "./provider-factory.ts";
66
+ import { createMessageIngester } from "./supervisor-ingest.ts";
67
+ import {
68
+ createSyncAccountNow,
69
+ type InFlightSyncs,
70
+ runExclusivePerAccount,
71
+ } from "./sync-account-now.ts";
72
+ import { createSyncCursorStore } from "./sync-cursor-store.ts";
73
+ import { listSyncableAccounts, type SyncableAccount } from "./syncable-accounts.ts";
74
+ import type { InboundMailContext, InboundMailProviderPlugin, MailAccountRecord } from "./types.ts";
75
+ import { createWatchCatchUp } from "./watch-catch-up.ts";
76
+ import { createWatchLease } from "./watch-lease.ts";
77
+ import type {
78
+ InboundMailSupervisor,
79
+ InboundMailSupervisorDeps,
80
+ WatcherState,
81
+ } from "./watch-supervisor-types.ts";
85
82
 
86
83
  const DEFAULT_POLL_INTERVAL_MS = 5 * 60 * 1000;
87
84
  const DEFAULT_BACKFILL_WINDOW_DAYS = 30;
@@ -89,92 +86,6 @@ const DEFAULT_MAX_MESSAGES_PER_POLL = 200;
89
86
  const DEFAULT_WATCH_BACKOFF_INITIAL_MS = 5_000;
90
87
  const DEFAULT_WATCH_BACKOFF_MAX_MS = 5 * 60 * 1000;
91
88
  const DEFAULT_WATCH_LEASE_TTL_SECONDS = 90;
92
- /** One cursor per account (one mailbox inbox) for now; multi-folder support
93
- * can add further scopes later without a schema change. */
94
- const CURSOR_SCOPE = "default";
95
-
96
- const SEEN_LOOKUP_CHUNK_SIZE = 1000;
97
-
98
- export type InboundMailSupervisorDeps = {
99
- /** Slim context for provider calls (`registry` is required; `secrets` is
100
- * used for provider credential reads). */
101
- readonly providerCtx: InboundMailContext;
102
- /** App database — direct reads from read_mail_accounts (account snapshot)
103
- * and direct writes to store_mail_sync_cursors (unmanaged store). */
104
- readonly db: DbConnection;
105
- /** Standard dispatcher with SystemUser — carries ingest-message and
106
- * update-account. */
107
- readonly dispatchWrite: (args: {
108
- readonly handlerQn: string;
109
- readonly payload: unknown;
110
- readonly tenantId: string;
111
- }) => Promise<{
112
- readonly isSuccess: boolean;
113
- readonly data?: unknown;
114
- readonly error?: unknown;
115
- }>;
116
- /** Persists the raw MIME body (file-foundation) and returns its bodyRef
117
- * for the event. Without this hook, runs in snippet-only mode
118
- * (bodyRef = ""). */
119
- readonly storeBody?: (account: MailAccountRecord, msg: RawInboundMessage) => Promise<string>;
120
- readonly pollIntervalMs?: number;
121
- readonly backfillWindowDays?: number;
122
- readonly maxMessagesPerPoll?: number;
123
- readonly watchBackoffInitialMs?: number;
124
- readonly watchBackoffMaxMs?: number;
125
- /** Coordinates the IMAP watch (not the poll) across worker processes:
126
- * before `plugin.watch()`, ensureWatcher claims a TTL lease for the
127
- * account via `lock.acquire` and renews it via `lock.renew` while the
128
- * watcher lives. Omit for single-process deployments — every active
129
- * account is watched locally, same as before. */
130
- readonly lock?: DistributedLock;
131
- /** Access-token lifecycle for OAuth accounts. Defaults to a manager built
132
- * from `db` + `providerCtx.secrets`; pass the instance shared with
133
- * `createInboundMailConnectRoutes` so a fresh connect primes this cache. */
134
- readonly oauthTokens?: OAuthAccessTokenManager;
135
- /** TTL (seconds) for the per-account watch lease. Default 90s, renewed
136
- * every ttl/3. Only used when `lock` is set. */
137
- readonly watchLeaseTtlSeconds?: number;
138
- readonly log?: (line: string) => void;
139
- };
140
-
141
- type WatcherState = {
142
- stop: (() => Promise<void>) | null;
143
- backoffMs: number;
144
- restartTimer: ReturnType<typeof setTimeout> | null;
145
- /** Incremented by stop() to prevent a delayed restart from reviving
146
- * an already stopped watcher. */
147
- generation: number;
148
- /** Token from `deps.lock.acquire` while this worker owns the account's
149
- * watch lease; null when unclaimed (no `deps.lock`, or claim lost). */
150
- lockToken: string | null;
151
- renewTimer: ReturnType<typeof setTimeout> | null;
152
- /** Consecutive renew() throws — tear down once failures span a full TTL. */
153
- renewFailures: number;
154
- /** True while plugin.watch() is in flight (re-entrancy guard). */
155
- starting: boolean;
156
- };
157
-
158
- type WatchLeaseResult = "acquired" | "no-token" | "stale";
159
-
160
- export type InboundMailSupervisor = {
161
- readonly start: () => Promise<void>;
162
- /** Reconciliation run across all active accounts; also usable standalone
163
- * for tests and manual operational triggers. */
164
- readonly pollOnce: () => Promise<void>;
165
- readonly stop: () => Promise<void>;
166
- };
167
-
168
- type ActiveMailAccountRow = {
169
- readonly id: string;
170
- readonly tenantId: string;
171
- readonly provider: string;
172
- readonly authMethod: string;
173
- readonly ownerUserId?: string | null;
174
- readonly address: string;
175
- readonly displayName?: string;
176
- readonly watchState?: string;
177
- };
178
89
 
179
90
  export function createInboundMailSupervisor(
180
91
  deps: InboundMailSupervisorDeps,
@@ -182,6 +93,7 @@ export function createInboundMailSupervisor(
182
93
  const pollIntervalMs = deps.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
183
94
  const backfillWindowDays = deps.backfillWindowDays ?? DEFAULT_BACKFILL_WINDOW_DAYS;
184
95
  const maxMessagesPerPoll = deps.maxMessagesPerPoll ?? DEFAULT_MAX_MESSAGES_PER_POLL;
96
+ const { pollConcurrency, accountPollDeadlineMs } = pollScheduleOf(deps);
185
97
  const backoffInitialMs = deps.watchBackoffInitialMs ?? DEFAULT_WATCH_BACKOFF_INITIAL_MS;
186
98
  const backoffMaxMs = deps.watchBackoffMaxMs ?? DEFAULT_WATCH_BACKOFF_MAX_MS;
187
99
  const leaseTtlSeconds = deps.watchLeaseTtlSeconds ?? DEFAULT_WATCH_LEASE_TTL_SECONDS;
@@ -229,156 +141,18 @@ export function createInboundMailSupervisor(
229
141
  let running = false;
230
142
  let pollTimer: ReturnType<typeof setTimeout> | null = null;
231
143
  let pollInFlight: Promise<void> | null = null;
144
+ let unregisterWake: (() => void) | null = null;
232
145
  const watchers = new Map<string, WatcherState>();
233
146
 
234
- // ---------------------------------------------------------------
235
- // Account snapshot: active accounts of all tenants with the address
236
- // decrypted (providers never work with ciphertext).
237
- // ---------------------------------------------------------------
238
- async function listActiveAccounts(): Promise<readonly MailAccountRecord[]> {
239
- const rows = await selectMany<ActiveMailAccountRow>(systemDb, mailAccountsProjectionTable, {
240
- status: InboundMailAccountStatuses.active,
241
- });
242
- const piiKms = configuredPiiSubjectKms();
243
- const records: MailAccountRecord[] = [];
244
- for (const row of rows) {
245
- let decrypted: Record<string, unknown>;
246
- try {
247
- decrypted = piiKms
248
- ? await decryptPiiFieldValues(row, MAIL_ACCOUNT_PII_FIELDS, piiKms, {
249
- requestId: "inbound-mail-foundation:supervisor:list-accounts",
250
- })
251
- : row;
252
- } catch (error) {
253
- // Class name only: the message may carry ciphertext or key details.
254
- const errorName = error instanceof Error ? error.name : "unknown error";
255
- log(`inbound-mail: account ${row.id} skipped — address did not decrypt (${errorName})`);
256
- continue;
257
- }
258
- const decryptedAddress = decrypted["address"];
259
- // Falling back to row.address would hand the ciphertext to a provider.
260
- // One undecryptable account must not stop polling of every other tenant's accounts.
261
- if (typeof decryptedAddress !== "string") {
262
- log(`inbound-mail: account ${row.id} skipped — address did not decrypt to text`);
263
- continue;
264
- }
265
- records.push({
266
- id: row.id,
267
- tenantId: row.tenantId,
268
- provider: row.provider,
269
- authMethod: row.authMethod,
270
- ownerUserId: row.ownerUserId ?? null,
271
- address: decryptedAddress,
272
- displayName: row.displayName ?? "",
273
- status: InboundMailAccountStatuses.active,
274
- watchState: row.watchState ?? "idle",
275
- });
276
- }
277
- return records;
278
- }
279
-
280
- // ---------------------------------------------------------------
281
- // Cursor-Persistenz (unmanaged direct-write store).
282
- // ---------------------------------------------------------------
283
- async function loadCursor(accountId: string): Promise<SyncCursorPayload | null> {
284
- const row = await fetchOne<{ cursor: string }>(systemDb, syncCursorTable, {
285
- accountId,
286
- scope: CURSOR_SCOPE,
287
- });
288
- if (!row) return null;
289
- try {
290
- const parsed: unknown = JSON.parse(row.cursor);
291
- return isPlainObject(parsed) ? parsed : null;
292
- } catch {
293
- return null; // a corrupt cursor counts as none → backfill; dedup catches duplicates
294
- }
295
- }
296
-
297
- async function saveCursor(
298
- account: Pick<MailAccountRecord, "id" | "tenantId">,
299
- cursor: SyncCursorPayload,
300
- ): Promise<void> {
301
- const accountId = account.id;
302
- const now = Temporal.Now.instant().toString();
303
- const serialized = JSON.stringify(cursor);
304
- const existing = await fetchOne<{ id: string }>(systemDb, syncCursorTable, {
305
- accountId,
306
- scope: CURSOR_SCOPE,
307
- });
308
- if (existing) {
309
- await updateMany(
310
- systemDb,
311
- syncCursorTable,
312
- { cursor: serialized, updatedAt: now },
313
- { accountId, scope: CURSOR_SCOPE },
314
- );
315
- // skip: the update path is complete; do not fall through to the insert branch.
316
- return;
317
- }
318
- await insertOne(systemDb, syncCursorTable, {
319
- id: crypto.randomUUID(),
320
- // Direct-write store: no executor stamps the NOT NULL tenant_id for us.
321
- tenantId: account.tenantId,
322
- accountId,
323
- scope: CURSOR_SCOPE,
324
- cursor: serialized,
325
- updatedAt: now,
326
- });
327
- }
328
-
329
- async function resetCursor(accountId: string): Promise<void> {
330
- // No deleteMany import is needed: an empty cursor parses to null, so the
331
- // next fetch runs as a backfill.
332
- await updateMany(
333
- systemDb,
334
- syncCursorTable,
335
- { cursor: "", updatedAt: Temporal.Now.instant().toString() },
336
- { accountId, scope: CURSOR_SCOPE },
337
- );
338
- }
147
+ const cursors = createSyncCursorStore(systemDb);
339
148
 
340
- // ---------------------------------------------------------------
341
- // Ingest: every message goes through the standard write handler.
342
- // ---------------------------------------------------------------
343
- async function ingestBatch(
344
- account: MailAccountRecord,
345
- msgs: readonly RawInboundMessage[],
346
- cursorSnapshot: string,
347
- ): Promise<void> {
348
- for (const msg of msgs) {
349
- const bodyRef = deps.storeBody && msg.rawMime ? await deps.storeBody(account, msg) : "";
350
- const result = await deps.dispatchWrite({
351
- handlerQn: InboundMailFoundationHandlers.ingestMessage,
352
- tenantId: account.tenantId,
353
- payload: {
354
- accountId: account.id,
355
- ownerUserId: account.ownerUserId,
356
- providerName: account.provider,
357
- providerMessageId: msg.providerMessageId,
358
- messageIdHeader: msg.messageIdHeader,
359
- providerThreadId: msg.providerThreadId,
360
- references: msg.references,
361
- from: msg.from,
362
- to: msg.to,
363
- cc: msg.cc,
364
- subject: msg.subject,
365
- snippet: msg.snippet,
366
- receivedAtIso: msg.receivedAtIso,
367
- bodyRef,
368
- scope: msg.scope,
369
- providerCursor: cursorSnapshot,
370
- },
371
- });
372
- if (!result.isSuccess) {
373
- // A single-message failure aborts the batch without persisting the cursor.
374
- // The next tick refetches from the old cursor, and deduplication skips
375
- // messages that were already processed.
376
- throw new Error(
377
- `ingest-message failed for account ${account.id}: ${JSON.stringify(result.error ?? {})}`,
378
- );
379
- }
380
- }
381
- }
149
+ const ingester = createMessageIngester({
150
+ db: systemDb,
151
+ dispatchWrite: deps.dispatchWrite,
152
+ log,
153
+ ...(deps.storeBody && { storeBody: deps.storeBody }),
154
+ });
155
+ const health = createAccountHealth({ db: systemDb, dispatchWrite: deps.dispatchWrite, log });
382
156
 
383
157
  async function markAccount(
384
158
  account: MailAccountRecord,
@@ -413,7 +187,7 @@ export function createInboundMailSupervisor(
413
187
  }
414
188
  if (isInboundCursorInvalidError(err)) {
415
189
  log(`inbound-mail: account ${account.id} cursor invalid — full resync in backfill window`);
416
- await resetCursor(account.id);
190
+ await cursors.reset(account.id);
417
191
  return true;
418
192
  }
419
193
  // Transient or unknown error: log it and retry on the next tick.
@@ -421,185 +195,117 @@ export function createInboundMailSupervisor(
421
195
  return true;
422
196
  }
423
197
 
424
- // ---------------------------------------------------------------
425
- // Poll (Reconciliation).
426
- // ---------------------------------------------------------------
427
- function unknownMessageIdsFilterFor(account: MailAccountRecord) {
428
- return async (providerMessageIds: readonly string[]): Promise<ReadonlySet<string>> => {
429
- if (providerMessageIds.length === 0) return new Set();
430
- // One bind parameter per id: a full-mailbox listing would exceed Postgres' 65535 limit.
431
- const seenIds = new Set<string>();
432
- for (let start = 0; start < providerMessageIds.length; start += SEEN_LOOKUP_CHUNK_SIZE) {
433
- const seen = await selectMany<{ providerMessageId: string }>(
434
- systemDb,
435
- seenMessageTable,
436
- {
437
- tenantId: account.tenantId,
438
- accountId: account.id,
439
- providerMessageId: {
440
- in: providerMessageIds.slice(start, start + SEEN_LOOKUP_CHUNK_SIZE),
441
- },
442
- },
443
- { fields: ["providerMessageId"] },
444
- );
445
- for (const row of seen) seenIds.add(row.providerMessageId);
446
- }
447
- return new Set(providerMessageIds.filter((id) => !seenIds.has(id)));
448
- };
198
+ const { pollAccount } = createAccountPoller({
199
+ systemDb,
200
+ cursors,
201
+ ingester,
202
+ health,
203
+ providerCtx,
204
+ dispatchWrite: deps.dispatchWrite,
205
+ backfillWindowDays,
206
+ maxMessagesPerPoll,
207
+ resolveAccessToken,
208
+ handleSyncError,
209
+ log,
210
+ });
211
+
212
+ function resolvePluginOrLog(account: SyncableAccount): InboundMailProviderPlugin | undefined {
213
+ try {
214
+ return resolveInboundProviderForAccount(providerCtx, account);
215
+ } catch (err) {
216
+ log(`inbound-mail: ${describeError(err)}`);
217
+ return undefined;
218
+ }
449
219
  }
450
220
 
451
- async function pollAccount(
452
- account: MailAccountRecord,
221
+ const inFlightSyncs: InFlightSyncs = new Map();
222
+
223
+ const pollAccountWithDeadline = withPollDeadline(pollAccount, {
224
+ ms: accountPollDeadlineMs,
225
+ onDeadline: async (account) => {
226
+ log(
227
+ `inbound-mail: account ${account.id} sync passed its ${accountPollDeadlineMs}ms deadline`,
228
+ );
229
+ await health.onPollFailure(account, "transient");
230
+ },
231
+ });
232
+
233
+ async function pollAccountOfTick(
234
+ account: SyncableAccount,
453
235
  plugin: InboundMailProviderPlugin,
454
236
  ): Promise<void> {
455
- let cursor = await loadCursor(account.id);
456
- let budget = maxMessagesPerPoll;
457
- try {
458
- // hasMore loop: paginate within this tick until the message budget is
459
- // exhausted.
460
- for (;;) {
461
- const accessToken = await resolveAccessToken(account, plugin);
462
- const result = await plugin.fetch(providerCtx, account, cursor, {
463
- backfillWindowDays,
464
- maxMessages: budget,
465
- filterUnknownMessageIds: unknownMessageIdsFilterFor(account),
466
- ...(accessToken !== undefined && { accessToken }),
237
+ // Joining would hang this tick on the same stuck sync; wake and syncAccountNow still join.
238
+ if (inFlightSyncs.has(account.id)) {
239
+ log(`inbound-mail: account ${account.id} is still syncing, skipped this tick`);
240
+ } else {
241
+ await runExclusivePerAccount(inFlightSyncs, account.id, async () => {
242
+ await pollAccountWithDeadline(account, plugin, {
243
+ countBucket: currentCountBucket(deps.now, pollIntervalMs),
467
244
  });
468
- await ingestBatch(account, result.messages, JSON.stringify(result.nextCursor));
469
- await saveCursor(account, result.nextCursor);
470
- cursor = result.nextCursor;
471
- budget -= result.messages.length;
472
- if (!result.hasMore || budget <= 0) break;
473
- }
474
- } catch (err) {
475
- await handleSyncError(account, err);
245
+ });
476
246
  }
247
+ if (running) await ensureWatcher(account, plugin);
477
248
  }
478
249
 
479
250
  async function pollOnce(): Promise<void> {
480
- const accounts = await listActiveAccounts();
481
- for (const account of accounts) {
482
- let plugin: InboundMailProviderPlugin;
251
+ const accounts = await listSyncableAccounts(systemDb, log, { health });
252
+ // One account failing must not abort the others, and mapWithConcurrency would reject on the first error.
253
+ await mapWithConcurrency(accounts, Math.max(1, pollConcurrency), async (account) => {
483
254
  try {
484
- plugin = resolveInboundProviderForAccount(providerCtx, account);
255
+ const plugin = resolvePluginOrLog(account);
256
+ if (plugin) await pollAccountOfTick(account, plugin);
485
257
  } catch (err) {
486
- log(`inbound-mail: ${describeError(err)}`);
487
- continue;
258
+ log(`inbound-mail: account ${account.id} tick failed: ${describeError(err)}`);
488
259
  }
489
- await pollAccount(account, plugin);
490
- if (running) await ensureWatcher(account, plugin);
491
- }
492
- // Remove watchers for accounts that are no longer active.
493
- const activeIds = new Set(accounts.map((a) => a.id));
260
+ });
261
+ // Remove watchers for accounts that are no longer syncable.
262
+ const syncableIds = new Set(accounts.map((a) => a.id));
494
263
  for (const accountId of watchers.keys()) {
495
- if (!activeIds.has(accountId)) await stopWatcher(accountId);
264
+ if (!syncableIds.has(accountId)) await stopWatcher(accountId);
496
265
  }
497
266
  }
498
267
 
268
+ const watchCatchUp = createWatchCatchUp({
269
+ inFlight: inFlightSyncs,
270
+ pollAccount: pollAccountWithDeadline,
271
+ isRunning: () => running,
272
+ log,
273
+ });
274
+
275
+ const syncAccountNow = createSyncAccountNow({
276
+ inFlight: inFlightSyncs,
277
+ loadAccount: async (onlyAccountId) =>
278
+ (await listSyncableAccounts(systemDb, log, { onlyAccountId }))[0],
279
+ resolvePlugin: resolvePluginOrLog,
280
+ pollAccount: pollAccountWithDeadline,
281
+ ensureWatcher,
282
+ isRunning: () => running,
283
+ });
284
+
499
285
  // ---------------------------------------------------------------
500
286
  // Watch lifecycle with backoff restart.
501
287
  // ---------------------------------------------------------------
502
- // Heartbeat for a held watch lease. Independent of the connection's own
503
- // lifecycle — it keeps renewing across reconnect backoff, not just while
504
- // `plugin.watch()` is actually connected, so a flaky IMAP link doesn't
505
- // make this worker lose the account to a peer mid-backoff.
506
- function scheduleRenew(
507
- account: MailAccountRecord,
508
- state: WatcherState,
509
- generation: number,
510
- ): void {
511
- state.renewTimer = setTimeout(() => {
512
- void renewLeaseOnce(account, state, generation);
513
- }, renewIntervalMs);
514
- }
515
-
516
- async function tearDownAfterRenewFailures(
517
- account: MailAccountRecord,
518
- state: WatcherState,
519
- reason: string,
520
- ): Promise<void> {
521
- log(
522
- `inbound-mail: watch lease ${reason} for account ${account.id} — tearing down local watcher`,
523
- );
524
- state.lockToken = null;
525
- await stopWatcher(account.id);
526
- }
527
-
528
- async function renewLeaseOnce(
529
- account: MailAccountRecord,
530
- state: WatcherState,
531
- generation: number,
532
- ): Promise<void> {
533
- state.renewTimer = null;
534
- // skip: supervisor stopped, generation superseded, or lease already lost — stale timer fire, nothing to renew.
535
- if (!running || state.generation !== generation || !state.lockToken || !deps.lock) return;
536
- let renewed: boolean;
537
- try {
538
- renewed = await deps.lock.renew(account.id, state.lockToken, leaseTtlSeconds);
539
- } catch (err) {
540
- log(
541
- `inbound-mail: watch lease renew for account ${account.id} failed: ${describeError(err)}`,
542
- );
543
- state.renewFailures += 1;
544
- // After failures spanning a full lease TTL, tear down — otherwise a
545
- // peer can acquire the expired key while we still hold an IDLE conn.
546
- if (state.renewFailures * renewIntervalMs >= leaseTtlSeconds * 1000) {
547
- await tearDownAfterRenewFailures(account, state, "renew failed across TTL");
548
- // skip: connection torn down, no reschedule
549
- return;
550
- }
551
- rescheduleIfStillHeld(account, state, generation);
552
- // skip: renew failed but TTL not yet exhausted, retry already scheduled
553
- return;
554
- }
555
- state.renewFailures = 0;
556
- if (!renewed) {
557
- await tearDownAfterRenewFailures(account, state, "lost");
558
- // skip: lease lost and torn down, nothing to reschedule
559
- return;
560
- }
561
- rescheduleIfStillHeld(account, state, generation);
562
- }
563
-
564
- function rescheduleIfStillHeld(
565
- account: MailAccountRecord,
566
- state: WatcherState,
567
- generation: number,
568
- ): void {
569
- if (running && state.generation === generation && state.lockToken) {
570
- scheduleRenew(account, state, generation);
571
- }
572
- }
573
-
574
- // kumiko-lint-ignore complexity-budget yield-point stale-map check after acquire is intentional
575
- async function acquireWatchLease(
576
- account: MailAccountRecord,
577
- state: WatcherState,
578
- ): Promise<WatchLeaseResult> {
579
- if (!deps.lock || state.lockToken) return "acquired";
580
- const token = await deps.lock.acquire(account.id, { ttlSeconds: leaseTtlSeconds });
581
- if (!token) return "no-token";
582
- // The acquire() await is a yield point: a concurrent stopWatcher()
583
- // (e.g. from a lost-lease renewal on a different generation) may have
584
- // already retired this exact state and removed it from the map. If so,
585
- // committing the fresh token onto it would leak the lease — nothing
586
- // would ever release it, blocking failover for the full TTL.
587
- if (watchers.get(account.id) !== state) {
588
- await deps.lock.release(account.id, token);
589
- return "stale";
590
- }
591
- state.lockToken = token;
592
- scheduleRenew(account, state, state.generation);
593
- return "acquired";
594
- }
288
+ const watchLease = createWatchLease({
289
+ lock: deps.lock,
290
+ ttlSeconds: leaseTtlSeconds,
291
+ renewIntervalMs,
292
+ watchers,
293
+ isRunning: () => running,
294
+ stopWatcher,
295
+ log,
296
+ });
595
297
 
596
298
  // kumiko-lint-ignore complexity-budget watch lifecycle (start/stop/restart) stays cohesive
597
299
  async function ensureWatcher(
598
- account: MailAccountRecord,
300
+ account: SyncableAccount,
599
301
  plugin: InboundMailProviderPlugin,
600
302
  ): Promise<void> {
601
303
  // skip: provider without live push; the reconciliation poll covers the account.
602
304
  if (!plugin.watch) return;
305
+ const stale = watchers.get(account.id);
306
+ if (stale?.stop && stale.trustedAuthservId !== (account.trustedAuthservId ?? null)) {
307
+ await stopWatcher(account.id);
308
+ }
603
309
  const existing = watchers.get(account.id);
604
310
  // skip: the watcher is already running, starting, or scheduled for restart.
605
311
  if (existing?.stop || existing?.restartTimer || existing?.starting) return;
@@ -613,29 +319,23 @@ export function createInboundMailSupervisor(
613
319
  renewTimer: null,
614
320
  renewFailures: 0,
615
321
  starting: false,
322
+ trustedAuthservId: null,
616
323
  };
617
324
  watchers.set(account.id, state);
325
+ state.starting = true;
618
326
 
619
- const leaseResult = await acquireWatchLease(account, state);
620
- if (leaseResult === "no-token") {
621
- // Another worker already holds this account's watch lease — the poll
622
- // still covers it, this worker just doesn't open a second IDLE
623
- // connection. Retried on the next tick.
624
- if (!existing) watchers.delete(account.id);
625
- // skip: no lease token acquired above — nothing more to set up here.
626
- return;
627
- }
628
- if (leaseResult === "stale") {
629
- // skip: state was retired by a concurrent stopWatcher while acquire()
630
- // awaited — committing here would leak the lease.
327
+ const leaseResult = await watchLease.acquire(account, state);
328
+ if (leaseResult !== "acquired") {
329
+ if (leaseResult === "no-token" && !existing) watchers.delete(account.id);
330
+ state.starting = false;
331
+ // skip: no-token means the poll still covers the account and the next tick retries; stale means stopWatcher retired this state.
631
332
  return;
632
333
  }
633
334
 
634
335
  const generation = state.generation;
635
336
 
636
337
  const scheduleRestart = (err: unknown) => {
637
- // skip: the supervisor stopped or the watcher generation changed; a restart
638
- // would create a zombie watcher.
338
+ // skip: stopped or superseded generation; a restart would create a zombie watcher.
639
339
  if (!running || state.generation !== generation) return;
640
340
  state.stop = null;
641
341
  const delay = state.backoffMs;
@@ -650,16 +350,16 @@ export function createInboundMailSupervisor(
650
350
  }, delay);
651
351
  };
652
352
 
653
- state.starting = true;
353
+ state.trustedAuthservId = account.trustedAuthservId ?? null;
654
354
  try {
655
355
  const accessToken = await resolveAccessToken(account, plugin);
656
356
  const stop = await plugin.watch(
657
357
  providerCtx,
658
358
  account,
659
359
  {
660
- onMessages: async (msgs) => {
360
+ onMessages: async (msgs, rejected = []) => {
661
361
  try {
662
- await ingestBatch(account, msgs, "watch");
362
+ await ingester.ingestBatch(account, msgs, rejected, "watch");
663
363
  } catch (err) {
664
364
  // An ingest failure must not kill the watcher: the next poll
665
365
  // tick fetches the messages again and dedup keeps it idempotent.
@@ -698,6 +398,7 @@ export function createInboundMailSupervisor(
698
398
  `inbound-mail: markAccount(watching) for account ${account.id} failed: ${describeError(err)}`,
699
399
  );
700
400
  }
401
+ watchCatchUp.schedule(account, plugin);
701
402
  } catch (err) {
702
403
  const keepRunning = await handleSyncError(account, err);
703
404
  if (keepRunning) scheduleRestart(err);
@@ -767,17 +468,26 @@ export function createInboundMailSupervisor(
767
468
  // skip: already started; start() is idempotent.
768
469
  if (running) return;
769
470
  running = true;
471
+ unregisterWake = registerMailAccountWakeListener((_tenantId, accountId) => {
472
+ syncAccountNow(accountId).catch((err) =>
473
+ log(`inbound-mail: wake sync for account ${accountId} failed: ${describeError(err)}`),
474
+ );
475
+ });
770
476
  await pollOnce();
771
477
  scheduleNextPoll();
772
478
  },
773
479
  pollOnce,
480
+ syncAccountNow,
774
481
  async stop() {
775
482
  running = false;
483
+ unregisterWake?.();
484
+ unregisterWake = null;
776
485
  if (pollTimer) {
777
486
  clearTimeout(pollTimer);
778
487
  pollTimer = null;
779
488
  }
780
489
  if (pollInFlight) await pollInFlight;
490
+ await Promise.allSettled([...inFlightSyncs.values(), watchCatchUp.settled()]);
781
491
  for (const accountId of [...watchers.keys()]) {
782
492
  await stopWatcher(accountId);
783
493
  }