@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.
- package/package.json +1 -1
- package/packages/bundled-features/package.json +15 -5
- package/packages/bundled-features/src/account-security/README.md +1 -1
- package/packages/bundled-features/src/account-security/changes.json +6 -0
- package/packages/bundled-features/src/account-security/feature.ts +9 -1
- package/packages/bundled-features/src/audit/event-classification.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/changes.json +6 -0
- package/packages/bundled-features/src/auth-email-password/web/auth-client.ts +10 -52
- package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +4 -4
- package/packages/bundled-features/src/auth-email-password/web/auth-redirect.ts +3 -12
- package/packages/bundled-features/src/auth-email-password/web/index.ts +2 -0
- package/packages/bundled-features/src/auth-email-password/web/login-failure.ts +54 -0
- package/packages/bundled-features/src/auth-email-password/web/login-url.ts +19 -0
- package/packages/bundled-features/src/auth-email-password/web/static-login.ts +183 -0
- package/packages/bundled-features/src/auth-mfa/web/mfa-client.ts +7 -63
- package/packages/bundled-features/src/billing-foundation/changes.json +30 -0
- package/packages/bundled-features/src/billing-foundation/checkout-core.ts +8 -16
- package/packages/bundled-features/src/billing-foundation/events.ts +2 -0
- package/packages/bundled-features/src/billing-foundation/feature.ts +1 -1
- package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +9 -2
- package/packages/bundled-features/src/billing-foundation/handlers/process-payment-event.write.ts +2 -0
- package/packages/bundled-features/src/billing-foundation/index.ts +2 -0
- package/packages/bundled-features/src/billing-foundation/one-off-checkout-gate.ts +44 -0
- package/packages/bundled-features/src/billing-foundation/one-off-customer.ts +17 -0
- package/packages/bundled-features/src/billing-foundation/plan-catalog.ts +1 -0
- package/packages/bundled-features/src/billing-foundation/types.ts +26 -0
- package/packages/bundled-features/src/billing-foundation/web/billing-plans-panel.tsx +1 -0
- package/packages/bundled-features/src/billing-foundation/web/plan-switch-confirm-dialog.tsx +8 -2
- package/packages/bundled-features/src/billing-foundation/web/plan-switch-recurring.ts +23 -0
- package/packages/bundled-features/src/billing-foundation/webhook-handler.ts +1 -0
- package/packages/bundled-features/src/cap-overview/changes.json +6 -0
- package/packages/bundled-features/src/cap-overview/handlers/caps-usage.query.ts +3 -3
- package/packages/bundled-features/src/cap-overview/types.ts +2 -0
- package/packages/bundled-features/src/compliance-profiles/web/compliance-profile-catalog.tsx +10 -1
- package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +12 -3
- package/packages/bundled-features/src/files/changes.json +18 -0
- package/packages/bundled-features/src/inbound-mail-foundation/account-health.ts +146 -0
- package/packages/bundled-features/src/inbound-mail-foundation/account-poll-schedule.ts +74 -0
- package/packages/bundled-features/src/inbound-mail-foundation/account-poll.ts +238 -0
- package/packages/bundled-features/src/inbound-mail-foundation/account-record.ts +2 -0
- package/packages/bundled-features/src/inbound-mail-foundation/account-wake.ts +21 -0
- package/packages/bundled-features/src/inbound-mail-foundation/changes.json +155 -0
- package/packages/bundled-features/src/inbound-mail-foundation/connect-callback-verify.ts +65 -0
- package/packages/bundled-features/src/inbound-mail-foundation/connect-route-responses.ts +26 -0
- package/packages/bundled-features/src/inbound-mail-foundation/connect-routes.ts +93 -62
- package/packages/bundled-features/src/inbound-mail-foundation/constants.ts +66 -3
- package/packages/bundled-features/src/inbound-mail-foundation/content-refetch.ts +137 -0
- package/packages/bundled-features/src/inbound-mail-foundation/describe-error.ts +5 -0
- package/packages/bundled-features/src/inbound-mail-foundation/entities.ts +45 -2
- package/packages/bundled-features/src/inbound-mail-foundation/events.ts +69 -2
- package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +133 -118
- package/packages/bundled-features/src/inbound-mail-foundation/file-storage-failure.ts +23 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/account-address.ts +79 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/acknowledge-quarantine.write.ts +55 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/attach-message-content.write.ts +184 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/connect-account.write.ts +137 -84
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/disconnect-account.write.ts +65 -45
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message-prepare.ts +177 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message-schema.ts +42 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message.write.ts +79 -160
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-accounts.query.ts +93 -30
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-messages.query.ts +46 -30
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-quarantine.query.ts +106 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/mark-credential-stored.write.ts +47 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/message-content.query.ts +99 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/message-content.ts +229 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/message-detail.query.ts +34 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/reactivate-account.write.ts +129 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/retry-quarantined-message.write.ts +49 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/scope-visibility.ts +48 -9
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/set-trusted-authserv-id.write.ts +66 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/thread-rollup.ts +136 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/trusted-authserv-id.ts +4 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/update-account.write.ts +82 -60
- package/packages/bundled-features/src/inbound-mail-foundation/i18n.ts +80 -1
- package/packages/bundled-features/src/inbound-mail-foundation/inbound-oauth-flow.ts +48 -0
- package/packages/bundled-features/src/inbound-mail-foundation/index.ts +94 -15
- package/packages/bundled-features/src/inbound-mail-foundation/ingest-failures.ts +137 -0
- package/packages/bundled-features/src/inbound-mail-foundation/ingest-payload.ts +46 -0
- package/packages/bundled-features/src/inbound-mail-foundation/mail-roles.ts +132 -0
- package/packages/bundled-features/src/inbound-mail-foundation/normalize-inbound-message.ts +119 -0
- package/packages/bundled-features/src/inbound-mail-foundation/oauth-access-token.ts +26 -8
- package/packages/bundled-features/src/inbound-mail-foundation/outbound-attachment-access.ts +28 -0
- package/packages/bundled-features/src/inbound-mail-foundation/outbound-attachments.ts +111 -0
- package/packages/bundled-features/src/inbound-mail-foundation/outbound-mail.ts +19 -1
- package/packages/bundled-features/src/inbound-mail-foundation/probe.ts +2 -1
- package/packages/bundled-features/src/inbound-mail-foundation/projection.ts +33 -2
- package/packages/bundled-features/src/inbound-mail-foundation/provider-errors.ts +74 -0
- package/packages/bundled-features/src/inbound-mail-foundation/quarantine-entities.ts +56 -0
- package/packages/bundled-features/src/inbound-mail-foundation/quarantine-header-import.ts +63 -0
- package/packages/bundled-features/src/inbound-mail-foundation/quarantine-retry.ts +137 -0
- package/packages/bundled-features/src/inbound-mail-foundation/quarantine-screens.ts +66 -0
- package/packages/bundled-features/src/inbound-mail-foundation/quarantine.ts +72 -0
- package/packages/bundled-features/src/inbound-mail-foundation/retention-job.ts +78 -0
- package/packages/bundled-features/src/inbound-mail-foundation/retention-sweep.ts +43 -8
- package/packages/bundled-features/src/inbound-mail-foundation/screens.ts +239 -79
- package/packages/bundled-features/src/inbound-mail-foundation/send-from-account.ts +24 -5
- package/packages/bundled-features/src/inbound-mail-foundation/sender-auth.ts +215 -0
- package/packages/bundled-features/src/inbound-mail-foundation/supervisor-ingest.ts +311 -0
- package/packages/bundled-features/src/inbound-mail-foundation/sync-account-now.ts +61 -0
- package/packages/bundled-features/src/inbound-mail-foundation/sync-cursor-store.ts +79 -0
- package/packages/bundled-features/src/inbound-mail-foundation/syncable-accounts.ts +123 -0
- package/packages/bundled-features/src/inbound-mail-foundation/tenant-destroy-hook.ts +5 -1
- package/packages/bundled-features/src/inbound-mail-foundation/types.ts +76 -105
- package/packages/bundled-features/src/inbound-mail-foundation/wake-consumer.ts +33 -0
- package/packages/bundled-features/src/inbound-mail-foundation/watch-catch-up.ts +68 -0
- package/packages/bundled-features/src/inbound-mail-foundation/watch-lease.ts +124 -0
- package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor-types.ts +87 -0
- package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +143 -433
- package/packages/bundled-features/src/inbound-mail-foundation/web/client-plugin.ts +73 -0
- package/packages/bundled-features/src/inbound-mail-foundation/web/constants.ts +7 -0
- package/packages/bundled-features/src/inbound-mail-foundation/web/index.ts +5 -0
- package/packages/bundled-features/src/inbound-provider-imap/changes.json +42 -0
- package/packages/bundled-features/src/inbound-provider-imap/connect-mailbox.ts +155 -63
- package/packages/bundled-features/src/inbound-provider-imap/connect-my-mailbox.ts +45 -0
- package/packages/bundled-features/src/inbound-provider-imap/constants.ts +14 -1
- package/packages/bundled-features/src/inbound-provider-imap/credential-document.ts +2 -1
- package/packages/bundled-features/src/inbound-provider-imap/download-batch.ts +38 -0
- package/packages/bundled-features/src/inbound-provider-imap/feature.ts +75 -44
- package/packages/bundled-features/src/inbound-provider-imap/fetch-content.ts +84 -0
- package/packages/bundled-features/src/inbound-provider-imap/i18n.ts +28 -2
- package/packages/bundled-features/src/inbound-provider-imap/imap-client.ts +35 -84
- package/packages/bundled-features/src/inbound-provider-imap/index.ts +1 -2
- package/packages/bundled-features/src/inbound-provider-imap/mime-content.ts +85 -0
- package/packages/bundled-features/src/inbound-provider-imap/parse-message.ts +149 -0
- package/packages/bundled-features/src/inbound-provider-imap/reconnect-account.ts +45 -0
- package/packages/bundled-features/src/inbound-provider-imap/reconnect-mailbox.ts +103 -0
- package/packages/bundled-features/src/inbound-provider-imap/reconnect-prefill.query.ts +49 -0
- package/packages/bundled-features/src/inbound-provider-imap/screens.ts +209 -42
- package/packages/bundled-features/src/inbound-provider-imap/send-mail.ts +11 -2
- package/packages/bundled-features/src/inbound-provider-imap/sender-auth-headers.ts +36 -0
- package/packages/bundled-features/src/inbound-provider-inmemory/feature.ts +75 -3
- package/packages/bundled-features/src/inbound-provider-inmemory/index.ts +6 -0
- package/packages/bundled-features/src/jobs/changes.json +18 -0
- package/packages/bundled-features/src/managed-pages/web/i18n.ts +1 -0
- package/packages/bundled-features/src/subscription-paddle/changes.json +15 -0
- package/packages/bundled-features/src/subscription-paddle/checkout-config.query.ts +17 -0
- package/packages/bundled-features/src/subscription-paddle/checkout-methods.ts +107 -0
- package/packages/bundled-features/src/subscription-paddle/checkout-page.ts +154 -0
- package/packages/bundled-features/src/subscription-paddle/client.ts +78 -0
- package/packages/bundled-features/src/subscription-paddle/constants.ts +32 -0
- package/packages/bundled-features/src/subscription-paddle/feature.ts +265 -0
- package/packages/bundled-features/src/subscription-paddle/index.ts +17 -0
- package/packages/bundled-features/src/subscription-paddle/plan-switch-methods.ts +135 -0
- package/packages/bundled-features/src/subscription-paddle/price-methods.ts +148 -0
- package/packages/bundled-features/src/subscription-paddle/runtime.ts +203 -0
- package/packages/bundled-features/src/subscription-paddle/subscription-state.ts +73 -0
- package/packages/bundled-features/src/subscription-paddle/tenant-binding.ts +34 -0
- package/packages/bundled-features/src/subscription-paddle/verify-webhook.ts +61 -0
- package/packages/bundled-features/src/subscription-paddle/webhook-events.ts +172 -0
- package/packages/bundled-features/src/template-resolver/changes.json +12 -0
- package/packages/bundled-features/src/template-resolver/handlers/collection-item.query.ts +2 -2
- package/packages/bundled-features/src/template-resolver/handlers/collection-list.query.ts +6 -2
- package/packages/bundled-features/src/template-resolver/handlers/collection-shared.ts +25 -0
- package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +218 -117
- package/packages/bundled-features/src/template-resolver/web/i18n.ts +1 -2
- package/packages/bundled-features/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +2 -1
- package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-constants.ts +4 -0
- package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +11 -5
- package/samples/apps/use-all-bundled/e2e-billing-gallery/server.ts +2 -2
- package/samples/apps/use-all-bundled/feature-manifest.json +89 -39
- package/samples/apps/use-all-bundled/kumiko/migrations/0056_inbound-mail-quarantine-retry.rebuild.json +4 -0
- package/samples/apps/use-all-bundled/package.json +1 -0
- package/samples/apps/use-all-bundled/playwright.billing-docs.config.ts +15 -0
- package/samples/apps/use-all-bundled/src/app/cap-overview-caps.ts +16 -2
- package/samples/apps/use-all-bundled/src/app/cap-overview-labels-feature.ts +2 -1
- package/samples/apps/use-all-bundled/src/app/client.tsx +2 -0
- package/samples/apps/use-all-bundled/src/app/screens-feature.ts +14 -2
- package/samples/apps/use-all-bundled/src/app/seed.ts +4 -1
- package/samples/apps/use-all-bundled/src/run-config.ts +7 -5
- package/samples/recipes/cross-feature-events/README.md +10 -0
- package/samples/recipes/cross-feature-events/src/feature.ts +14 -0
- package/samples/recipes/dashboard-stats/README.md +8 -0
- package/samples/recipes/dashboard-stats/src/feature.ts +91 -1
- package/samples/recipes/env-composition/README.md +2 -2
- package/samples/recipes/env-composition/src/env.ts +2 -2
- package/samples/recipes/inbound-mail-triage/README.md +12 -0
- package/samples/recipes/pdf-audit-export/src/feature.ts +71 -48
- package/packages/bundled-features/src/subscription-dodo/changes.json +0 -14
- package/packages/bundled-features/src/subscription-dodo/client.ts +0 -45
- package/packages/bundled-features/src/subscription-dodo/constants.ts +0 -24
- package/packages/bundled-features/src/subscription-dodo/feature.ts +0 -207
- package/packages/bundled-features/src/subscription-dodo/index.ts +0 -16
- package/packages/bundled-features/src/subscription-dodo/plugin-methods.ts +0 -345
- package/packages/bundled-features/src/subscription-dodo/runtime.ts +0 -126
- 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
|
|
58
|
-
import {
|
|
59
|
-
import {
|
|
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 {
|
|
62
|
-
import {
|
|
63
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
|
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
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
-
|
|
452
|
-
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
-
|
|
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
|
|
481
|
-
|
|
482
|
-
|
|
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 =
|
|
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
|
-
|
|
490
|
-
|
|
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 (!
|
|
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
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
)
|
|
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:
|
|
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
|
|
620
|
-
if (leaseResult
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
//
|
|
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:
|
|
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.
|
|
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
|
}
|