Navigace
ELOWEN · Dokumentace

Poslední aktualizace: 27. října 2026

Procházet dokumentaci · Email storage and credential lifecycle

Tento článek zatím není přeložen. Je dostupný v angličtině.

Email storage and credential lifecycle

src/store.ts owns the host-selected plugin_email schema and exports SQL-only migrate(db). The host invokes migration version 1 before registration; repeated invocation uses its migration ledger and is idempotent. Version 1 is unreleased and targets clean installations only: its Microsoft-reference uniqueness is part of the initial schema, not a production repair or upgrade migration. Once shipped, later changes append versions and never rewrite a shipped step. Uninstall with data uses the host schema/ledger removal mechanism. There is no down migration; rollback retains the schema for the owning compatible plugin version.

Accounts and cursors

accounts stores owner ID, provider kind, address, display name, access, alert preference, IMAP/SMTP settings or a Microsoft reference, and creation time. Provider constraints reject mixed credential metadata. EmailStore.create is insert-only: it never changes another owner through a colliding ID. New connection callers generate a UUID before credential storage.

The partial unique index accounts_microsoft_owner enforces one Microsoft account row per (user_id, microsoft_ref), independently of address or caller. Both inserts and reconnect updates are atomic under this invariant, including concurrent verified imports/reconnects and direct SQL writes. A collision rejects without changing the existing row, cursor or Microsoft token; the connection API retains its existing connection-failure response. No refcount or browser prefilter owns this rule. Other owners may use the same reference, and IMAP/Google rows have null references and are outside the index. Consequently removing one Microsoft row cannot disconnect another row that shares its identity, because such duplicates cannot exist.

Every interactive read/write supplies (userId, accountId). require rejects missing or foreign accounts alike. update, remove and saveCursor lock the owner-matched account row in their transaction and use only that transaction's executor. The scheduler's alertAccounts() is the explicit cross-owner enumeration for opted-in background checks.

alert_cursors has one account-owned row, containing either IMAP UIDVALIDITY/last UID or a Graph delta, plus check time. Account deletion cascades it. Cursor writes verify the provider and owner; no body, header cache or attachment bytes are stored.

Reconnect without replacing the account

MailAccountConnection in src/types.ts contains address and either {kind:'imap'|'google',imap,smtp,username} or {kind:'microsoft',microsoftRef}. mailConnectionSchema in the existing store validates that exact metadata, rejecting extra fields. EmailStore.reconnect(userId,id,connection): Promise<MailAccount> locks the owner-matched row and changes only its connection columns. Provider kind and exact mailbox address must match. The account ID, creation time, display name, access, alert preference and cursor remain intact; there is no delete/re-add or schema migration.

Connection/API code calls the existing account service:

await accounts.reconnect(userId, accountId, connection, secret, verify);

The required verify(account: NewMailAccount, secret: MailAccountSecret | null): Promise<void> matches the connection layer's VerifyMailConnection: it must perform real login, verify SMTP when needed, and close resources. It receives the candidate with the existing account ID and owner. Password and Google kinds require their matching credential; Microsoft requires null, since its identity control owns the token. The API must select a Microsoft reference from that owner's mailAccounts and pass the authenticated Google profile address, never trust a browser-selected owner.

Verification happens before persistence and outside SQL transactions. Afterwards the service takes the same owner/account credential lock used by refresh and removal, rechecks account existence, saves the verified credential and commits connection metadata. A failed metadata write restores the previous credential before releasing the lock; failure to restore throws an AggregateError, never success. Microsoft stores only its verified reference. Missing/foreign accounts, provider/mailbox changes, failed verification and failed persistence reject. A mailbox removed during verification is not recreated.

Credentials and removal

EmailSecrets resolves scope for each operation without caching bags. With a current account, it checks that exact owner and uses ambient ctx.userSecrets() in both daemon and runners. Without a current account, only the authoritative daemon may call ctx.userSecretsFor(userId); the host checks owner existence and live grants for every operation, including late refresh writes. A runner without an account rejects and never invokes the daemon-only accessor. It stores validated JSON under account.<uuid>: a password, or Google refresh/access token and expiration. Invalid stored JSON rejects with a generic reconnect error that never includes credential bytes. Reads do not invent credentials.

withLock(userId, id, operation) rereads the secret inside the host vault's interprocess lock. The callback receives the current value and an awaited save function. Saving null deletes the credential; reconnect rollback uses that when no credential previously existed. The save function rechecks the current owner and expires with its callback, so retaining it cannot bypass scope or lock ownership. Google refresh belongs inside that callback. Ordinary writes and removal use the same account lock without decoding an old value; malformed JSON therefore does not prevent explicit replacement or deletion. remove accepts an awaited afterRemove callback for account-row cleanup while the same credential lock is still held.

EmailAccounts.remove first verifies ownership, then deletes the credential (or calls the live Microsoft control's mailDisconnect), then removes the local row and cursor. A credential or provider-control failure keeps the row available for retry and is propagated. Remote messages are never deleted. Removing a Microsoft reference requires the owning control; absence is an unavailable error, never a successful disconnect. Removal holds the same account credential lock through provider disconnect/vault deletion and metadata deletion, so concurrent reconnect cannot leave a credential behind after the account row disappears.

registerUserRemoved closes owner admission, stops and drains alert polls, awaits complete API/tool callbacks (including connections, refreshes and backend close), forgets pending Google sign-in and deletes that user's account rows and cursors idempotently. Core's subsequent account-deletion transaction deletes every user vault row, including email and Microsoft mail credentials. The callback never reads the administrator's ambient vault. Background access uses the coordinated owner-authorized vault seam described in runtime.md.

Verification

tests/emailFoundation.test.ts and tests/emailAccounts.test.ts use tests/helpers/pluginDb.mjs and its disposable socket-only PostgreSQL cluster. They cover migration replay, owner isolation, ID collision, UIDVALIDITY identity, cursor provider checks, cursor cascade, verified reconnect with stable IDs/cursors/preferences, credential rollback, malformed credentials and removal failures. tests/emailMicrosoftUniqueness.test.ts covers direct SQL/store collisions, concurrent verified imports/reconnects, owner/null-reference isolation and preserved native encrypted tokens after a rejected reconnect and source removal. tests/emailSecretsHost.test.ts uses the candidate's real plugin context, daemon host, per-account grants and durable vault locks to verify background access, runner ambient scope, expired/cross-owner save callbacks and deletion during refresh. No fixture connects to an instance database or boots the daemon.