@cosmicdrift/kumiko-samples 0.393.0 → 0.394.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/packages/bundled-features/package.json +15 -5
- package/packages/bundled-features/src/account-security/README.md +1 -1
- package/packages/bundled-features/src/account-security/changes.json +6 -0
- package/packages/bundled-features/src/account-security/feature.ts +9 -1
- package/packages/bundled-features/src/audit/event-classification.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/changes.json +6 -0
- package/packages/bundled-features/src/auth-email-password/web/auth-client.ts +10 -52
- package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +4 -4
- package/packages/bundled-features/src/auth-email-password/web/auth-redirect.ts +3 -12
- package/packages/bundled-features/src/auth-email-password/web/index.ts +2 -0
- package/packages/bundled-features/src/auth-email-password/web/login-failure.ts +54 -0
- package/packages/bundled-features/src/auth-email-password/web/login-url.ts +19 -0
- package/packages/bundled-features/src/auth-email-password/web/static-login.ts +183 -0
- package/packages/bundled-features/src/auth-mfa/web/mfa-client.ts +7 -63
- package/packages/bundled-features/src/billing-foundation/changes.json +30 -0
- package/packages/bundled-features/src/billing-foundation/checkout-core.ts +8 -16
- package/packages/bundled-features/src/billing-foundation/events.ts +2 -0
- package/packages/bundled-features/src/billing-foundation/feature.ts +1 -1
- package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +9 -2
- package/packages/bundled-features/src/billing-foundation/handlers/process-payment-event.write.ts +2 -0
- package/packages/bundled-features/src/billing-foundation/index.ts +2 -0
- package/packages/bundled-features/src/billing-foundation/one-off-checkout-gate.ts +44 -0
- package/packages/bundled-features/src/billing-foundation/one-off-customer.ts +17 -0
- package/packages/bundled-features/src/billing-foundation/plan-catalog.ts +1 -0
- package/packages/bundled-features/src/billing-foundation/types.ts +26 -0
- package/packages/bundled-features/src/billing-foundation/web/billing-plans-panel.tsx +1 -0
- package/packages/bundled-features/src/billing-foundation/web/plan-switch-confirm-dialog.tsx +8 -2
- package/packages/bundled-features/src/billing-foundation/web/plan-switch-recurring.ts +23 -0
- package/packages/bundled-features/src/billing-foundation/webhook-handler.ts +1 -0
- package/packages/bundled-features/src/cap-overview/changes.json +6 -0
- package/packages/bundled-features/src/cap-overview/handlers/caps-usage.query.ts +3 -3
- package/packages/bundled-features/src/cap-overview/types.ts +2 -0
- package/packages/bundled-features/src/compliance-profiles/web/compliance-profile-catalog.tsx +10 -1
- package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +12 -3
- package/packages/bundled-features/src/files/changes.json +18 -0
- package/packages/bundled-features/src/inbound-mail-foundation/account-health.ts +146 -0
- package/packages/bundled-features/src/inbound-mail-foundation/account-poll-schedule.ts +74 -0
- package/packages/bundled-features/src/inbound-mail-foundation/account-poll.ts +238 -0
- package/packages/bundled-features/src/inbound-mail-foundation/account-record.ts +2 -0
- package/packages/bundled-features/src/inbound-mail-foundation/account-wake.ts +21 -0
- package/packages/bundled-features/src/inbound-mail-foundation/changes.json +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 +30 -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 +75 -49
- 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-inmemory/feature.ts +75 -3
- package/packages/bundled-features/src/inbound-provider-inmemory/index.ts +6 -0
- package/packages/bundled-features/src/jobs/changes.json +18 -0
- package/packages/bundled-features/src/managed-pages/web/i18n.ts +1 -0
- package/packages/bundled-features/src/subscription-paddle/changes.json +15 -0
- package/packages/bundled-features/src/subscription-paddle/checkout-config.query.ts +17 -0
- package/packages/bundled-features/src/subscription-paddle/checkout-methods.ts +107 -0
- package/packages/bundled-features/src/subscription-paddle/checkout-page.ts +154 -0
- package/packages/bundled-features/src/subscription-paddle/client.ts +78 -0
- package/packages/bundled-features/src/subscription-paddle/constants.ts +32 -0
- package/packages/bundled-features/src/subscription-paddle/feature.ts +265 -0
- package/packages/bundled-features/src/subscription-paddle/index.ts +17 -0
- package/packages/bundled-features/src/subscription-paddle/plan-switch-methods.ts +135 -0
- package/packages/bundled-features/src/subscription-paddle/price-methods.ts +148 -0
- package/packages/bundled-features/src/subscription-paddle/runtime.ts +203 -0
- package/packages/bundled-features/src/subscription-paddle/subscription-state.ts +73 -0
- package/packages/bundled-features/src/subscription-paddle/tenant-binding.ts +34 -0
- package/packages/bundled-features/src/subscription-paddle/verify-webhook.ts +61 -0
- package/packages/bundled-features/src/subscription-paddle/webhook-events.ts +172 -0
- package/packages/bundled-features/src/template-resolver/changes.json +12 -0
- package/packages/bundled-features/src/template-resolver/handlers/collection-item.query.ts +2 -2
- package/packages/bundled-features/src/template-resolver/handlers/collection-list.query.ts +6 -2
- package/packages/bundled-features/src/template-resolver/handlers/collection-shared.ts +25 -0
- package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +218 -117
- package/packages/bundled-features/src/template-resolver/web/i18n.ts +1 -2
- package/packages/bundled-features/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +2 -1
- package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-constants.ts +4 -0
- package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +11 -5
- package/samples/apps/use-all-bundled/e2e-billing-gallery/server.ts +2 -2
- package/samples/apps/use-all-bundled/feature-manifest.json +87 -38
- package/samples/apps/use-all-bundled/kumiko/migrations/0056_inbound-mail-quarantine-retry.rebuild.json +4 -0
- package/samples/apps/use-all-bundled/package.json +1 -0
- package/samples/apps/use-all-bundled/playwright.billing-docs.config.ts +15 -0
- package/samples/apps/use-all-bundled/src/app/cap-overview-caps.ts +16 -2
- package/samples/apps/use-all-bundled/src/app/cap-overview-labels-feature.ts +2 -1
- package/samples/apps/use-all-bundled/src/app/client.tsx +2 -0
- package/samples/apps/use-all-bundled/src/app/screens-feature.ts +14 -2
- package/samples/apps/use-all-bundled/src/app/seed.ts +4 -1
- package/samples/apps/use-all-bundled/src/run-config.ts +7 -5
- package/samples/recipes/cross-feature-events/README.md +10 -0
- package/samples/recipes/cross-feature-events/src/feature.ts +14 -0
- package/samples/recipes/dashboard-stats/README.md +8 -0
- package/samples/recipes/dashboard-stats/src/feature.ts +91 -1
- package/samples/recipes/env-composition/README.md +2 -2
- package/samples/recipes/env-composition/src/env.ts +2 -2
- package/samples/recipes/inbound-mail-triage/README.md +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,146 @@
|
|
|
1
|
+
// Account health: maps what the supervisor observes (repeated poll failures, quarantined mails, file
|
|
2
|
+
// storage, KMS or ingest outage) onto the account status "degraded" plus a lastError code, and back to "active" once
|
|
3
|
+
// nothing is wrong any more. Auth errors and disconnects are never touched here. An update is
|
|
4
|
+
// dispatched only when status or lastError really changes, so a healthy mailbox causes no events.
|
|
5
|
+
|
|
6
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
7
|
+
import {
|
|
8
|
+
type InboundMailAccountStatus,
|
|
9
|
+
InboundMailAccountStatuses,
|
|
10
|
+
InboundMailFoundationHandlers,
|
|
11
|
+
type InboundMailLastError,
|
|
12
|
+
InboundMailLastErrors,
|
|
13
|
+
} from "./constants.ts";
|
|
14
|
+
import { hasOpenQuarantine } from "./quarantine.ts";
|
|
15
|
+
import type {
|
|
16
|
+
DispatchWrite,
|
|
17
|
+
IngestBatchOutcome,
|
|
18
|
+
IngestBlockedReason,
|
|
19
|
+
} from "./supervisor-ingest.ts";
|
|
20
|
+
import type { AccountHealthRef, SyncableAccount } from "./syncable-accounts.ts";
|
|
21
|
+
|
|
22
|
+
const FAILURES_BEFORE_DEGRADED = 3;
|
|
23
|
+
const OUTAGE_LAST_ERRORS: ReadonlySet<string> = new Set([
|
|
24
|
+
InboundMailLastErrors.encryptionUnavailable,
|
|
25
|
+
InboundMailLastErrors.ingestUnavailable,
|
|
26
|
+
InboundMailLastErrors.fileStorageUnavailable,
|
|
27
|
+
]);
|
|
28
|
+
const SUPERVISOR_AUDIT_REASON = "watch_supervisor";
|
|
29
|
+
|
|
30
|
+
export type PollFailureKind = "transient" | "rate_limited";
|
|
31
|
+
|
|
32
|
+
type AppliedHealth = {
|
|
33
|
+
readonly status: InboundMailAccountStatus;
|
|
34
|
+
readonly lastError: string | null;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type AccountHealth = {
|
|
38
|
+
readonly onPollSuccess: (
|
|
39
|
+
account: SyncableAccount,
|
|
40
|
+
outcome: Pick<IngestBatchOutcome, "storageUnavailable" | "storageHealthy">,
|
|
41
|
+
) => Promise<void>;
|
|
42
|
+
readonly onPollFailure: (account: SyncableAccount, kind: PollFailureKind) => Promise<void>;
|
|
43
|
+
/** The cursor stayed before a failing mail: either the mail waits for its count or the ingest is down. */
|
|
44
|
+
readonly onIngestBlocked: (
|
|
45
|
+
account: SyncableAccount,
|
|
46
|
+
blocked: IngestBlockedReason,
|
|
47
|
+
outcome: Pick<IngestBatchOutcome, "storageHealthy">,
|
|
48
|
+
) => Promise<void>;
|
|
49
|
+
/** The address did not decrypt because the key store is down, so the account could not be polled. */
|
|
50
|
+
readonly onKeyStoreUnavailable: (account: AccountHealthRef) => Promise<void>;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export function createAccountHealth(deps: {
|
|
54
|
+
readonly db: DbConnection;
|
|
55
|
+
readonly dispatchWrite: DispatchWrite;
|
|
56
|
+
readonly log: (line: string) => void;
|
|
57
|
+
}): AccountHealth {
|
|
58
|
+
const consecutiveFailures = new Map<string, number>();
|
|
59
|
+
const storageUnavailable = new Set<string>();
|
|
60
|
+
// The account snapshot of a tick is stale after our own update; this keeps the "real change" check honest.
|
|
61
|
+
// Per process: a drift between workers costs at most one redundant update-account.
|
|
62
|
+
const applied = new Map<string, AppliedHealth>();
|
|
63
|
+
|
|
64
|
+
async function apply(account: AccountHealthRef, lastError: InboundMailLastError | null) {
|
|
65
|
+
const known = applied.get(account.id) ?? {
|
|
66
|
+
status: account.status,
|
|
67
|
+
lastError: account.lastError,
|
|
68
|
+
};
|
|
69
|
+
const status =
|
|
70
|
+
lastError === null ? InboundMailAccountStatuses.active : InboundMailAccountStatuses.degraded;
|
|
71
|
+
// skip: the stored health already matches, nothing to write.
|
|
72
|
+
if (known.status === status && known.lastError === lastError) return;
|
|
73
|
+
const result = await deps.dispatchWrite({
|
|
74
|
+
handlerQn: InboundMailFoundationHandlers.updateAccount,
|
|
75
|
+
tenantId: account.tenantId,
|
|
76
|
+
payload: { accountId: account.id, status, lastError, reason: SUPERVISOR_AUDIT_REASON },
|
|
77
|
+
});
|
|
78
|
+
if (!result.isSuccess) {
|
|
79
|
+
deps.log(
|
|
80
|
+
`inbound-mail: account ${account.id} health update was rejected (${rejectionCode(result.error)})`,
|
|
81
|
+
);
|
|
82
|
+
// skip: not recorded as applied, so the next tick retries the update.
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
applied.set(account.id, { status, lastError });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function lastErrorWithoutOutage(account: AccountHealthRef) {
|
|
89
|
+
if (await hasOpenQuarantine(deps.db, account.id)) {
|
|
90
|
+
return InboundMailLastErrors.messageQuarantined;
|
|
91
|
+
}
|
|
92
|
+
if (storageUnavailable.has(account.id)) return InboundMailLastErrors.fileStorageUnavailable;
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
async onPollSuccess(account, outcome) {
|
|
98
|
+
consecutiveFailures.delete(account.id);
|
|
99
|
+
if (outcome.storageUnavailable) storageUnavailable.add(account.id);
|
|
100
|
+
else if (outcome.storageHealthy) storageUnavailable.delete(account.id);
|
|
101
|
+
return apply(account, await lastErrorWithoutOutage(account));
|
|
102
|
+
},
|
|
103
|
+
async onIngestBlocked(account, blocked, outcome) {
|
|
104
|
+
consecutiveFailures.delete(account.id);
|
|
105
|
+
if (blocked === "message_failing") {
|
|
106
|
+
deps.log(`inbound-mail: account ${account.id} waits for a failing message to be counted`);
|
|
107
|
+
if (outcome.storageHealthy) storageUnavailable.delete(account.id);
|
|
108
|
+
// A counted failure proves the ingest works again, so a shown outage is over; anything else stays.
|
|
109
|
+
const shown = (applied.get(account.id) ?? account).lastError;
|
|
110
|
+
// skip: no outage is shown, the status stays as it is
|
|
111
|
+
if (shown === null || !OUTAGE_LAST_ERRORS.has(shown)) return;
|
|
112
|
+
return apply(account, await lastErrorWithoutOutage(account));
|
|
113
|
+
}
|
|
114
|
+
// KMS and ingest outages need no memory like storage: the next unblocked poll proves they are over.
|
|
115
|
+
if (blocked === "encryption_unavailable") {
|
|
116
|
+
return apply(account, InboundMailLastErrors.encryptionUnavailable);
|
|
117
|
+
}
|
|
118
|
+
if (blocked === "ingest_unavailable") {
|
|
119
|
+
return apply(account, InboundMailLastErrors.ingestUnavailable);
|
|
120
|
+
}
|
|
121
|
+
storageUnavailable.add(account.id);
|
|
122
|
+
return apply(account, InboundMailLastErrors.fileStorageUnavailable);
|
|
123
|
+
},
|
|
124
|
+
async onKeyStoreUnavailable(account) {
|
|
125
|
+
consecutiveFailures.delete(account.id);
|
|
126
|
+
return apply(account, InboundMailLastErrors.encryptionUnavailable);
|
|
127
|
+
},
|
|
128
|
+
async onPollFailure(account, kind) {
|
|
129
|
+
const count = (consecutiveFailures.get(account.id) ?? 0) + 1;
|
|
130
|
+
consecutiveFailures.set(account.id, count);
|
|
131
|
+
// skip: below the failure threshold the mailbox stays as it is.
|
|
132
|
+
if (count < FAILURES_BEFORE_DEGRADED) return;
|
|
133
|
+
return apply(
|
|
134
|
+
account,
|
|
135
|
+
kind === "rate_limited"
|
|
136
|
+
? InboundMailLastErrors.rateLimited
|
|
137
|
+
: InboundMailLastErrors.connectionFailed,
|
|
138
|
+
);
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function rejectionCode(error: unknown): string {
|
|
144
|
+
if (typeof error === "object" && error !== null && "code" in error) return String(error.code);
|
|
145
|
+
return "unknown";
|
|
146
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// How the supervisor tick spreads its accounts: a bounded number of syncs side by side, and a
|
|
2
|
+
// deadline after which a sync that never answers is abandoned instead of holding the tick.
|
|
3
|
+
|
|
4
|
+
import type { PollAccountOptions, PollAccountResult } from "./sync-account-now.ts";
|
|
5
|
+
import type { SyncableAccount } from "./syncable-accounts.ts";
|
|
6
|
+
import type { InboundMailProviderPlugin } from "./types.ts";
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_POLL_CONCURRENCY = 4;
|
|
9
|
+
export const DEFAULT_ACCOUNT_POLL_DEADLINE_MS = 10 * 60 * 1000;
|
|
10
|
+
|
|
11
|
+
export function pollScheduleOf(options: {
|
|
12
|
+
readonly pollConcurrency?: number;
|
|
13
|
+
readonly accountPollDeadlineMs?: number;
|
|
14
|
+
}): { readonly pollConcurrency: number; readonly accountPollDeadlineMs: number } {
|
|
15
|
+
return {
|
|
16
|
+
pollConcurrency: options.pollConcurrency ?? DEFAULT_POLL_CONCURRENCY,
|
|
17
|
+
accountPollDeadlineMs: options.accountPollDeadlineMs ?? DEFAULT_ACCOUNT_POLL_DEADLINE_MS,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type DeadlineOutcome<T> =
|
|
22
|
+
| { readonly timedOut: false; readonly value: T }
|
|
23
|
+
| { readonly timedOut: true };
|
|
24
|
+
|
|
25
|
+
/** Aborts the signal when the deadline passes. The abandoned work may still settle later; its
|
|
26
|
+
* outcome is dropped and a late rejection is swallowed. */
|
|
27
|
+
async function runWithDeadline<T>(
|
|
28
|
+
deadlineMs: number,
|
|
29
|
+
run: (signal: AbortSignal) => Promise<T>,
|
|
30
|
+
): Promise<DeadlineOutcome<T>> {
|
|
31
|
+
const controller = new AbortController();
|
|
32
|
+
const work = run(controller.signal);
|
|
33
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
34
|
+
const deadline = new Promise<DeadlineOutcome<T>>((resolve) => {
|
|
35
|
+
timer = setTimeout(() => resolve({ timedOut: true }), deadlineMs);
|
|
36
|
+
});
|
|
37
|
+
try {
|
|
38
|
+
const outcome = await Promise.race([
|
|
39
|
+
work.then((value): DeadlineOutcome<T> => ({ timedOut: false, value })),
|
|
40
|
+
deadline,
|
|
41
|
+
]);
|
|
42
|
+
if (outcome.timedOut) {
|
|
43
|
+
controller.abort();
|
|
44
|
+
work.catch(() => {});
|
|
45
|
+
}
|
|
46
|
+
return outcome;
|
|
47
|
+
} finally {
|
|
48
|
+
clearTimeout(timer);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type PollAccount = (
|
|
53
|
+
account: SyncableAccount,
|
|
54
|
+
plugin: InboundMailProviderPlugin,
|
|
55
|
+
options: PollAccountOptions,
|
|
56
|
+
) => Promise<PollAccountResult>;
|
|
57
|
+
|
|
58
|
+
/** A sync past the deadline counts as one failed poll and its in-flight slot is freed. */
|
|
59
|
+
export function withPollDeadline(
|
|
60
|
+
pollAccount: PollAccount,
|
|
61
|
+
deadline: {
|
|
62
|
+
readonly ms: number;
|
|
63
|
+
readonly onDeadline: (account: SyncableAccount) => Promise<void>;
|
|
64
|
+
},
|
|
65
|
+
): PollAccount {
|
|
66
|
+
return async (account, plugin, options) => {
|
|
67
|
+
const outcome = await runWithDeadline(deadline.ms, (signal) =>
|
|
68
|
+
pollAccount(account, plugin, { ...options, signal }),
|
|
69
|
+
);
|
|
70
|
+
if (!outcome.timedOut) return outcome.value;
|
|
71
|
+
await deadline.onDeadline(account);
|
|
72
|
+
return "done";
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
+
import type { AccountHealth, PollFailureKind } from "./account-health.ts";
|
|
4
|
+
import { refetchSnippetOnlyMessages } from "./content-refetch.ts";
|
|
5
|
+
import { describeError } from "./describe-error.ts";
|
|
6
|
+
import { seenMessageTable } from "./entities.ts";
|
|
7
|
+
import {
|
|
8
|
+
isInboundAuthError,
|
|
9
|
+
isInboundCredentialNotStoredError,
|
|
10
|
+
isInboundCursorInvalidError,
|
|
11
|
+
isInboundRateLimitError,
|
|
12
|
+
} from "./provider-errors.ts";
|
|
13
|
+
import { quarantinedMessageIds } from "./quarantine.ts";
|
|
14
|
+
import { retryQuarantinedMessages } from "./quarantine-retry.ts";
|
|
15
|
+
import type {
|
|
16
|
+
DispatchWrite,
|
|
17
|
+
IngestBatchOutcome,
|
|
18
|
+
IngestBlockedReason,
|
|
19
|
+
MessageIngester,
|
|
20
|
+
} from "./supervisor-ingest.ts";
|
|
21
|
+
import type { PollAccountOptions, PollAccountResult } from "./sync-account-now.ts";
|
|
22
|
+
import type { SyncCursorStore } from "./sync-cursor-store.ts";
|
|
23
|
+
import type { SyncableAccount } from "./syncable-accounts.ts";
|
|
24
|
+
import type { InboundMailContext, InboundMailProviderPlugin, MailAccountRecord } from "./types.ts";
|
|
25
|
+
|
|
26
|
+
const SEEN_LOOKUP_CHUNK_SIZE = 1000;
|
|
27
|
+
|
|
28
|
+
export type AccountPollerDeps = {
|
|
29
|
+
readonly systemDb: DbConnection;
|
|
30
|
+
readonly cursors: SyncCursorStore;
|
|
31
|
+
readonly ingester: MessageIngester;
|
|
32
|
+
readonly health: AccountHealth;
|
|
33
|
+
readonly providerCtx: InboundMailContext;
|
|
34
|
+
readonly dispatchWrite: DispatchWrite;
|
|
35
|
+
readonly backfillWindowDays: number;
|
|
36
|
+
readonly maxMessagesPerPoll: number;
|
|
37
|
+
readonly resolveAccessToken: (
|
|
38
|
+
account: MailAccountRecord,
|
|
39
|
+
plugin: InboundMailProviderPlugin,
|
|
40
|
+
) => Promise<string | undefined>;
|
|
41
|
+
/** Returns true when the account may keep running. */
|
|
42
|
+
readonly handleSyncError: (account: MailAccountRecord, err: unknown) => Promise<boolean>;
|
|
43
|
+
readonly log: (line: string) => void;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export function createAccountPoller(deps: AccountPollerDeps): {
|
|
47
|
+
readonly pollAccount: (
|
|
48
|
+
account: SyncableAccount,
|
|
49
|
+
plugin: InboundMailProviderPlugin,
|
|
50
|
+
options?: PollAccountOptions,
|
|
51
|
+
) => Promise<PollAccountResult>;
|
|
52
|
+
} {
|
|
53
|
+
const {
|
|
54
|
+
systemDb,
|
|
55
|
+
cursors,
|
|
56
|
+
ingester,
|
|
57
|
+
health,
|
|
58
|
+
providerCtx,
|
|
59
|
+
backfillWindowDays,
|
|
60
|
+
maxMessagesPerPoll,
|
|
61
|
+
resolveAccessToken,
|
|
62
|
+
handleSyncError,
|
|
63
|
+
log,
|
|
64
|
+
} = deps;
|
|
65
|
+
|
|
66
|
+
function unknownMessageIdsFilterFor(account: MailAccountRecord) {
|
|
67
|
+
return async (providerMessageIds: readonly string[]): Promise<ReadonlySet<string>> => {
|
|
68
|
+
if (providerMessageIds.length === 0) return new Set();
|
|
69
|
+
// One bind parameter per id: a full-mailbox listing would exceed Postgres' 65535 limit.
|
|
70
|
+
const seenIds = new Set<string>();
|
|
71
|
+
for (let start = 0; start < providerMessageIds.length; start += SEEN_LOOKUP_CHUNK_SIZE) {
|
|
72
|
+
const seen = await selectMany<{ providerMessageId: string }>(
|
|
73
|
+
systemDb,
|
|
74
|
+
seenMessageTable,
|
|
75
|
+
{
|
|
76
|
+
tenantId: account.tenantId,
|
|
77
|
+
accountId: account.id,
|
|
78
|
+
providerMessageId: {
|
|
79
|
+
in: providerMessageIds.slice(start, start + SEEN_LOOKUP_CHUNK_SIZE),
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
{ fields: ["providerMessageId"] },
|
|
83
|
+
);
|
|
84
|
+
for (const row of seen) seenIds.add(row.providerMessageId);
|
|
85
|
+
}
|
|
86
|
+
// A quarantined message counts as seen: re-fetching it would only fail the same way again.
|
|
87
|
+
for (const id of await quarantinedMessageIds(systemDb, account.id, providerMessageIds)) {
|
|
88
|
+
seenIds.add(id);
|
|
89
|
+
}
|
|
90
|
+
return new Set(providerMessageIds.filter((id) => !seenIds.has(id)));
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function pollFailureKindOf(err: unknown): PollFailureKind | null {
|
|
95
|
+
if (isInboundAuthError(err) || isInboundCursorInvalidError(err)) return null;
|
|
96
|
+
return isInboundRateLimitError(err) ? "rate_limited" : "transient";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function refetchContentSafely(
|
|
100
|
+
account: SyncableAccount,
|
|
101
|
+
plugin: InboundMailProviderPlugin,
|
|
102
|
+
signal: AbortSignal | undefined,
|
|
103
|
+
): Promise<void> {
|
|
104
|
+
// skip: the provider cannot reload content.
|
|
105
|
+
if (!plugin.fetchContent) return;
|
|
106
|
+
try {
|
|
107
|
+
const accessToken = await resolveAccessToken(account, plugin);
|
|
108
|
+
await refetchSnippetOnlyMessages({
|
|
109
|
+
db: systemDb,
|
|
110
|
+
dispatchWrite: deps.dispatchWrite,
|
|
111
|
+
providerCtx,
|
|
112
|
+
account,
|
|
113
|
+
plugin,
|
|
114
|
+
windowDays: backfillWindowDays,
|
|
115
|
+
accessToken,
|
|
116
|
+
signal,
|
|
117
|
+
});
|
|
118
|
+
} catch (err) {
|
|
119
|
+
// Best effort: the sync itself succeeded, the next tick retries the reload.
|
|
120
|
+
log(`inbound-mail: content reload for account ${account.id} failed: ${describeError(err)}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function retryQuarantineSafely(
|
|
125
|
+
account: SyncableAccount,
|
|
126
|
+
plugin: InboundMailProviderPlugin,
|
|
127
|
+
signal: AbortSignal | undefined,
|
|
128
|
+
): Promise<void> {
|
|
129
|
+
try {
|
|
130
|
+
const accessToken = await resolveAccessToken(account, plugin);
|
|
131
|
+
await retryQuarantinedMessages({
|
|
132
|
+
db: systemDb,
|
|
133
|
+
dispatchWrite: deps.dispatchWrite,
|
|
134
|
+
providerCtx,
|
|
135
|
+
account,
|
|
136
|
+
plugin,
|
|
137
|
+
accessToken,
|
|
138
|
+
signal,
|
|
139
|
+
log,
|
|
140
|
+
});
|
|
141
|
+
} catch (err) {
|
|
142
|
+
// Best effort: the sync itself succeeded, the flag stays and the next tick retries.
|
|
143
|
+
log(`inbound-mail: quarantine retry for account ${account.id} failed: ${describeError(err)}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Fetches and ingests batch after batch until the provider has no more or the message budget of
|
|
148
|
+
// this tick is used up. An abandoned sync (deadline) throws out of the loop.
|
|
149
|
+
async function ingestBatches(
|
|
150
|
+
account: SyncableAccount,
|
|
151
|
+
plugin: InboundMailProviderPlugin,
|
|
152
|
+
options: PollAccountOptions,
|
|
153
|
+
): Promise<{
|
|
154
|
+
storageUnavailable: boolean;
|
|
155
|
+
storageHealthy: boolean;
|
|
156
|
+
ingested: boolean;
|
|
157
|
+
blocked?: IngestBlockedReason;
|
|
158
|
+
}> {
|
|
159
|
+
const { signal } = options;
|
|
160
|
+
let cursor = await cursors.load(account.id);
|
|
161
|
+
let budget = maxMessagesPerPoll;
|
|
162
|
+
const totals = { storageUnavailable: false, storageHealthy: false, ingested: false };
|
|
163
|
+
for (;;) {
|
|
164
|
+
const accessToken = await resolveAccessToken(account, plugin);
|
|
165
|
+
const result = await plugin.fetch(providerCtx, account, cursor, {
|
|
166
|
+
backfillWindowDays,
|
|
167
|
+
maxMessages: budget,
|
|
168
|
+
filterUnknownMessageIds: unknownMessageIdsFilterFor(account),
|
|
169
|
+
...(accessToken !== undefined && { accessToken }),
|
|
170
|
+
...(signal && { signal }),
|
|
171
|
+
});
|
|
172
|
+
// The sync was abandoned while the provider was busy; its late result is dropped.
|
|
173
|
+
signal?.throwIfAborted();
|
|
174
|
+
const outcome: IngestBatchOutcome = await ingester.ingestBatch(
|
|
175
|
+
account,
|
|
176
|
+
result.messages,
|
|
177
|
+
result.rejected ?? [],
|
|
178
|
+
JSON.stringify(result.nextCursor),
|
|
179
|
+
{
|
|
180
|
+
...(options.countBucket !== undefined && { countBucket: options.countBucket }),
|
|
181
|
+
...(signal && { signal }),
|
|
182
|
+
proof: { ingested: totals.ingested, storageHealthy: totals.storageHealthy },
|
|
183
|
+
},
|
|
184
|
+
);
|
|
185
|
+
totals.storageUnavailable ||= outcome.storageUnavailable;
|
|
186
|
+
totals.storageHealthy ||= outcome.storageHealthy;
|
|
187
|
+
totals.ingested ||= outcome.ingested;
|
|
188
|
+
// Blocked: the cursor stays before the stuck mail, the next tick fetches it again.
|
|
189
|
+
if (outcome.blocked) return { ...totals, blocked: outcome.blocked };
|
|
190
|
+
// A newer poll may already own the account; an abandoned one must not move its cursor back.
|
|
191
|
+
signal?.throwIfAborted();
|
|
192
|
+
await cursors.save(account, result.nextCursor);
|
|
193
|
+
cursor = result.nextCursor;
|
|
194
|
+
budget -= result.messages.length;
|
|
195
|
+
if (!result.hasMore || budget <= 0) return totals;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function pollAccount(
|
|
200
|
+
account: SyncableAccount,
|
|
201
|
+
plugin: InboundMailProviderPlugin,
|
|
202
|
+
options: PollAccountOptions = {},
|
|
203
|
+
): Promise<PollAccountResult> {
|
|
204
|
+
let totals: Awaited<ReturnType<typeof ingestBatches>>;
|
|
205
|
+
try {
|
|
206
|
+
totals = await ingestBatches(account, plugin, options);
|
|
207
|
+
} catch (err) {
|
|
208
|
+
// skip: an error caused by abandoning the sync is already counted as the deadline failure.
|
|
209
|
+
if (options.signal?.aborted) return "done";
|
|
210
|
+
if (options.fromWake && isInboundCredentialNotStoredError(err)) {
|
|
211
|
+
// The secret is written after the account is created; the next tick picks it up.
|
|
212
|
+
log(`inbound-mail: account ${account.id} credential not stored yet, wake sync skipped`);
|
|
213
|
+
return "credential_pending";
|
|
214
|
+
}
|
|
215
|
+
const stillRunning = await handleSyncError(account, err);
|
|
216
|
+
const failureKind = pollFailureKindOf(err);
|
|
217
|
+
if (stillRunning && failureKind) await health.onPollFailure(account, failureKind);
|
|
218
|
+
return "done";
|
|
219
|
+
}
|
|
220
|
+
// skip: abandoned during the last batch; the deadline already counted this poll as failed.
|
|
221
|
+
if (options.signal?.aborted) return "done";
|
|
222
|
+
if (totals.blocked) {
|
|
223
|
+
await health.onIngestBlocked(account, totals.blocked, totals);
|
|
224
|
+
// A retry asked for by an admin does not depend on the cursor, so a stuck mail must not hold it.
|
|
225
|
+
// During an outage it would fail the same way; the flag stays and runs after the recovery.
|
|
226
|
+
if (totals.blocked === "message_failing") {
|
|
227
|
+
await retryQuarantineSafely(account, plugin, options.signal);
|
|
228
|
+
}
|
|
229
|
+
return "done";
|
|
230
|
+
}
|
|
231
|
+
await health.onPollSuccess(account, totals);
|
|
232
|
+
await retryQuarantineSafely(account, plugin, options.signal);
|
|
233
|
+
await refetchContentSafely(account, plugin, options.signal);
|
|
234
|
+
return "done";
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return { pollAccount };
|
|
238
|
+
}
|
|
@@ -16,6 +16,7 @@ export type MailAccountRow = {
|
|
|
16
16
|
readonly displayName?: string | null;
|
|
17
17
|
readonly status?: string | null;
|
|
18
18
|
readonly watchState?: string | null;
|
|
19
|
+
readonly trustedAuthservId?: string | null;
|
|
19
20
|
};
|
|
20
21
|
|
|
21
22
|
function isAccountStatus(value: unknown): value is InboundMailAccountStatus {
|
|
@@ -44,5 +45,6 @@ export async function toMailAccountRecord(
|
|
|
44
45
|
displayName: row.displayName ?? "",
|
|
45
46
|
status: isAccountStatus(row.status) ? row.status : InboundMailAccountStatuses.active,
|
|
46
47
|
watchState: row.watchState ?? "idle",
|
|
48
|
+
trustedAuthservId: row.trustedAuthservId ?? null,
|
|
47
49
|
};
|
|
48
50
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Process-local wake channel between the account-event consumer (feature.ts) and the sync supervisor.
|
|
2
|
+
// A freshly connected, reconnected or re-activated mailbox is synced right away instead of waiting
|
|
3
|
+
// for the next poll tick. The consumer runs "per-instance", so every process wakes its own supervisor.
|
|
4
|
+
//
|
|
5
|
+
// Without a running event dispatcher in the process (or without a supervisor) nothing wakes anyone;
|
|
6
|
+
// the regular poll tick then picks the account up, so this is purely a latency optimisation.
|
|
7
|
+
|
|
8
|
+
export type MailAccountWakeListener = (tenantId: string, accountId: string) => void;
|
|
9
|
+
|
|
10
|
+
const listeners = new Set<MailAccountWakeListener>();
|
|
11
|
+
|
|
12
|
+
export function registerMailAccountWakeListener(listener: MailAccountWakeListener): () => void {
|
|
13
|
+
listeners.add(listener);
|
|
14
|
+
return () => {
|
|
15
|
+
listeners.delete(listener);
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function wakeMailAccount(tenantId: string, accountId: string): void {
|
|
20
|
+
for (const listener of listeners) listener(tenantId, accountId);
|
|
21
|
+
}
|
|
@@ -1,4 +1,147 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.394.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Imported mails store full text, original and attachments",
|
|
6
|
+
"detail": "ingest-message now takes bodyText (at most 32,000 characters, cut with bodyTextTruncated set; 32,000 keeps a long mail readable while one event, one projection row and the PII-encrypted column stay small, the original keeps everything), rawMime, bodyHtml, attachments (at most 50) and skippedAttachments (at most 200). The original is stored as .eml (or .html for HTML-only mails) and each attachment as a file reference through file storage; apps without a file provider still import the mail, mark contentState unavailable and show the mailbox as interrupted. The event inbound-message-received is now v2 with an upcast that defaults the new fields; bodyText is personal tenant data. The new query inbound-mail-foundation:query:message:content returns text, original reference and attachments of one mail; message:list never carries the body. Retention removes the mail row, its body object and its attachment files. RawInboundMessage gains text, html, attachments and skippedAttachments."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.394.0",
|
|
10
|
+
"type": "improvement",
|
|
11
|
+
"title": "A mail that cannot be parsed is quarantined instead of blocking the mailbox",
|
|
12
|
+
"detail": "A mail the provider reports as rejected, or whose ingest fails, is normalized where possible and otherwise stored in the unmanaged table store_mail_quarantine; every message is isolated in the supervisor, so the rest of the batch imports and the sync cursor moves on. An ingest failure only counts against a mail once a later mail of the batch succeeded, and the third counted failure quarantines it as ingest_failed; an outage of the database, KMS or file storage never quarantines good mail and never moves the cursor past an unhandled mail. The account list shows quarantinedCount and lastError, the new handler acknowledge-quarantine confirms the entries, acknowledged entries are purged by retention after 90 days, and the tenant-destroy hook removes them."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.394.0",
|
|
16
|
+
"type": "improvement",
|
|
17
|
+
"title": "A mailbox that stops syncing shows as interrupted with a reason",
|
|
18
|
+
"detail": "The account events mail-account-connected and mail-account-updated are v2 with lastError (connection_failed, rate_limited, message_quarantined, file_storage_unavailable). Three failed polls in a row set the status degraded, shown as Unterbrochen in German; the next successful poll sets it back to active. New key in all locale packages."
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"version": "0.394.0",
|
|
22
|
+
"type": "improvement",
|
|
23
|
+
"title": "A disconnected mailbox is reactivated under the same account",
|
|
24
|
+
"detail": "The new handler reactivate-account puts a disconnected mailbox back to active with its stream and sync cursor, so no mail is imported twice. connect-account refuses an address that is already connected (address_already_connected) or disconnected (address_disconnected) and reopens a disconnected OAuth mailbox in place; connect-routes answers 409 with the reason. reconnect-mailbox now also accepts a disconnected account."
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"version": "0.394.0",
|
|
28
|
+
"type": "improvement",
|
|
29
|
+
"title": "Snippet-only mails get their content reloaded",
|
|
30
|
+
"detail": "Mails imported before this change carry only a snippet. The supervisor asks the provider for their content through fetchContent and stores it with attach-message-content (event inbound-message-content-fetched v1); a mail the server no longer has becomes unavailable and is not asked for again."
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"version": "0.394.0",
|
|
34
|
+
"type": "improvement",
|
|
35
|
+
"title": "Connected, reconnected and reactivated mailboxes sync immediately",
|
|
36
|
+
"detail": "A per-instance event consumer wakes the supervisor on a new or active-again mailbox, which syncs once right away instead of waiting for the next poll. A sync of one account never runs twice at the same time. A woken sync that finds the credential not stored yet (the OAuth secret is written after the account) sets no status and leaves it to the next tick; stop() waits for running syncs."
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"version": "0.394.0",
|
|
40
|
+
"type": "improvement",
|
|
41
|
+
"title": "The first sync after an OAuth connect no longer waits for the next poll",
|
|
42
|
+
"detail": "The OAuth callback now dispatches the internal handler mark-credential-stored once the refresh token is saved, so the sync that the account commit triggers earlier than the token is repeated as soon as the credential exists. Originals above 50 MB are not stored; they are listed in skippedAttachments as message/rfc822 with reason too_large while text and attachments are kept. attach-message-content no longer accepts a caller-supplied bodyRef."
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"version": "0.394.0",
|
|
46
|
+
"type": "improvement",
|
|
47
|
+
"title": "Outgoing mail carries attachments",
|
|
48
|
+
"detail": "sendMailFromAccount takes attachmentFileIds (at most 10 file references of the caller's tenant, at most 18 MB together, duplicates dropped). The bytes are read through file storage and sent over SMTP and stored in the sent folder unchanged; an unknown or foreign file gives mail_send_attachment_invalid, too many bytes mail_send_attachments_too_large, and nothing is sent. The plugin method send now receives the resolved attachments as a fourth argument and returns ProviderSentMail. New keys in all locale packages."
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
"version": "0.394.0",
|
|
52
|
+
"type": "improvement",
|
|
53
|
+
"title": "A hanging mailbox no longer blocks the others",
|
|
54
|
+
"detail": "The supervisor syncs up to 4 accounts at the same time (pollConcurrency), skips an account whose previous sync is still running, and abandons a sync after 10 minutes (accountPollDeadlineMs) as one failed poll; plugin.fetch and plugin.fetchContent receive an AbortSignal, and an abandoned sync stores nothing and moves no cursor. The IMAP client sets connection (20 s) and greeting (16 s) timeouts explicitly."
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"version": "0.394.0",
|
|
58
|
+
"type": "improvement",
|
|
59
|
+
"title": "Sender authentication (SPF, DKIM, DMARC) of imported mails",
|
|
60
|
+
"detail": "inbound-message-received v2 carries senderAuthentication ({ authservId, spf, dkim, dkimDomains, dmarc, fromDomain } or null; verdicts pass, fail, none, unknown; dkimDomains at most 10), stored as JSON text in read_inbound_messages.sender_authentication and returned by message:content and loadInboundMessageContent; isSenderAuthenticated is true only for a dmarc pass. The mail account has a new optional trustedAuthservId (events mail-account-* v2, read_mail_accounts.trusted_authserv_id, input of connect-account, update-account, reactivate-account and the IMAP connect and reconnect forms). Only the first Authentication-Results header from the top whose authserv-id equals it counts; without an id, without such a header or when its header.from domain differs from the From domain the result is null, and a dmarc result without header.from is unknown. Headers below the trusted one are ignored, so a sender cannot inject a pass. Mails imported before, and content refetched later, have null. New keys in all locale packages."
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"version": "0.394.0",
|
|
64
|
+
"type": "breaking",
|
|
65
|
+
"title": "Mail account handlers are stricter and the trusted authserv-id has its own handler",
|
|
66
|
+
"detail": "trustedAuthservId is no longer accepted by connect-account, update-account and reactivate-account (strict schemas reject unknown keys); set it with the new set-trusted-authserv-id (SystemAdmin, TenantAdmin, not model-callable). update-account accepts status, lastError, watchState and reason only from the system identity; connect-account is no longer exposed to agents. reactivate-account checks the account owner (unless the OAuth override is set), fails with credential_required when a password account has no stored credential, and reopens an account in place only with that override; disconnect-account deletes the stored secret. Outgoing attachments must pass the file read check of the sender, and SendMailFromAccountContext needs dispatch. New screen \"Absenderprüfung\" edits the id. The watch supervisor and the IMAP forms use mapWithConcurrency and implicit TLS on port 465 for SMTPS.",
|
|
67
|
+
"migration": "Drop trustedAuthservId from connect-account, update-account and reactivate-account payloads and dispatch set-trusted-authserv-id after connecting. Wrapping or agent setups that called connect-account as a model tool need their own exposed handler. Callers of sendMailFromAccount pass a context that includes dispatch. Personal mailboxes are reactivated by their owner only; after disconnect-account a new credential must be stored before reactivate-account."
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"version": "0.394.0",
|
|
71
|
+
"type": "breaking",
|
|
72
|
+
"title": "The mailbox row action Reconnect is folded into Reactivate",
|
|
73
|
+
"detail": "The row action reconnect of mailAccountListScreen and its i18n key inboundMail.accounts.reconnect are removed. A password IMAP mailbox with a sign-in error now shows Reactivate, which opens the same screen mail-account-reconnect-imap; OAuth mailboxes get the client action reactivate-oauth instead.",
|
|
74
|
+
"migration": "Apps that override inboundMail.accounts.reconnect move the text to inboundMail.accounts.reactivate. Apps that reference the row action id reconnect (tests, screen extensions) use reactivate for password IMAP and reactivate-oauth for OAuth mailboxes, and mount inboundMailFoundationClient() from @cosmicdrift/kumiko-bundled-features/inbound-mail-foundation/web so the OAuth action runs."
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
"version": "0.394.0",
|
|
78
|
+
"type": "breaking",
|
|
79
|
+
"title": "The OAuth callback only connects a personal mailbox for the account that signed in",
|
|
80
|
+
"detail": "InboundOAuthFlow.exchangeCode returns OAuthCodeExchange, an OAuthTokenSet with the required accountAddress of the signed-in provider account. The callback compares it with the mailbox address, ignoring case, and answers 403 mailbox_address_mismatch with a plain-text message and no account when they differ, so nobody can claim another person's address. This applies to personal mailboxes; a shared mailbox (only admins start that flow) is often opened with the admin's own login through delegated access, as with M365 shared mailboxes, so its address is not compared, and a token without access to it shows auth_error on the first sync.",
|
|
81
|
+
"migration": "Inbound providers with an oauth block return accountAddress from exchangeCode, taken from the token's email claim or the provider's profile endpoint (for example the userinfo email). There is no default: a provider that cannot name the signed-in address cannot connect mailboxes."
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
"version": "0.394.0",
|
|
85
|
+
"type": "improvement",
|
|
86
|
+
"title": "Failing ingests are counted and a poison mail is quarantined after three intervals",
|
|
87
|
+
"detail": "The supervisor counts an ingest failure once per poll interval in the unmanaged table store_mail_ingest_failures, but only when another mail of the account went through in the same poll (for a storage failure another storage write); at three it quarantines the mail with reason ingest_failed and moves the cursor on, before that the batch stops and nothing behind it is skipped. A failure nothing proves to be the mail's (KMS, file storage, the app's storeBody hook, the ingest handler or projection failing for every mail) counts nothing (when every failure of a poll was counted before as a storage or ingest failure, they all keep counting), keeps the cursor and sets the account to degraded with lastError encryption_unavailable, file_storage_unavailable or ingest_unavailable; the next interval retries. An account the supervisor cannot poll because the key store fails while decrypting its address is set to encryption_unavailable too, instead of being skipped silently. A mail that waits for its count clears a shown encryption_unavailable or ingest_unavailable, and file_storage_unavailable once a storage write went through. A requested quarantine retry also runs while a failing mail holds the cursor; during an outage, and when a reload fails because of the provider, the KMS or file storage, the request stays and runs after the recovery. Every quarantined mail also gets a header-only entry (contentState unavailable, contentUnavailableReason quarantined); after a storage failure it goes without the original."
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
"version": "0.394.0",
|
|
91
|
+
"type": "improvement",
|
|
92
|
+
"title": "Quarantined mails can be retried",
|
|
93
|
+
"detail": "The write handler retry-quarantined-message (mailbox owner or admin) flags a quarantine entry; the next successful sync loads the mail again, attaches the content to the header entry or ingests it in full, and removes the entry."
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
"version": "0.394.0",
|
|
97
|
+
"type": "breaking",
|
|
98
|
+
"title": "inbound-message-received is v3 and inbound-message-content-fetched is v2",
|
|
99
|
+
"detail": "Both events carry contentUnavailableReason (null for older events through upcasters). The read table read_inbound_messages gets content_unavailable_reason, store_mail_quarantine gets retry_requested_at, and store_mail_ingest_failures is new; generate and apply a migration with `kumiko-schema generate`. ingest-message and attach-message-content upload files before their transaction when called at top level, and attach-message-content skips the upload when the mail already shows its content; inside a wrapper handler's transaction they upload in it, as before. RejectedInboundMessage may carry rawMime and headers, and an inbound provider's fetch may take filterUnknownMessageIds to skip mails that are already known.",
|
|
100
|
+
"migration": "Run `kumiko-schema generate` and apply the migration; read_inbound_messages is rebuilt from events because attachment_file_ids becomes jsonb. Consumers that read the event payloads type them against the new versions (upcasters fill contentUnavailableReason with null). To get the pre-transaction upload, trigger on inbound-message-received or inbound-message-content-fetched instead of wrapping ingest-message in an own write handler."
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
"version": "0.394.0",
|
|
104
|
+
"type": "improvement",
|
|
105
|
+
"title": "A watch that starts during a sync catches up on mail that arrived before it opened",
|
|
106
|
+
"detail": "After a successful watch start the supervisor runs one catch-up poll (after a running sync finished), and a tick also starts the watch while the account is still syncing. Mail delivered between the last poll and the watch opening is imported without waiting for the poll interval. The watch start is idempotent per account."
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
"version": "0.394.0",
|
|
110
|
+
"type": "improvement",
|
|
111
|
+
"title": "Exports for consumers of content events and attachment names",
|
|
112
|
+
"detail": "INBOUND_MESSAGE_CONTENT_FETCHED_EVENT_QN, its payload type and schema, and readableFileName({ id, fileName }) are exported from the feature index, so an app can react to fetched content and show decrypted attachment names."
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
"version": "0.394.0",
|
|
116
|
+
"type": "improvement",
|
|
117
|
+
"title": "Reactivate a disconnected mailbox from the mailbox list",
|
|
118
|
+
"detail": "list-accounts returns reactivateVia per mailbox (password_form, oauth_flow or null) for a disconnected mailbox or one with a sign-in error, set only for the owner of a personal mailbox and for admins of a shared one. The mailbox list shows Reactivate for password_form; the new dormant screen my-mail-accounts (MY_MAIL_ACCOUNT_LIST_SCREEN_ID, role User) and the admin list show Reactivate for oauth_flow as the client action inbound-mail-foundation:reconnect-oauth, which goes through GET /api/inbound-mail/connect and the callback again and reopens the mailbox under its old id; apps mount it with inboundMailFoundationClient() from inbound-mail-foundation/web. The callback also reopens an OAuth mailbox with a sign-in error. It switches a reopened mailbox back on only after the new refresh token is stored; when storing fails it answers 502 credential_store_failed with a plain-text message and the mailbox keeps its previous state. reactivate-account admits the system user for this step. reactivate-account admits role User, gated by the owner rule for personal mailboxes, and requires a credential for disconnected, auth_error and degraded alike. set-trusted-authserv-id is a tenant security policy: only SystemAdmin and TenantAdmin change it, on every mailbox of the tenant including personal ones. The connect route answers 403 shared_mailbox_requires_admin when a user without admin role starts the shared flow. The row action Reconnect is folded into Reactivate (see the breaking entry). The connect-mailbox form reopens a disconnected shared IMAP mailbox of the same address under its old account id with the new password. For anything else connect-account answers with a text pointing to Reactivate, and credential_required from reactivate-account is a plain-text message. A new i18n key set (de, en, fr, es, it) is part of the locale packages."
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
"version": "0.394.0",
|
|
122
|
+
"type": "improvement",
|
|
123
|
+
"title": "Quarantined mails are listed with sender and subject and handled one by one",
|
|
124
|
+
"detail": "The query quarantine:list and the dormant screen mail-quarantine show the open entries of the mailboxes the caller may manage, with sender and subject from the header-only import (empty without one), reason and date; row actions fetch a mail again or confirm it. acknowledge-quarantine takes an optional providerMessageId (without it all entries of the mailbox as before) and now also admits the owner of a personal mailbox (role User). The mailbox list gets a row action Quarantine for mailboxes with open entries."
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
"version": "0.394.0",
|
|
128
|
+
"type": "improvement",
|
|
129
|
+
"title": "Mail attachments are bound to the mail and follow its visibility",
|
|
130
|
+
"detail": "The attachment fileRefs of an imported mail are bound to the inbound-message record (field attachmentFileIds). GET /files/:id answers with the mail's visibility: owner and admins for a personal mailbox, whoever may see the mail for a shared one. The new query inbound-message:detail ({ id }) backs that check and is not exposed to agents. Outbound attachments use the same rule (canCallerReadFileRef). Retention now also removes the attachment files of a purged mail. read_inbound_messages.attachment_file_ids is jsonb; the migration 0056 carries a table rebuild marker, so the read table is rebuilt from the events. InboundMessageContent.attachmentFileIds stays string[]."
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
"version": "0.394.0",
|
|
134
|
+
"type": "breaking",
|
|
135
|
+
"title": "Personal mail is private to its owner, roles are configurable",
|
|
136
|
+
"detail": "Admins no longer read messages, bodies, attachments or quarantine of a personal mailbox; list-accounts still shows them the account without counts. The screens and the IMAP handlers are factories (createMailAccountListScreen, createImapConnectScreen and others) taking the resolved roles instead of constants. The content-fetched event is version 3 with accountId and ownerUserId. createDefaultGuard has a fourth parameter recordOnlyEntityTypes; for those entity types the files privilegedRoles no longer read or delete attached files (shared mailboxes included), only the uploader and readers of the record do. The OAuth connect route answers 403 mail_role_required to callers without a mail role.",
|
|
137
|
+
"migration": "Apps that import mailAccountListScreen, mailAccountEditScreen, mailAccountSenderCheckScreen, myMailAccountListScreen, mailQuarantineScreen, imapConnectScreen or imapReconnectScreen call the matching create*Screen factory with resolveInboundMailRoles(). Apps with their own roles pass { userRoles, adminRoles } to createInboundMailFoundationFeature and the same options to createInboundProviderImapFeature. Callers of createDefaultGuard pass a Set of record-only entity types as fourth argument. Code that read personal mail as an admin must run as the owner; the nullable accountId and ownerUserId on content-fetched events need no action."
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
"version": "0.394.0",
|
|
141
|
+
"type": "fix",
|
|
142
|
+
"title": "Supervisor and OAuth callback writes work when the app roles leave out SystemAdmin",
|
|
143
|
+
"detail": "`update-account`, `connect-account` and `reactivate-account` are dispatched as the system user by the watch supervisor, the account health and the OAuth callback, but their access list held only the configured `adminRoles`/`memberRoles`. An app whose roles exclude SystemAdmin saw every health, watch-state and callback write refused (\"health update was rejected\"), so mailboxes never showed `degraded`. These three handlers now also accept SystemAdmin (the list is `SYSTEM_DISPATCHED_MAIL_HANDLERS`). `isMailAdmin`, screens and what real users may do stay on the configured roles."
|
|
144
|
+
},
|
|
2
145
|
{
|
|
3
146
|
"version": "0.393.0",
|
|
4
147
|
"type": "improvement",
|