@cosmicdrift/kumiko-samples 0.393.0 → 0.395.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 +143 -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 -2
- package/packages/bundled-features/src/inbound-mail-foundation/inbound-oauth-flow.ts +48 -0
- package/packages/bundled-features/src/inbound-mail-foundation/index.ts +93 -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 +238 -89
- 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 +36 -0
- package/packages/bundled-features/src/inbound-provider-imap/connect-mailbox.ts +124 -41
- package/packages/bundled-features/src/inbound-provider-imap/connect-my-mailbox.ts +45 -0
- package/packages/bundled-features/src/inbound-provider-imap/constants.ts +2 -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 +61 -136
- package/packages/bundled-features/src/inbound-provider-imap/fetch-content.ts +84 -0
- package/packages/bundled-features/src/inbound-provider-imap/i18n.ts +9 -0
- 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 +4 -6
- package/packages/bundled-features/src/inbound-provider-imap/reconnect-mailbox.ts +75 -45
- package/packages/bundled-features/src/inbound-provider-imap/reconnect-prefill.query.ts +19 -8
- package/packages/bundled-features/src/inbound-provider-imap/screens.ts +203 -109
- 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-imap/watch-mailbox.ts +109 -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/changes.json +7 -0
- package/packages/bundled-features/src/managed-pages/constants.ts +7 -0
- package/packages/bundled-features/src/managed-pages/feature.ts +6 -4
- package/packages/bundled-features/src/managed-pages/handlers/by-slug.query.ts +2 -0
- package/packages/bundled-features/src/managed-pages/handlers/page-body-guard.ts +61 -0
- package/packages/bundled-features/src/managed-pages/handlers/page-body-issues.ts +26 -0
- package/packages/bundled-features/src/managed-pages/handlers/page-crud.ts +17 -10
- package/packages/bundled-features/src/managed-pages/handlers/set.write.ts +15 -4
- package/packages/bundled-features/src/managed-pages/render-page-body.ts +28 -0
- package/packages/bundled-features/src/managed-pages/screens/page-screens.ts +6 -2
- package/packages/bundled-features/src/managed-pages/seeding.ts +13 -2
- package/packages/bundled-features/src/managed-pages/table.ts +6 -2
- package/packages/bundled-features/src/managed-pages/web/client-plugin.tsx +6 -4
- package/packages/bundled-features/src/managed-pages/web/constants.ts +14 -0
- package/packages/bundled-features/src/managed-pages/web/i18n.ts +14 -0
- package/packages/bundled-features/src/managed-pages/web/lossy-markdown.ts +71 -0
- package/packages/bundled-features/src/managed-pages/web/markdown-to-document.ts +27 -0
- package/packages/bundled-features/src/managed-pages/web/page-body-section.tsx +73 -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/README.md +2 -2
- package/packages/bundled-features/src/template-resolver/changes.json +20 -0
- package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +16 -16
- package/packages/bundled-features/src/template-resolver/feature.ts +14 -1
- 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-set.write.ts +24 -6
- package/packages/bundled-features/src/template-resolver/handlers/collection-shared.ts +32 -0
- package/packages/bundled-features/src/template-resolver/handlers/set.write.ts +1 -0
- package/packages/bundled-features/src/template-resolver/handlers/shared.ts +9 -3
- package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +13 -480
- package/packages/bundled-features/src/template-resolver/web/collection-entry-create-form.tsx +105 -0
- package/packages/bundled-features/src/template-resolver/web/collection-lookup.ts +33 -0
- package/packages/bundled-features/src/template-resolver/web/constants.ts +3 -0
- package/packages/bundled-features/src/template-resolver/web/content-field.tsx +61 -0
- package/packages/bundled-features/src/template-resolver/web/content-tree-provider.ts +256 -0
- package/packages/bundled-features/src/template-resolver/web/document-reference-name.ts +12 -0
- package/packages/bundled-features/src/template-resolver/web/document-schema.ts +3 -11
- package/packages/bundled-features/src/template-resolver/web/i18n.ts +9 -2
- package/packages/bundled-features/src/template-resolver/web/legacy-variable-text.ts +30 -0
- package/packages/bundled-features/src/template-resolver/web/tenant-override.ts +7 -0
- package/packages/bundled-features/src/template-resolver/web/text-block-editor.tsx +92 -0
- package/packages/bundled-features/src/template-resolver/web/use-collection-editor-context.ts +42 -0
- package/packages/bundled-features/src/template-resolver/web/use-collection-entry-create.ts +92 -0
- package/packages/bundled-features/src/template-resolver/web/use-content-write.ts +81 -0
- package/packages/bundled-features/src/template-resolver/web/use-text-block-editor.ts +127 -0
- package/packages/bundled-features/src/tier-engine/changes.json +6 -0
- package/packages/bundled-features/src/tier-engine/feature.ts +9 -1
- 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 -40
- 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 +8 -1
- package/samples/apps/use-all-bundled/src/app/screens-feature.ts +14 -2
- package/samples/apps/use-all-bundled/src/app/seed.ts +17 -2
- package/samples/apps/use-all-bundled/src/run-config.ts +19 -11
- 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 +8 -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
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
// Per-message ingest for the sync supervisor. Every message is isolated: a poison mail must not
|
|
2
|
+
// block the cursor of its mailbox, but a systemic outage (database, projection, KMS, file storage,
|
|
3
|
+
// the app's storeBody hook) must not quarantine good mail either.
|
|
4
|
+
//
|
|
5
|
+
// - permanent rejection (validation/unprocessable) -> quarantine immediately, continue
|
|
6
|
+
// - provider-side parse failures (`rejected`) -> quarantine "unparseable"
|
|
7
|
+
// - any other failure -> the batch goes on, then the failure is settled:
|
|
8
|
+
// - proven (another mail of the account went through in the same poll, for a storage failure
|
|
9
|
+
// another storage write) -> counted once per poll interval (persisted); the third counted
|
|
10
|
+
// failure quarantines the mail as "ingest_failed", below that the cursor stays before it
|
|
11
|
+
// - when every failure of this poll was counted before, they all keep being counted: a
|
|
12
|
+
// counter row exists only because its mail failed while others went through. A KMS failure
|
|
13
|
+
// among them voids this, it is never the mail's
|
|
14
|
+
// - unproven -> nothing is counted, the account is disrupted (degraded with the class of the
|
|
15
|
+
// failure) and the next interval retries from the same cursor
|
|
16
|
+
//
|
|
17
|
+
// Every quarantine also imports the headers, so the mail stays visible in the inbox.
|
|
18
|
+
|
|
19
|
+
import { ENCRYPTION_UNAVAILABLE_CODE } from "@cosmicdrift/kumiko-framework/crypto";
|
|
20
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
21
|
+
import { errorCodeOf, propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
22
|
+
import { InboundMailFoundationHandlers } from "./constants.ts";
|
|
23
|
+
import { FileStorageWriteFailedError, isFileStorageWriteFailure } from "./file-storage-failure.ts";
|
|
24
|
+
import {
|
|
25
|
+
clearIngestFailures,
|
|
26
|
+
countIngestFailure,
|
|
27
|
+
hasCountedIngestFailure,
|
|
28
|
+
MAX_COUNTED_INGEST_FAILURES,
|
|
29
|
+
} from "./ingest-failures.ts";
|
|
30
|
+
import { ingestPayloadOf } from "./ingest-payload.ts";
|
|
31
|
+
import { normalizeRawInboundMessage } from "./normalize-inbound-message.ts";
|
|
32
|
+
import { quarantineMessage } from "./quarantine.ts";
|
|
33
|
+
import type { QuarantineReasonCode } from "./quarantine-entities.ts";
|
|
34
|
+
import {
|
|
35
|
+
headerOnlyOf,
|
|
36
|
+
headerOnlyOfRejected,
|
|
37
|
+
importQuarantinedHeaders,
|
|
38
|
+
} from "./quarantine-header-import.ts";
|
|
39
|
+
import type { MailAccountRecord, RawInboundMessage, RejectedInboundMessage } from "./types.ts";
|
|
40
|
+
|
|
41
|
+
const PERMANENT_ERROR_CODES: ReadonlySet<string> = new Set(["validation_error", "unprocessable"]);
|
|
42
|
+
|
|
43
|
+
export type DispatchWrite = (args: {
|
|
44
|
+
readonly handlerQn: string;
|
|
45
|
+
readonly payload: unknown;
|
|
46
|
+
readonly tenantId: string;
|
|
47
|
+
}) => Promise<{ readonly isSuccess: boolean; readonly data?: unknown; readonly error?: unknown }>;
|
|
48
|
+
|
|
49
|
+
export type IngestBlockedReason =
|
|
50
|
+
| "file_storage_unavailable"
|
|
51
|
+
| "encryption_unavailable"
|
|
52
|
+
| "ingest_unavailable"
|
|
53
|
+
| "message_failing";
|
|
54
|
+
|
|
55
|
+
/** What earlier batches of the same poll proved to work. */
|
|
56
|
+
export type IngestProof = {
|
|
57
|
+
/** A mail was newly ingested (a duplicate proves nothing: it returns before any write). */
|
|
58
|
+
readonly ingested: boolean;
|
|
59
|
+
/** A content write to file storage or through the storeBody hook went through. */
|
|
60
|
+
readonly storageHealthy: boolean;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export type IngestBatchOutcome = IngestProof & {
|
|
64
|
+
readonly quarantinedNew: number;
|
|
65
|
+
/** True when a message needed file storage and none was available. */
|
|
66
|
+
readonly storageUnavailable: boolean;
|
|
67
|
+
/** Set when the batch has an unresolved failure: the cursor must not move past it. */
|
|
68
|
+
readonly blocked?: IngestBlockedReason;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export type IngestBatchOptions = {
|
|
72
|
+
/** Poll-interval bucket of a regular tick; absent (wake, manual sync) means failures are not counted. */
|
|
73
|
+
readonly countBucket?: number;
|
|
74
|
+
readonly signal?: AbortSignal;
|
|
75
|
+
readonly proof?: IngestProof;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export type MessageIngester = {
|
|
79
|
+
readonly ingestBatch: (
|
|
80
|
+
account: MailAccountRecord,
|
|
81
|
+
msgs: readonly RawInboundMessage[],
|
|
82
|
+
rejected: readonly RejectedInboundMessage[],
|
|
83
|
+
cursorSnapshot: string,
|
|
84
|
+
options?: IngestBatchOptions,
|
|
85
|
+
) => Promise<IngestBatchOutcome>;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
type IngesterDeps = {
|
|
89
|
+
readonly db: DbConnection;
|
|
90
|
+
readonly dispatchWrite: DispatchWrite;
|
|
91
|
+
readonly storeBody?: (account: MailAccountRecord, msg: RawInboundMessage) => Promise<string>;
|
|
92
|
+
readonly log: (line: string) => void;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
type FailureClass = "storage" | "encryption" | "ingest";
|
|
96
|
+
|
|
97
|
+
type Attempt =
|
|
98
|
+
| {
|
|
99
|
+
readonly kind: "ingested";
|
|
100
|
+
readonly duplicate: boolean;
|
|
101
|
+
readonly storage: unknown;
|
|
102
|
+
readonly storedBody: boolean;
|
|
103
|
+
}
|
|
104
|
+
| { readonly kind: "permanent" }
|
|
105
|
+
| { readonly kind: "failed"; readonly failure: FailureClass };
|
|
106
|
+
|
|
107
|
+
type Failure = { readonly msg: RawInboundMessage; readonly failure: FailureClass };
|
|
108
|
+
|
|
109
|
+
function attemptOfFailure(failure: unknown): Attempt {
|
|
110
|
+
if (isFileStorageWriteFailure(failure)) return { kind: "failed", failure: "storage" };
|
|
111
|
+
const code = errorCodeOf(failure);
|
|
112
|
+
if (code === ENCRYPTION_UNAVAILABLE_CODE) return { kind: "failed", failure: "encryption" };
|
|
113
|
+
return code !== undefined && PERMANENT_ERROR_CODES.has(code)
|
|
114
|
+
? { kind: "permanent" }
|
|
115
|
+
: { kind: "failed", failure: "ingest" };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// The most specific class names the outage: a KMS or storage failure explains the generic ones.
|
|
119
|
+
function blockedReasonOf(failures: readonly FailureClass[]): IngestBlockedReason {
|
|
120
|
+
if (failures.includes("encryption")) return "encryption_unavailable";
|
|
121
|
+
if (failures.includes("storage")) return "file_storage_unavailable";
|
|
122
|
+
return "ingest_unavailable";
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// The original is what failed to store; writing it again would fail the header import too.
|
|
126
|
+
async function everyFailureCountedBefore(
|
|
127
|
+
db: DbConnection,
|
|
128
|
+
account: MailAccountRecord,
|
|
129
|
+
failures: readonly Failure[],
|
|
130
|
+
): Promise<boolean> {
|
|
131
|
+
for (const { msg, failure } of failures) {
|
|
132
|
+
if (failure === "encryption") return false;
|
|
133
|
+
const target = {
|
|
134
|
+
tenantId: account.tenantId,
|
|
135
|
+
accountId: account.id,
|
|
136
|
+
providerMessageId: msg.providerMessageId,
|
|
137
|
+
};
|
|
138
|
+
if (!(await hasCountedIngestFailure(db, target))) return false;
|
|
139
|
+
}
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function quarantineHeadersOf(msg: RawInboundMessage, failure: FailureClass): RawInboundMessage {
|
|
144
|
+
const headerOnly = headerOnlyOf(msg);
|
|
145
|
+
return failure === "storage" ? { ...headerOnly, rawMime: null } : headerOnly;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function createMessageIngester(deps: IngesterDeps): MessageIngester {
|
|
149
|
+
async function quarantine(
|
|
150
|
+
account: MailAccountRecord,
|
|
151
|
+
providerMessageId: string,
|
|
152
|
+
reason: QuarantineReasonCode,
|
|
153
|
+
headerOnly: RawInboundMessage | null,
|
|
154
|
+
): Promise<boolean> {
|
|
155
|
+
const created = await quarantineMessage(
|
|
156
|
+
deps.db,
|
|
157
|
+
{ tenantId: account.tenantId, accountId: account.id, providerMessageId },
|
|
158
|
+
reason,
|
|
159
|
+
);
|
|
160
|
+
await clearIngestFailures(deps.db, account.id, [providerMessageId]);
|
|
161
|
+
await importQuarantinedHeaders(deps, account, headerOnly);
|
|
162
|
+
// Ids only: subject and sender of a poison mail stay out of logs.
|
|
163
|
+
deps.log(`inbound-mail: account ${account.id} quarantined a message (${reason})`);
|
|
164
|
+
return created;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function storeBodyOf(account: MailAccountRecord, raw: RawInboundMessage): Promise<string> {
|
|
168
|
+
if (!deps.storeBody || !raw.rawMime) return "";
|
|
169
|
+
try {
|
|
170
|
+
return await deps.storeBody(account, raw);
|
|
171
|
+
} catch (err) {
|
|
172
|
+
// The app hook writes to the app's storage: its failure is a storage failure, not the mail's.
|
|
173
|
+
throw new FileStorageWriteFailedError(err instanceof Error ? err : undefined);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function attempt(
|
|
178
|
+
account: MailAccountRecord,
|
|
179
|
+
raw: RawInboundMessage,
|
|
180
|
+
cursorSnapshot: string,
|
|
181
|
+
): Promise<Attempt> {
|
|
182
|
+
try {
|
|
183
|
+
const msg = normalizeRawInboundMessage(raw);
|
|
184
|
+
const bodyRef = await storeBodyOf(account, raw);
|
|
185
|
+
const result = await deps.dispatchWrite({
|
|
186
|
+
handlerQn: InboundMailFoundationHandlers.ingestMessage,
|
|
187
|
+
tenantId: account.tenantId,
|
|
188
|
+
payload: ingestPayloadOf(account, msg, { providerCursor: cursorSnapshot, bodyRef }),
|
|
189
|
+
});
|
|
190
|
+
if (result.isSuccess) {
|
|
191
|
+
return {
|
|
192
|
+
kind: "ingested",
|
|
193
|
+
duplicate: propertyOf(result.data, "duplicate") === true,
|
|
194
|
+
storage: propertyOf(result.data, "contentStorage"),
|
|
195
|
+
storedBody: bodyRef !== "",
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
return attemptOfFailure(result.error ?? {});
|
|
199
|
+
} catch (err) {
|
|
200
|
+
return attemptOfFailure(err);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Returns the failures left unresolved: "proven" ones wait for their count, the others are the
|
|
205
|
+
// account's outage.
|
|
206
|
+
async function settleFailures(
|
|
207
|
+
account: MailAccountRecord,
|
|
208
|
+
failures: readonly Failure[],
|
|
209
|
+
proof: IngestProof,
|
|
210
|
+
options: IngestBatchOptions,
|
|
211
|
+
): Promise<{ quarantinedNew: number; blocked?: IngestBlockedReason }> {
|
|
212
|
+
let quarantinedNew = 0;
|
|
213
|
+
const unproven: FailureClass[] = [];
|
|
214
|
+
let waiting = false;
|
|
215
|
+
// An abandoned sync (deadline) must not count; its failures are the deadline's.
|
|
216
|
+
const bucket = options.signal?.aborted ? undefined : options.countBucket;
|
|
217
|
+
// Accepted residue, no mail is lost and each case is visible:
|
|
218
|
+
// - a first failure without proof never counts: degraded until the next new mail
|
|
219
|
+
// - a KMS failure only holds the cursor, shown as encryption_unavailable
|
|
220
|
+
// - a storage or ingest outage can finish the count of mails counted before (retry exists)
|
|
221
|
+
const countedBefore =
|
|
222
|
+
!(proof.ingested && proof.storageHealthy) &&
|
|
223
|
+
(await everyFailureCountedBefore(deps.db, account, failures));
|
|
224
|
+
for (const { msg, failure } of failures) {
|
|
225
|
+
const target = {
|
|
226
|
+
tenantId: account.tenantId,
|
|
227
|
+
accountId: account.id,
|
|
228
|
+
providerMessageId: msg.providerMessageId,
|
|
229
|
+
};
|
|
230
|
+
const proven =
|
|
231
|
+
(failure === "storage" ? proof.storageHealthy : proof.ingested) || countedBefore;
|
|
232
|
+
if (!proven) {
|
|
233
|
+
unproven.push(failure);
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
const count = bucket === undefined ? null : await countIngestFailure(deps.db, target, bucket);
|
|
237
|
+
if (count !== null && count >= MAX_COUNTED_INGEST_FAILURES) {
|
|
238
|
+
const headerOnly = quarantineHeadersOf(msg, failure);
|
|
239
|
+
if (await quarantine(account, msg.providerMessageId, "ingest_failed", headerOnly)) {
|
|
240
|
+
quarantinedNew += 1;
|
|
241
|
+
}
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
waiting = true;
|
|
245
|
+
}
|
|
246
|
+
if (unproven.length > 0) return { quarantinedNew, blocked: blockedReasonOf(unproven) };
|
|
247
|
+
return { quarantinedNew, ...(waiting && { blocked: "message_failing" as const }) };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function quarantineRejected(
|
|
251
|
+
account: MailAccountRecord,
|
|
252
|
+
rejected: readonly RejectedInboundMessage[],
|
|
253
|
+
): Promise<number> {
|
|
254
|
+
let created = 0;
|
|
255
|
+
for (const entry of rejected) {
|
|
256
|
+
const headerOnly = headerOnlyOfRejected(entry);
|
|
257
|
+
if (await quarantine(account, entry.providerMessageId, "unparseable", headerOnly))
|
|
258
|
+
created += 1;
|
|
259
|
+
}
|
|
260
|
+
return created;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
async function ingestBatch(
|
|
264
|
+
account: MailAccountRecord,
|
|
265
|
+
msgs: readonly RawInboundMessage[],
|
|
266
|
+
rejected: readonly RejectedInboundMessage[],
|
|
267
|
+
cursorSnapshot: string,
|
|
268
|
+
options: IngestBatchOptions = {},
|
|
269
|
+
): Promise<IngestBatchOutcome> {
|
|
270
|
+
let quarantinedNew = await quarantineRejected(account, rejected);
|
|
271
|
+
let storageUnavailable = false;
|
|
272
|
+
let ingested = options.proof?.ingested ?? false;
|
|
273
|
+
let storageHealthy = options.proof?.storageHealthy ?? false;
|
|
274
|
+
const failures: Failure[] = [];
|
|
275
|
+
for (const msg of msgs) {
|
|
276
|
+
const result = await attempt(account, msg, cursorSnapshot);
|
|
277
|
+
if (result.kind === "permanent") {
|
|
278
|
+
const headerOnly = headerOnlyOf(msg);
|
|
279
|
+
const created = await quarantine(
|
|
280
|
+
account,
|
|
281
|
+
msg.providerMessageId,
|
|
282
|
+
"invalid_message",
|
|
283
|
+
headerOnly,
|
|
284
|
+
);
|
|
285
|
+
if (created) quarantinedNew += 1;
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
if (result.kind === "failed") {
|
|
289
|
+
failures.push({ msg, failure: result.failure });
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
await clearIngestFailures(deps.db, account.id, [msg.providerMessageId]);
|
|
293
|
+
if (result.storage === "unavailable") storageUnavailable = true;
|
|
294
|
+
if (result.storage === "stored" || result.storedBody) storageHealthy = true;
|
|
295
|
+
if (!result.duplicate) ingested = true;
|
|
296
|
+
}
|
|
297
|
+
const settled =
|
|
298
|
+
failures.length > 0
|
|
299
|
+
? await settleFailures(account, failures, { ingested, storageHealthy }, options)
|
|
300
|
+
: { quarantinedNew: 0 };
|
|
301
|
+
return {
|
|
302
|
+
quarantinedNew: quarantinedNew + settled.quarantinedNew,
|
|
303
|
+
storageUnavailable,
|
|
304
|
+
storageHealthy,
|
|
305
|
+
ingested,
|
|
306
|
+
...(settled.blocked && { blocked: settled.blocked }),
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return { ingestBatch };
|
|
311
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Immediate sync of one account, used when a mailbox was just connected, reconnected or re-activated.
|
|
2
|
+
// Shares the per-account in-flight guard with the regular poll, so a wake never runs a second fetch
|
|
3
|
+
// next to a running tick for the same account; a caller that loses the race joins the running one.
|
|
4
|
+
|
|
5
|
+
import type { SyncableAccount } from "./syncable-accounts.ts";
|
|
6
|
+
import type { InboundMailProviderPlugin } from "./types.ts";
|
|
7
|
+
|
|
8
|
+
export type PollAccountOptions = {
|
|
9
|
+
readonly fromWake?: boolean;
|
|
10
|
+
/** Aborted when the supervisor abandons this sync; an aborted sync stores nothing. */
|
|
11
|
+
readonly signal?: AbortSignal;
|
|
12
|
+
/** Set by regular ticks only: ingest failures count once per bucket. Wake and manual syncs never count. */
|
|
13
|
+
readonly countBucket?: number;
|
|
14
|
+
};
|
|
15
|
+
/** "credential_pending": the secret is not stored yet, nothing was polled and no status was set. */
|
|
16
|
+
export type PollAccountResult = "done" | "credential_pending";
|
|
17
|
+
|
|
18
|
+
export type InFlightSyncs = Map<string, Promise<void>>;
|
|
19
|
+
|
|
20
|
+
/** Runs `run` unless a sync for the account is already in flight; then returns that one instead. */
|
|
21
|
+
export function runExclusivePerAccount(
|
|
22
|
+
inFlight: InFlightSyncs,
|
|
23
|
+
accountId: string,
|
|
24
|
+
run: () => Promise<void>,
|
|
25
|
+
): Promise<void> {
|
|
26
|
+
const running = inFlight.get(accountId);
|
|
27
|
+
if (running) return running;
|
|
28
|
+
const started = run().finally(() => {
|
|
29
|
+
inFlight.delete(accountId);
|
|
30
|
+
});
|
|
31
|
+
inFlight.set(accountId, started);
|
|
32
|
+
return started;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function createSyncAccountNow(deps: {
|
|
36
|
+
readonly inFlight: InFlightSyncs;
|
|
37
|
+
readonly loadAccount: (accountId: string) => Promise<SyncableAccount | undefined>;
|
|
38
|
+
readonly resolvePlugin: (account: SyncableAccount) => InboundMailProviderPlugin | undefined;
|
|
39
|
+
readonly pollAccount: (
|
|
40
|
+
account: SyncableAccount,
|
|
41
|
+
plugin: InboundMailProviderPlugin,
|
|
42
|
+
options: PollAccountOptions,
|
|
43
|
+
) => Promise<PollAccountResult>;
|
|
44
|
+
readonly ensureWatcher: (
|
|
45
|
+
account: SyncableAccount,
|
|
46
|
+
plugin: InboundMailProviderPlugin,
|
|
47
|
+
) => Promise<void>;
|
|
48
|
+
readonly isRunning: () => boolean;
|
|
49
|
+
}): (accountId: string) => Promise<void> {
|
|
50
|
+
return (accountId) =>
|
|
51
|
+
runExclusivePerAccount(deps.inFlight, accountId, async () => {
|
|
52
|
+
const account = await deps.loadAccount(accountId);
|
|
53
|
+
// skip: the account is gone or no longer syncable.
|
|
54
|
+
if (!account) return;
|
|
55
|
+
const plugin = deps.resolvePlugin(account);
|
|
56
|
+
// skip: no provider plugin for this account; resolvePlugin logged it.
|
|
57
|
+
if (!plugin) return;
|
|
58
|
+
const result = await deps.pollAccount(account, plugin, { fromWake: true });
|
|
59
|
+
if (result === "done" && deps.isRunning()) await deps.ensureWatcher(account, plugin);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Cursor persistence of the sync supervisor (unmanaged direct-write store).
|
|
2
|
+
|
|
3
|
+
import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
|
|
4
|
+
import { fetchOne, insertOne, updateMany } from "@cosmicdrift/kumiko-framework/db";
|
|
5
|
+
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
6
|
+
import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
|
|
7
|
+
import { syncCursorTable } from "./entities.ts";
|
|
8
|
+
import type { MailAccountRecord, SyncCursorPayload } from "./types.ts";
|
|
9
|
+
|
|
10
|
+
/** One cursor per account (one mailbox inbox) for now; multi-folder support
|
|
11
|
+
* can add further scopes later without a schema change. */
|
|
12
|
+
const CURSOR_SCOPE = "default";
|
|
13
|
+
|
|
14
|
+
export type SyncCursorStore = {
|
|
15
|
+
readonly load: (accountId: string) => Promise<SyncCursorPayload | null>;
|
|
16
|
+
readonly save: (
|
|
17
|
+
account: Pick<MailAccountRecord, "id" | "tenantId">,
|
|
18
|
+
cursor: SyncCursorPayload,
|
|
19
|
+
) => Promise<void>;
|
|
20
|
+
/** The next fetch runs as a backfill. */
|
|
21
|
+
readonly reset: (accountId: string) => Promise<void>;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function createSyncCursorStore(db: DbRunner): SyncCursorStore {
|
|
25
|
+
return {
|
|
26
|
+
async load(accountId) {
|
|
27
|
+
const row = await fetchOne<{ cursor: string }>(db, syncCursorTable, {
|
|
28
|
+
accountId,
|
|
29
|
+
scope: CURSOR_SCOPE,
|
|
30
|
+
});
|
|
31
|
+
if (!row) return null;
|
|
32
|
+
try {
|
|
33
|
+
const parsed: unknown = JSON.parse(row.cursor);
|
|
34
|
+
return isPlainObject(parsed) ? parsed : null;
|
|
35
|
+
} catch {
|
|
36
|
+
return null; // a corrupt cursor counts as none → backfill; dedup catches duplicates
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
async save(account, cursor) {
|
|
41
|
+
const accountId = account.id;
|
|
42
|
+
const now = Temporal.Now.instant().toString();
|
|
43
|
+
const serialized = JSON.stringify(cursor);
|
|
44
|
+
const existing = await fetchOne<{ id: string }>(db, syncCursorTable, {
|
|
45
|
+
accountId,
|
|
46
|
+
scope: CURSOR_SCOPE,
|
|
47
|
+
});
|
|
48
|
+
if (existing) {
|
|
49
|
+
await updateMany(
|
|
50
|
+
db,
|
|
51
|
+
syncCursorTable,
|
|
52
|
+
{ cursor: serialized, updatedAt: now },
|
|
53
|
+
{ accountId, scope: CURSOR_SCOPE },
|
|
54
|
+
);
|
|
55
|
+
// skip: the update path is complete; do not fall through to the insert branch.
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
await insertOne(db, syncCursorTable, {
|
|
59
|
+
id: crypto.randomUUID(),
|
|
60
|
+
// Direct-write store: no executor stamps the NOT NULL tenant_id for us.
|
|
61
|
+
tenantId: account.tenantId,
|
|
62
|
+
accountId,
|
|
63
|
+
scope: CURSOR_SCOPE,
|
|
64
|
+
cursor: serialized,
|
|
65
|
+
updatedAt: now,
|
|
66
|
+
});
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
async reset(accountId) {
|
|
70
|
+
// An empty cursor parses to null, so the next fetch runs as a backfill.
|
|
71
|
+
await updateMany(
|
|
72
|
+
db,
|
|
73
|
+
syncCursorTable,
|
|
74
|
+
{ cursor: "", updatedAt: Temporal.Now.instant().toString() },
|
|
75
|
+
{ accountId, scope: CURSOR_SCOPE },
|
|
76
|
+
);
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Account snapshot for the sync supervisor: accounts of all tenants that should be polled, with the
|
|
2
|
+
// address decrypted (providers never work with ciphertext). Degraded accounts stay in the set, so a
|
|
3
|
+
// poison mail or a transient outage does not stop the sync that would heal them.
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
configuredPiiSubjectKms,
|
|
7
|
+
decryptPiiFieldValues,
|
|
8
|
+
ENCRYPTION_UNAVAILABLE_CODE,
|
|
9
|
+
type LocalKeyKmsAdapter,
|
|
10
|
+
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
11
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
12
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/db";
|
|
13
|
+
import { errorCodeOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
14
|
+
import { type InboundMailAccountStatus, InboundMailAccountStatuses } from "./constants.ts";
|
|
15
|
+
import { MAIL_ACCOUNT_PII_FIELDS } from "./entities.ts";
|
|
16
|
+
import { mailAccountsProjectionTable } from "./projection.ts";
|
|
17
|
+
import type { MailAccountRecord } from "./types.ts";
|
|
18
|
+
|
|
19
|
+
export type SyncableAccount = MailAccountRecord & {
|
|
20
|
+
/** Health code currently shown on the account; supervisor-internal, not part of the provider contract. */
|
|
21
|
+
readonly lastError: string | null;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** What the account health needs of an account, which an account without a readable address still has. */
|
|
25
|
+
export type AccountHealthRef = Pick<SyncableAccount, "id" | "tenantId" | "status" | "lastError">;
|
|
26
|
+
|
|
27
|
+
type SyncableMailAccountRow = {
|
|
28
|
+
readonly id: string;
|
|
29
|
+
readonly tenantId: string;
|
|
30
|
+
readonly provider: string;
|
|
31
|
+
readonly authMethod: string;
|
|
32
|
+
readonly ownerUserId?: string | null;
|
|
33
|
+
readonly address: string;
|
|
34
|
+
readonly displayName?: string;
|
|
35
|
+
readonly status?: string;
|
|
36
|
+
readonly watchState?: string;
|
|
37
|
+
readonly lastError?: string | null;
|
|
38
|
+
readonly trustedAuthservId?: string | null;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const SYNCABLE_STATUSES: readonly InboundMailAccountStatus[] = [
|
|
42
|
+
InboundMailAccountStatuses.active,
|
|
43
|
+
InboundMailAccountStatuses.degraded,
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
function syncableStatusOf(status: string | undefined): InboundMailAccountStatus {
|
|
47
|
+
return status === InboundMailAccountStatuses.degraded
|
|
48
|
+
? InboundMailAccountStatuses.degraded
|
|
49
|
+
: InboundMailAccountStatuses.active;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type KeyStoreOutageListener = {
|
|
53
|
+
readonly onKeyStoreUnavailable: (account: AccountHealthRef) => Promise<void>;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// Undefined skips the account: one undecryptable account must not stop polling of every other
|
|
57
|
+
// tenant's accounts.
|
|
58
|
+
async function decryptedAddressOf(
|
|
59
|
+
row: SyncableMailAccountRow,
|
|
60
|
+
piiKms: LocalKeyKmsAdapter | undefined,
|
|
61
|
+
log: (line: string) => void,
|
|
62
|
+
health: KeyStoreOutageListener | undefined,
|
|
63
|
+
): Promise<string | undefined> {
|
|
64
|
+
let decrypted: Record<string, unknown>;
|
|
65
|
+
try {
|
|
66
|
+
decrypted = piiKms
|
|
67
|
+
? await decryptPiiFieldValues(row, MAIL_ACCOUNT_PII_FIELDS, piiKms, {
|
|
68
|
+
requestId: "inbound-mail-foundation:supervisor:list-accounts",
|
|
69
|
+
})
|
|
70
|
+
: row;
|
|
71
|
+
} catch (error) {
|
|
72
|
+
// Class name only: the message may carry ciphertext or key details.
|
|
73
|
+
const errorName = error instanceof Error ? error.name : "unknown error";
|
|
74
|
+
log(`inbound-mail: account ${row.id} skipped — address did not decrypt (${errorName})`);
|
|
75
|
+
// A key store outage is not the account's problem, but without a mark it would look active.
|
|
76
|
+
if (health && errorCodeOf(error) === ENCRYPTION_UNAVAILABLE_CODE) {
|
|
77
|
+
await health.onKeyStoreUnavailable({
|
|
78
|
+
id: row.id,
|
|
79
|
+
tenantId: row.tenantId,
|
|
80
|
+
status: syncableStatusOf(row.status),
|
|
81
|
+
lastError: row.lastError ?? null,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
const address = decrypted["address"];
|
|
87
|
+
// Falling back to row.address would hand the ciphertext to a provider.
|
|
88
|
+
if (typeof address === "string") return address;
|
|
89
|
+
log(`inbound-mail: account ${row.id} skipped — address did not decrypt to text`);
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function listSyncableAccounts(
|
|
94
|
+
db: DbConnection,
|
|
95
|
+
log: (line: string) => void,
|
|
96
|
+
options: { readonly onlyAccountId?: string; readonly health?: KeyStoreOutageListener } = {},
|
|
97
|
+
): Promise<readonly SyncableAccount[]> {
|
|
98
|
+
const { onlyAccountId, health } = options;
|
|
99
|
+
const rows = await selectMany<SyncableMailAccountRow>(db, mailAccountsProjectionTable, {
|
|
100
|
+
status: { in: SYNCABLE_STATUSES },
|
|
101
|
+
...(onlyAccountId !== undefined && { id: onlyAccountId }),
|
|
102
|
+
});
|
|
103
|
+
const piiKms = configuredPiiSubjectKms();
|
|
104
|
+
const records: SyncableAccount[] = [];
|
|
105
|
+
for (const row of rows) {
|
|
106
|
+
const address = await decryptedAddressOf(row, piiKms, log, health);
|
|
107
|
+
if (address === undefined) continue;
|
|
108
|
+
records.push({
|
|
109
|
+
id: row.id,
|
|
110
|
+
tenantId: row.tenantId,
|
|
111
|
+
provider: row.provider,
|
|
112
|
+
authMethod: row.authMethod,
|
|
113
|
+
ownerUserId: row.ownerUserId ?? null,
|
|
114
|
+
address,
|
|
115
|
+
displayName: row.displayName ?? "",
|
|
116
|
+
status: syncableStatusOf(row.status),
|
|
117
|
+
watchState: row.watchState ?? "idle",
|
|
118
|
+
lastError: row.lastError ?? null,
|
|
119
|
+
trustedAuthservId: row.trustedAuthservId ?? null,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
return records;
|
|
123
|
+
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// operation, so O(n) is acceptable.
|
|
9
9
|
// 2. Deletes the projection rows.
|
|
10
10
|
// 3. For mail-account only, deletes unmanaged sync state (cursors,
|
|
11
|
-
// seen anchors), which is keyed by accountId and must therefore be
|
|
11
|
+
// seen anchors, quarantine entries, ingest failure counters), which is keyed by accountId and must therefore be
|
|
12
12
|
// collected from the account rows BEFORE deletion.
|
|
13
13
|
//
|
|
14
14
|
// The actual PII erasure uses crypto-shredding: eraseSubjectKeys deletes the
|
|
@@ -24,11 +24,13 @@ import {
|
|
|
24
24
|
MAIL_ACCOUNT_AGGREGATE_TYPE,
|
|
25
25
|
MAIL_THREAD_AGGREGATE_TYPE,
|
|
26
26
|
} from "./events.ts";
|
|
27
|
+
import { ingestFailureTable } from "./ingest-failures.ts";
|
|
27
28
|
import {
|
|
28
29
|
inboundMessagesProjectionTable,
|
|
29
30
|
mailAccountsProjectionTable,
|
|
30
31
|
mailThreadsProjectionTable,
|
|
31
32
|
} from "./projection.ts";
|
|
33
|
+
import { quarantineTable } from "./quarantine-entities.ts";
|
|
32
34
|
|
|
33
35
|
const ARCHIVED_BY = "tenant-lifecycle:destroy";
|
|
34
36
|
const REASON = "tenant_destroy";
|
|
@@ -66,6 +68,8 @@ export async function mailAccountTenantDestroyHook(ctx: TenantDataHookCtx): Prom
|
|
|
66
68
|
for (const accountId of accountIds) {
|
|
67
69
|
await ctx.db.deleteMany(syncCursorTable, { accountId });
|
|
68
70
|
await ctx.db.deleteMany(seenMessageTable, { accountId });
|
|
71
|
+
await ctx.db.deleteMany(quarantineTable, { accountId });
|
|
72
|
+
await ctx.db.deleteMany(ingestFailureTable, { accountId });
|
|
69
73
|
}
|
|
70
74
|
}
|
|
71
75
|
|