@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
package/packages/bundled-features/src/inbound-mail-foundation/handlers/reactivate-account.write.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// reactivate-account — the only way from disconnected back to active (it also clears auth_error
|
|
2
|
+
// and degraded). update-account keeps rejecting disconnected accounts so a late supervisor tick
|
|
3
|
+
// can never undo a disconnect; a deliberate reconnect goes through here instead, keeping the
|
|
4
|
+
// accountId and with it cursor, seen anchors and threads.
|
|
5
|
+
|
|
6
|
+
import { requireSecretsContext } from "@cosmicdrift/kumiko-bundled-features/secrets";
|
|
7
|
+
import {
|
|
8
|
+
type HandlerContext,
|
|
9
|
+
SYSTEM_ROLE,
|
|
10
|
+
SYSTEM_USER_ID,
|
|
11
|
+
type WriteHandlerDef,
|
|
12
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
13
|
+
import {
|
|
14
|
+
AccessDeniedError,
|
|
15
|
+
failNotFound,
|
|
16
|
+
UnprocessableError,
|
|
17
|
+
writeFailure,
|
|
18
|
+
} from "@cosmicdrift/kumiko-framework/errors";
|
|
19
|
+
import * as z from "zod";
|
|
20
|
+
import {
|
|
21
|
+
InboundMailAccountStatuses,
|
|
22
|
+
InboundMailConnectErrors,
|
|
23
|
+
InboundMailFoundationHandlers,
|
|
24
|
+
inboundCredentialSecretKey,
|
|
25
|
+
} from "../constants.ts";
|
|
26
|
+
import {
|
|
27
|
+
MAIL_ACCOUNT_AGGREGATE_TYPE,
|
|
28
|
+
MAIL_ACCOUNT_UPDATED_EVENT_QN,
|
|
29
|
+
type MailAccountEventHeaders,
|
|
30
|
+
type MailAccountEventPayload,
|
|
31
|
+
} from "../events.ts";
|
|
32
|
+
import { accessRolesOf, type InboundMailRoles } from "../mail-roles.ts";
|
|
33
|
+
import { loadCurrentMailAccountPayload } from "./account-state.ts";
|
|
34
|
+
import { mayReactivateMailbox } from "./scope-visibility.ts";
|
|
35
|
+
|
|
36
|
+
export const RECONNECT_AUDIT_REASON = "reconnect";
|
|
37
|
+
|
|
38
|
+
// Strict: the trusted authserv-id only changes through set-trusted-authserv-id.
|
|
39
|
+
export const reactivateAccountSchema = z.object({ accountId: z.uuid() }).strict();
|
|
40
|
+
|
|
41
|
+
const CREDENTIAL_PRESENCE_REASON =
|
|
42
|
+
"the credential secret is checked on the request transaction because reconnect-mailbox writes it earlier in the same transaction, which a separate connection would not see";
|
|
43
|
+
|
|
44
|
+
// The OAuth callback re-arms a personal mailbox for its owner after the owner signed in: the signed
|
|
45
|
+
// state carries the owner, the dispatcher only sees the system user.
|
|
46
|
+
function isSystemActor(user: { readonly id: string; readonly roles: readonly string[] }): boolean {
|
|
47
|
+
return user.id === SYSTEM_USER_ID && user.roles.includes(SYSTEM_ROLE);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isAlreadyHealthy(current: MailAccountEventPayload): boolean {
|
|
51
|
+
return (
|
|
52
|
+
current.status === InboundMailAccountStatuses.active &&
|
|
53
|
+
current.watchState === "idle" &&
|
|
54
|
+
current.lastError === null
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Returns false when nothing had to change.
|
|
59
|
+
async function appendMailAccountReactivation(
|
|
60
|
+
ctx: Pick<HandlerContext, "events">,
|
|
61
|
+
accountId: string,
|
|
62
|
+
current: MailAccountEventPayload,
|
|
63
|
+
): Promise<boolean> {
|
|
64
|
+
if (isAlreadyHealthy(current)) return false;
|
|
65
|
+
const eventPayload: MailAccountEventPayload = {
|
|
66
|
+
...current,
|
|
67
|
+
status: InboundMailAccountStatuses.active,
|
|
68
|
+
watchState: "idle",
|
|
69
|
+
lastError: null,
|
|
70
|
+
};
|
|
71
|
+
const headers: MailAccountEventHeaders = {
|
|
72
|
+
providerName: current.provider,
|
|
73
|
+
reason: RECONNECT_AUDIT_REASON,
|
|
74
|
+
};
|
|
75
|
+
await ctx.events.unsafeAppend({
|
|
76
|
+
aggregateId: accountId,
|
|
77
|
+
aggregateType: MAIL_ACCOUNT_AGGREGATE_TYPE,
|
|
78
|
+
type: MAIL_ACCOUNT_UPDATED_EVENT_QN,
|
|
79
|
+
payload: eventPayload,
|
|
80
|
+
headers,
|
|
81
|
+
});
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function createReactivateAccountHandler({
|
|
86
|
+
memberRoles,
|
|
87
|
+
adminRoles,
|
|
88
|
+
}: InboundMailRoles): WriteHandlerDef {
|
|
89
|
+
return {
|
|
90
|
+
name: "reactivate-account",
|
|
91
|
+
schema: reactivateAccountSchema,
|
|
92
|
+
// Members: the owner re-arms a personal mailbox; mayReactivateMailbox keeps shared ones to admins.
|
|
93
|
+
access: { roles: accessRolesOf(InboundMailFoundationHandlers.reactivateAccount, memberRoles) },
|
|
94
|
+
agent: { expose: false },
|
|
95
|
+
escapeHatch: { grants: ["unsafeRaw"], reason: CREDENTIAL_PRESENCE_REASON },
|
|
96
|
+
description:
|
|
97
|
+
"Brings a disconnected, interrupted or sign-in-failed mailbox back to active under its existing accountId, so cursor, seen messages and threads survive; the caller replaces the credential in the same flow.",
|
|
98
|
+
handler: async (event, ctx) => {
|
|
99
|
+
const payload = reactivateAccountSchema.parse(event.payload);
|
|
100
|
+
const current = await loadCurrentMailAccountPayload(ctx, payload.accountId);
|
|
101
|
+
if (!current) return failNotFound("mail-account", payload.accountId);
|
|
102
|
+
if (
|
|
103
|
+
!isSystemActor(event.user) &&
|
|
104
|
+
!mayReactivateMailbox(current.ownerUserId, event.user, adminRoles)
|
|
105
|
+
) {
|
|
106
|
+
return writeFailure(new AccessDeniedError({ details: { reason: "not_mailbox_owner" } }));
|
|
107
|
+
}
|
|
108
|
+
// Nothing to re-arm, so no credential is needed either.
|
|
109
|
+
if (isAlreadyHealthy(current)) {
|
|
110
|
+
return { isSuccess: true as const, data: { accountId: payload.accountId, changed: false } };
|
|
111
|
+
}
|
|
112
|
+
// Every other starting state: without a credential the supervisor would only fail again.
|
|
113
|
+
const secrets = requireSecretsContext(
|
|
114
|
+
{ secrets: ctx.services.secrets, user: ctx.user },
|
|
115
|
+
"reactivate-account",
|
|
116
|
+
).withRunner(ctx.db.unsafeRaw());
|
|
117
|
+
const hasCredential = await secrets.has(
|
|
118
|
+
event.user.tenantId,
|
|
119
|
+
inboundCredentialSecretKey(payload.accountId),
|
|
120
|
+
);
|
|
121
|
+
if (!hasCredential) {
|
|
122
|
+
const { reason, i18nKey } = InboundMailConnectErrors.credentialRequired;
|
|
123
|
+
return writeFailure(new UnprocessableError(reason, { i18nKey }));
|
|
124
|
+
}
|
|
125
|
+
const changed = await appendMailAccountReactivation(ctx, payload.accountId, current);
|
|
126
|
+
return { isSuccess: true as const, data: { accountId: payload.accountId, changed } };
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// retry-quarantined-message — marks one quarantined mail for a reload. The next successful sync of
|
|
2
|
+
// the mailbox fetches it again and, when it now goes through, removes the quarantine row
|
|
3
|
+
// (quarantine-retry.ts). The handler only flags; the supervisor owns the provider connection.
|
|
4
|
+
|
|
5
|
+
import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
6
|
+
import {
|
|
7
|
+
AccessDeniedError,
|
|
8
|
+
failNotFound,
|
|
9
|
+
writeFailure,
|
|
10
|
+
} from "@cosmicdrift/kumiko-framework/errors";
|
|
11
|
+
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
12
|
+
import * as z from "zod";
|
|
13
|
+
import type { InboundMailRoles } from "../mail-roles.ts";
|
|
14
|
+
import { quarantineTable } from "../quarantine-entities.ts";
|
|
15
|
+
import { loadCurrentMailAccountPayload } from "./account-state.ts";
|
|
16
|
+
import { mayManageQuarantine } from "./scope-visibility.ts";
|
|
17
|
+
|
|
18
|
+
export const retryQuarantinedMessageSchema = z
|
|
19
|
+
.object({ accountId: z.uuid(), providerMessageId: z.string().min(1).max(500) })
|
|
20
|
+
.strict();
|
|
21
|
+
|
|
22
|
+
export function createRetryQuarantinedMessageHandler({
|
|
23
|
+
memberRoles,
|
|
24
|
+
adminRoles,
|
|
25
|
+
}: InboundMailRoles): WriteHandlerDef {
|
|
26
|
+
return {
|
|
27
|
+
name: "retry-quarantined-message",
|
|
28
|
+
schema: retryQuarantinedMessageSchema,
|
|
29
|
+
access: { roles: [...memberRoles] },
|
|
30
|
+
agent: { expose: false },
|
|
31
|
+
description:
|
|
32
|
+
"Asks for a quarantined mail to be loaded again from the provider on the next sync of its mailbox; the owner of a personal mailbox or, for a shared one, an admin may call it, and the quarantine entry disappears once the mail went through.",
|
|
33
|
+
handler: async (event, ctx) => {
|
|
34
|
+
const payload = retryQuarantinedMessageSchema.parse(event.payload);
|
|
35
|
+
const account = await loadCurrentMailAccountPayload(ctx, payload.accountId);
|
|
36
|
+
if (!account) return failNotFound("mail-account", payload.accountId);
|
|
37
|
+
if (!mayManageQuarantine(account.ownerUserId, event.user, adminRoles)) {
|
|
38
|
+
return writeFailure(new AccessDeniedError({ details: { reason: "not_mailbox_owner" } }));
|
|
39
|
+
}
|
|
40
|
+
const updated = await ctx.db.updateMany(
|
|
41
|
+
quarantineTable,
|
|
42
|
+
{ retryRequestedAt: Temporal.Now.instant().toString() },
|
|
43
|
+
{ accountId: payload.accountId, providerMessageId: payload.providerMessageId },
|
|
44
|
+
);
|
|
45
|
+
if (updated.length === 0) return failNotFound("mail-quarantine", payload.providerMessageId);
|
|
46
|
+
return { isSuccess: true as const, data: { requested: true as const } };
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -1,13 +1,52 @@
|
|
|
1
|
-
// Scope visibility: shared rows (`ownerUserId=null`) are visible to any authorized caller
|
|
2
|
-
// personal
|
|
3
|
-
//
|
|
1
|
+
// Scope visibility: shared rows (`ownerUserId=null`) are visible to any authorized caller.
|
|
2
|
+
// A personal mailbox is private to its owner: its messages, bodies, attachments and quarantine are
|
|
3
|
+
// never shown to admins, who see only the operational metadata of the account (address, status).
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
type Caller = { readonly id: string; readonly roles: readonly string[] };
|
|
6
|
+
|
|
7
|
+
function isShared(ownerUserId: unknown): boolean {
|
|
8
|
+
return ownerUserId == null || ownerUserId === "";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function isVisibleToCaller(row: Readonly<Record<string, unknown>>, user: Caller): boolean {
|
|
12
|
+
const owner = row["ownerUserId"];
|
|
13
|
+
return isShared(owner) || owner === user.id;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function isMailAdmin(
|
|
17
|
+
user: { readonly roles: readonly string[] },
|
|
18
|
+
adminRoles: readonly string[],
|
|
19
|
+
): boolean {
|
|
20
|
+
return adminRoles.some((role) => user.roles.includes(role));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Account metadata (not content) of a personal mailbox stays visible to admins for operations.
|
|
24
|
+
export function isAccountListableBy(
|
|
6
25
|
row: Readonly<Record<string, unknown>>,
|
|
7
|
-
user:
|
|
26
|
+
user: Caller,
|
|
27
|
+
adminRoles: readonly string[],
|
|
8
28
|
): boolean {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
29
|
+
return isVisibleToCaller(row, user) || isMailAdmin(user, adminRoles);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// A personal mailbox is re-armed (and its sender check changed) by its owner only: a reconnect
|
|
33
|
+
// re-reads the owner's mail with whatever credential the caller supplies, so an admin must not do
|
|
34
|
+
// it. A shared mailbox belongs to the tenant: admins only.
|
|
35
|
+
export function mayReactivateMailbox(
|
|
36
|
+
ownerUserId: unknown,
|
|
37
|
+
user: Caller,
|
|
38
|
+
adminRoles: readonly string[],
|
|
39
|
+
): boolean {
|
|
40
|
+
if (isShared(ownerUserId)) return isMailAdmin(user, adminRoles);
|
|
41
|
+
return ownerUserId === user.id;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Quarantine entries carry sender and subject of the mail, so those of a personal mailbox are the
|
|
45
|
+
// owner's alone; shared mailboxes are administered by admins.
|
|
46
|
+
export function mayManageQuarantine(
|
|
47
|
+
ownerUserId: string | null,
|
|
48
|
+
user: Caller,
|
|
49
|
+
adminRoles: readonly string[],
|
|
50
|
+
): boolean {
|
|
51
|
+
return isShared(ownerUserId) ? isMailAdmin(user, adminRoles) : ownerUserId === user.id;
|
|
13
52
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// set-trusted-authserv-id — the one place that changes which Authentication-Results header the
|
|
2
|
+
// sender check believes. It is separate from connect/update/reactivate on purpose: a payload that
|
|
3
|
+
// can smuggle this value in through a generic update would let any caller mark forged headers as
|
|
4
|
+
// trusted, and it must never be a model-callable tool.
|
|
5
|
+
|
|
6
|
+
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
import { failNotFound, failUnprocessable } from "@cosmicdrift/kumiko-framework/errors";
|
|
8
|
+
import * as z from "zod";
|
|
9
|
+
import { InboundMailAccountStatuses } from "../constants.ts";
|
|
10
|
+
import {
|
|
11
|
+
MAIL_ACCOUNT_AGGREGATE_TYPE,
|
|
12
|
+
MAIL_ACCOUNT_UPDATED_EVENT_QN,
|
|
13
|
+
type MailAccountEventHeaders,
|
|
14
|
+
type MailAccountEventPayload,
|
|
15
|
+
} from "../events.ts";
|
|
16
|
+
import type { InboundMailRoles } from "../mail-roles.ts";
|
|
17
|
+
import { normalizeTrustedAuthservId } from "../sender-auth.ts";
|
|
18
|
+
import { loadCurrentMailAccountPayload } from "./account-state.ts";
|
|
19
|
+
import { trustedAuthservIdInputSchema } from "./trusted-authserv-id.ts";
|
|
20
|
+
import { auditReasonFor } from "./update-account.write.ts";
|
|
21
|
+
|
|
22
|
+
export const setTrustedAuthservIdSchema = z
|
|
23
|
+
.object({
|
|
24
|
+
accountId: z.uuid(),
|
|
25
|
+
trustedAuthservId: trustedAuthservIdInputSchema,
|
|
26
|
+
})
|
|
27
|
+
.strict();
|
|
28
|
+
|
|
29
|
+
export const createSetTrustedAuthservIdHandler = ({ adminRoles }: InboundMailRoles) =>
|
|
30
|
+
defineWriteHandler({
|
|
31
|
+
name: "set-trusted-authserv-id",
|
|
32
|
+
schema: setTrustedAuthservIdSchema,
|
|
33
|
+
// A tenant security policy, personal mailboxes included: a trusted authserv-id makes forged senders
|
|
34
|
+
// count as authenticated for the whole tenant (contact matching, auto replies), so owners never set it.
|
|
35
|
+
access: { roles: [...adminRoles] },
|
|
36
|
+
agent: { expose: false },
|
|
37
|
+
description:
|
|
38
|
+
"Sets or clears the authserv-id of the mailbox's own server, which decides whose Authentication-Results header the sender check trusts; null or empty clears it, a connected mailbox only.",
|
|
39
|
+
handler: async (event, ctx) => {
|
|
40
|
+
const payload = event.payload;
|
|
41
|
+
const current = await loadCurrentMailAccountPayload(ctx, payload.accountId);
|
|
42
|
+
if (!current) return failNotFound("mail-account", payload.accountId);
|
|
43
|
+
if (current.status === InboundMailAccountStatuses.disconnected) {
|
|
44
|
+
return failUnprocessable("account_disconnected", { accountId: payload.accountId });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const trustedAuthservId = normalizeTrustedAuthservId(payload.trustedAuthservId);
|
|
48
|
+
if (current.trustedAuthservId === trustedAuthservId) {
|
|
49
|
+
return { isSuccess: true as const, data: { accountId: payload.accountId, changed: false } };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const eventPayload: MailAccountEventPayload = { ...current, trustedAuthservId };
|
|
53
|
+
const headers: MailAccountEventHeaders = {
|
|
54
|
+
providerName: current.provider,
|
|
55
|
+
reason: auditReasonFor(event.user, undefined),
|
|
56
|
+
};
|
|
57
|
+
await ctx.events.unsafeAppend({
|
|
58
|
+
aggregateId: payload.accountId,
|
|
59
|
+
aggregateType: MAIL_ACCOUNT_AGGREGATE_TYPE,
|
|
60
|
+
type: MAIL_ACCOUNT_UPDATED_EVENT_QN,
|
|
61
|
+
payload: eventPayload,
|
|
62
|
+
headers,
|
|
63
|
+
});
|
|
64
|
+
return { isSuccess: true as const, data: { accountId: payload.accountId, changed: true } };
|
|
65
|
+
},
|
|
66
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type configuredPiiSubjectKms,
|
|
3
|
+
encryptPiiFieldValues,
|
|
4
|
+
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
5
|
+
import { acquireNamespacedAdvisoryLock, countWhere } from "@cosmicdrift/kumiko-framework/db";
|
|
6
|
+
import type { HandlerContext, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
8
|
+
import { MAIL_THREAD_PII_FIELDS, mailThreadEntity } from "../entities.ts";
|
|
9
|
+
import {
|
|
10
|
+
type InboundMessageEventHeaders,
|
|
11
|
+
MAIL_THREAD_AGGREGATE_TYPE,
|
|
12
|
+
MAIL_THREAD_UPDATED_EVENT_QN,
|
|
13
|
+
type MailThreadEventPayload,
|
|
14
|
+
} from "../events.ts";
|
|
15
|
+
import { inboundMessagesProjectionTable } from "../projection.ts";
|
|
16
|
+
import type { IngestMessagePayload } from "./ingest-message-schema.ts";
|
|
17
|
+
|
|
18
|
+
const THREAD_ROLLUP_MAX_ATTEMPTS = 5;
|
|
19
|
+
// pg_advisory_xact_lock namespace (int4) for the step-5 thread-rollup lock
|
|
20
|
+
// — 'inbm' as ASCII, keeps it disjoint from the framework's other fixed
|
|
21
|
+
// single-int advisory-lock keys (schema bootstrap, es-ops boot).
|
|
22
|
+
const THREAD_ROLLUP_LOCK_NAMESPACE = 0x696e626d;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Normalizes threadKey:
|
|
26
|
+
* 1. Use the provider thread ID when present — prefix it with the provider
|
|
27
|
+
* so IMAP References keys and Graph conversationIds never collide.
|
|
28
|
+
* 2. Otherwise use the root of the References chain (the oldest Message-ID,
|
|
29
|
+
* which is the thread anchor shared by all replies).
|
|
30
|
+
* 3. Otherwise use the own Message-ID → single-message thread; a later
|
|
31
|
+
* reply carries it as the References root and joins the same thread.
|
|
32
|
+
*/
|
|
33
|
+
export function buildThreadKey(p: IngestMessagePayload, effectiveMessageIdHeader: string): string {
|
|
34
|
+
if (p.providerThreadId) return `${p.providerName}:${p.providerThreadId}`.slice(0, 500);
|
|
35
|
+
const referencesRoot = p.references[0];
|
|
36
|
+
if (referencesRoot) return `mid:${referencesRoot}`.slice(0, 500);
|
|
37
|
+
return `mid:${effectiveMessageIdHeader}`.slice(0, 500);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type ThreadRollupInput = {
|
|
41
|
+
readonly tenantId: TenantId;
|
|
42
|
+
readonly threadAggId: string;
|
|
43
|
+
readonly threadKey: string;
|
|
44
|
+
readonly subject: string;
|
|
45
|
+
readonly receivedAtIso: string;
|
|
46
|
+
readonly headers: InboundMessageEventHeaders;
|
|
47
|
+
readonly piiKms: ReturnType<typeof configuredPiiSubjectKms>;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------
|
|
51
|
+
// 5. Thread-Rollup. New snapshot (live COUNT + max(lastMessageAt)) via
|
|
52
|
+
// ctx.events.tryAppend in a bounded retry loop — the apply stays a
|
|
53
|
+
// dumb, replay-idempotent UPSERT. lastMessageAtIso is not PII (a
|
|
54
|
+
// timestamp), so it stays plaintext-comparable; subject is
|
|
55
|
+
// encrypted once up front (constant across retries, doesn't
|
|
56
|
+
// depend on the thread's prior state).
|
|
57
|
+
//
|
|
58
|
+
// messageCount is a live COUNT, not previousCount+1, so two
|
|
59
|
+
// concurrent ingests on the same thread (Watch-Push vs.
|
|
60
|
+
// Poll-Reconciliation overlap, or two different messages of the
|
|
61
|
+
// same thread arriving at once) converge on the true count.
|
|
62
|
+
// acquireNamespacedAdvisoryLock below serializes this whole
|
|
63
|
+
// countWhere+append section per threadAggId — transaction-scoped,
|
|
64
|
+
// so it survives events.tryAppend's inner SAVEPOINT and releases at
|
|
65
|
+
// the enclosing TX's commit/rollback. Without it, a concurrent
|
|
66
|
+
// commit landing between countWhere and events.tryAppend's own fresh
|
|
67
|
+
// version-read makes the append succeed with a stale count instead
|
|
68
|
+
// of a VersionConflictError, so the retry loop below never sees it
|
|
69
|
+
// as a conflict to retry (the original gap). Unlike step 4, a
|
|
70
|
+
// lost version-conflict race here must NOT be treated as a no-op
|
|
71
|
+
// duplicate — skipping would leave this message's contribution to
|
|
72
|
+
// messageCount/lastMessageAt out of the thread forever. Exhausting
|
|
73
|
+
// all attempts throws, rolling back the whole TX (including the
|
|
74
|
+
// step-4 message append) for a clean re-ingest.
|
|
75
|
+
// ---------------------------------------------------------------
|
|
76
|
+
export async function appendThreadRollup(
|
|
77
|
+
ctx: HandlerContext,
|
|
78
|
+
input: ThreadRollupInput,
|
|
79
|
+
): Promise<void> {
|
|
80
|
+
const { tenantId, threadAggId, threadKey, subject, receivedAtIso, headers, piiKms } = input;
|
|
81
|
+
const threadPlainPii = { tenantId, subject: subject };
|
|
82
|
+
const encryptedThreadFields = piiKms
|
|
83
|
+
? await encryptPiiFieldValues(
|
|
84
|
+
threadPlainPii,
|
|
85
|
+
mailThreadEntity,
|
|
86
|
+
MAIL_THREAD_PII_FIELDS,
|
|
87
|
+
piiKms,
|
|
88
|
+
{
|
|
89
|
+
requestId: `inbound-mail-foundation:ingest-message:thread:${threadAggId}`,
|
|
90
|
+
tenantId,
|
|
91
|
+
},
|
|
92
|
+
{ tenantId, entityName: MAIL_THREAD_AGGREGATE_TYPE },
|
|
93
|
+
)
|
|
94
|
+
: threadPlainPii;
|
|
95
|
+
|
|
96
|
+
const threadRollupRunner = ctx.db.unsafeRaw();
|
|
97
|
+
await acquireNamespacedAdvisoryLock(
|
|
98
|
+
threadRollupRunner,
|
|
99
|
+
THREAD_ROLLUP_LOCK_NAMESPACE,
|
|
100
|
+
threadAggId,
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
let threadAppendOk = false;
|
|
104
|
+
for (let attempt = 1; attempt <= THREAD_ROLLUP_MAX_ATTEMPTS && !threadAppendOk; attempt++) {
|
|
105
|
+
const threadEvents = await ctx.events.load(threadAggId);
|
|
106
|
+
const lastThreadEvent = threadEvents[threadEvents.length - 1];
|
|
107
|
+
// @cast-boundary engine-payload — eigene events, shape via defineEvent
|
|
108
|
+
const previousThread = lastThreadEvent?.payload as MailThreadEventPayload | undefined;
|
|
109
|
+
const previousLastAt = previousThread?.lastMessageAtIso ?? "";
|
|
110
|
+
const newLastAt = receivedAtIso > previousLastAt ? receivedAtIso : previousLastAt;
|
|
111
|
+
const messageCount = await countWhere(threadRollupRunner, inboundMessagesProjectionTable, {
|
|
112
|
+
tenantId,
|
|
113
|
+
threadKey,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const threadEventPayload: MailThreadEventPayload = {
|
|
117
|
+
threadKey,
|
|
118
|
+
subject: encryptedThreadFields.subject,
|
|
119
|
+
lastMessageAtIso: newLastAt,
|
|
120
|
+
messageCount,
|
|
121
|
+
};
|
|
122
|
+
const threadAppend = await ctx.events.tryAppend({
|
|
123
|
+
aggregateId: threadAggId,
|
|
124
|
+
aggregateType: MAIL_THREAD_AGGREGATE_TYPE,
|
|
125
|
+
type: MAIL_THREAD_UPDATED_EVENT_QN,
|
|
126
|
+
payload: threadEventPayload,
|
|
127
|
+
headers,
|
|
128
|
+
});
|
|
129
|
+
threadAppendOk = threadAppend.ok;
|
|
130
|
+
}
|
|
131
|
+
if (!threadAppendOk) {
|
|
132
|
+
throw new InternalError({
|
|
133
|
+
message: `ingest-message: thread-rollup append for "${threadAggId}" lost the version-conflict race ${THREAD_ROLLUP_MAX_ATTEMPTS} times in a row — aborting for a clean re-ingest instead of persisting a stale messageCount.`,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
package/packages/bundled-features/src/inbound-mail-foundation/handlers/update-account.write.ts
CHANGED
|
@@ -11,82 +11,104 @@ import {
|
|
|
11
11
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
12
12
|
import { failNotFound, failUnprocessable } from "@cosmicdrift/kumiko-framework/errors";
|
|
13
13
|
import * as z from "zod";
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
InboundMailAccountStatuses,
|
|
16
|
+
InboundMailFoundationHandlers,
|
|
17
|
+
InboundMailLastErrors,
|
|
18
|
+
} from "../constants.ts";
|
|
15
19
|
import {
|
|
16
20
|
MAIL_ACCOUNT_AGGREGATE_TYPE,
|
|
17
21
|
MAIL_ACCOUNT_UPDATED_EVENT_QN,
|
|
18
22
|
type MailAccountEventHeaders,
|
|
19
23
|
type MailAccountEventPayload,
|
|
20
24
|
} from "../events.ts";
|
|
25
|
+
import { accessRolesOf, type InboundMailRoles } from "../mail-roles.ts";
|
|
21
26
|
import { loadCurrentMailAccountPayload } from "./account-state.ts";
|
|
22
27
|
|
|
23
|
-
export const updateAccountSchema = z
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
28
|
+
export const updateAccountSchema = z
|
|
29
|
+
.object({
|
|
30
|
+
accountId: z.uuid(),
|
|
31
|
+
status: z
|
|
32
|
+
.enum([
|
|
33
|
+
InboundMailAccountStatuses.active,
|
|
34
|
+
InboundMailAccountStatuses.authError,
|
|
35
|
+
InboundMailAccountStatuses.degraded,
|
|
36
|
+
])
|
|
37
|
+
.optional(),
|
|
38
|
+
/** Why the mailbox is degraded; null clears it. Codes only, never provider error text. */
|
|
39
|
+
lastError: z.enum(Object.values(InboundMailLastErrors)).nullable().optional(),
|
|
40
|
+
/** Ops-Signal ("idle", "watching", "backoff:3", ...). */
|
|
41
|
+
watchState: z.string().max(100).optional(),
|
|
42
|
+
displayName: z.string().trim().max(200).optional(),
|
|
43
|
+
/** Audit reason for metadata.headers, honored for the system identity only
|
|
44
|
+
* ("watch_supervisor", "oauth_refresh"); user callers get a role-derived reason. */
|
|
45
|
+
reason: z.string().min(1).max(100).optional(),
|
|
46
|
+
})
|
|
47
|
+
.strict();
|
|
39
48
|
type UpdateAccountPayload = z.infer<typeof updateAccountSchema>;
|
|
40
49
|
|
|
41
50
|
const SYSTEM_FALLBACK_REASON = "system";
|
|
42
51
|
|
|
43
|
-
function auditReasonFor(user: SessionUser, requestedReason: string | undefined): string {
|
|
52
|
+
export function auditReasonFor(user: SessionUser, requestedReason: string | undefined): string {
|
|
44
53
|
if (user.id === SYSTEM_USER_ID) return requestedReason ?? SYSTEM_FALLBACK_REASON;
|
|
45
54
|
return user.roles.includes(ROLES.SystemAdmin) ? "system_admin" : "tenant_admin";
|
|
46
55
|
}
|
|
47
56
|
|
|
48
|
-
export
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
+
export function createUpdateAccountHandler({ adminRoles }: InboundMailRoles): WriteHandlerDef {
|
|
58
|
+
return {
|
|
59
|
+
name: "update-account",
|
|
60
|
+
schema: updateAccountSchema,
|
|
61
|
+
access: { roles: accessRolesOf(InboundMailFoundationHandlers.updateAccount, adminRoles) },
|
|
62
|
+
description:
|
|
63
|
+
"Renames a connected mailbox (display name) as a new snapshot with an audit reason, rejecting any change to an already disconnected mailbox; status, error and watch state are reserved for the system identity.",
|
|
64
|
+
handler: async (event, ctx) => {
|
|
65
|
+
// @cast-boundary engine-payload — dispatcher-zod-validated payload
|
|
66
|
+
const payload = event.payload as UpdateAccountPayload;
|
|
67
|
+
|
|
68
|
+
// Status, error and watch state are machine signals of the supervisor and the OAuth refresh; an admin
|
|
69
|
+
// setting them could hide an auth error or fake an active mailbox.
|
|
70
|
+
const isSystemOnlyField =
|
|
71
|
+
payload.status !== undefined ||
|
|
72
|
+
payload.lastError !== undefined ||
|
|
73
|
+
payload.watchState !== undefined ||
|
|
74
|
+
payload.reason !== undefined;
|
|
75
|
+
if (isSystemOnlyField && event.user.id !== SYSTEM_USER_ID) {
|
|
76
|
+
return failUnprocessable("field_requires_system_identity");
|
|
77
|
+
}
|
|
57
78
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
79
|
+
const current = await loadCurrentMailAccountPayload(ctx, payload.accountId);
|
|
80
|
+
if (!current) {
|
|
81
|
+
return failNotFound("mail-account", payload.accountId);
|
|
82
|
+
}
|
|
83
|
+
if (current.status === InboundMailAccountStatuses.disconnected) {
|
|
84
|
+
// Only reactivate-account may leave disconnected. This prevents a delayed
|
|
85
|
+
// supervisor tick from restoring a disconnected account to active
|
|
86
|
+
// (disconnect vs. poll race).
|
|
87
|
+
return failUnprocessable("account_disconnected", { accountId: payload.accountId });
|
|
88
|
+
}
|
|
69
89
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
90
|
+
// address/provider/authMethod come unchanged from the latest snapshot
|
|
91
|
+
// (address remains ciphertext—never re-encrypt it).
|
|
92
|
+
const eventPayload: MailAccountEventPayload = {
|
|
93
|
+
...current,
|
|
94
|
+
...(payload.status !== undefined ? { status: payload.status } : {}),
|
|
95
|
+
...(payload.watchState !== undefined ? { watchState: payload.watchState } : {}),
|
|
96
|
+
...(payload.displayName !== undefined ? { displayName: payload.displayName } : {}),
|
|
97
|
+
...(payload.lastError !== undefined ? { lastError: payload.lastError } : {}),
|
|
98
|
+
};
|
|
99
|
+
const headers: MailAccountEventHeaders = {
|
|
100
|
+
providerName: current.provider,
|
|
101
|
+
reason: auditReasonFor(event.user, payload.reason),
|
|
102
|
+
};
|
|
103
|
+
await ctx.events.unsafeAppend({
|
|
104
|
+
aggregateId: payload.accountId,
|
|
105
|
+
aggregateType: MAIL_ACCOUNT_AGGREGATE_TYPE,
|
|
106
|
+
type: MAIL_ACCOUNT_UPDATED_EVENT_QN,
|
|
107
|
+
payload: eventPayload,
|
|
108
|
+
headers,
|
|
109
|
+
});
|
|
89
110
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
};
|
|
111
|
+
return { isSuccess: true as const, data: { accountId: payload.accountId } };
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|