/** * cursor-store.ts, the durable per-mailbox cursor, for both sources * (docs/inbound-email.md §4, §3.4d). * * `EXAMINE` + `BODY.PEEK` means the daemon never marks anything `\Seen`, so * `SEARCH UNSEEN` would return the same messages forever and "have I handled * this?" cannot be asked of the server. This store keeps the daemon's own * answer: one record per (account, mailbox), so the file cannot grow with * mail traffic. * * The record is a discriminated union, because IMAP's position and Gmail's are * not the same kind of thing, `UIDVALIDITY` + highest UID against a decimal * uint64 `historyId` STRING. `source-cursor.ts` holds the shapes and the * validators and states the reasoning; this file holds the custody rules over * them. Both sources' write paths are here, and both go through ONE * establish-at-high-water-mark path (`establishAt`), so `uid-validity-changed` * and `history-expired` cannot drift into two different ideas of what to do * when we lost our place. * * Follows the same five-rule shape as `platform/devices/device-grants.ts`: * 1. Reap on recovery, cursors for an account no longer configured are * dropped at load. The "is this account configured" predicate is an * injected dependency (`isAccountConfigured`); this module never reads * config directly. It answers THREE things, not two, `true`, `false` and * `'unknown'`, because "the config has not loaded yet" is not the same * claim as "this account is not configured", and reading the first as the * second reaps a live cursor. See `AccountConfiguredAnswer`. * 2. Bound everything, one record per mailbox already bounds the file with * traffic; `maxCursors` is a defensive count cap on top of that, in case a * bug or a hand-edited config ever produced more distinct (account, * mailbox) pairs than could plausibly be real. * 3. Validate by content, on an IMAP record, `uidValidity` and `lastSeenUid` * must be integers inside the 32-bit range RFC 3501 §2.3.1.1 defines, * positive / non-negative respectively (see the note on `lastSeenUid` * below, and `MAX_IMAP_UID` on why the UPPER bound is the load-bearing * one); on a Gmail record, `historyId` must be a decimal uint64 string; * on both, `updatedAt` a parseable ISO date and `mailbox` and `account` * non-empty, length-bounded strings. A record failing ANY check is * discarded, not repaired: a corrupt cursor silently coerced to 0 would * replay the entire mailbox at the owner. A record naming a source this * build does not know is discarded rather than read as either shape. * 4. Reap periodically, `sweep()` is safe to call on a timer, not only at boot. * 5. Disclose what was reaped, every sweep returns an itemised report. * * The UIDVALIDITY rule and first-run behaviour (§4) live in `resolve()`: * establishing or re-establishing a cursor NEVER replays past mail. It always * sets `lastSeenUid` to the caller-supplied current high-water mark and * reports how many messages were skipped, rather than backfilling. * * A NOTE ON WHERE THE DESIGN DOC AND THIS IMPLEMENTATION DIVERGE: * §9.1 says "`uidValidity` and `lastSeenUid` must be positive integers". * `uidValidity` is always positive under IMAP (RFC 3501: a 32-bit * non-zero value). `lastSeenUid`, however, is legitimately `0` on first run * against a mailbox that currently holds no messages, there is no highest * UID to establish, and `0` is the only honest value meaning "nothing seen * yet". Requiring `lastSeenUid > 0` would make that record fail its own * validation on the very next load, immediately after being written. This * store therefore validates `lastSeenUid` as a NON-NEGATIVE integer while * keeping `uidValidity` strictly positive. Flagged in the implementation * report as a design-doc correction, not silently reinterpreted. */ import { PersistentStore, type PersistentStoreCorruption } from '../../state/persistent-store.js'; import { type HousekeepingTrigger, type MailboxCursor } from './types.js'; import { type GmailMailboxCursor, type InboundSourceCursor } from './source-cursor.js'; /** * `file-unreadable` is rule 3 applied to the FILE rather than to a record: a * cursor file that will not parse is discarded and disclosed, exactly as a torn * record inside it would be. Reading it as a permanent hard failure instead * would take every reader of this store down with it, including the sweep of * the two stores that are fine, and including the disclosure verb whose whole * job is to explain this state. */ export type CursorDiscardReason = 'malformed' | 'file-unreadable' | 'account-not-configured' | 'over-cap'; /** One removal, itemised for disclosure. */ export interface CursorDiscard { readonly account: string; readonly mailbox: string; readonly reason: CursorDiscardReason; readonly removedAt: number; readonly note?: string | undefined; } /** Result of one housekeeping pass over the cursor store. */ export interface CursorSweepReport { readonly sweptAt: number; readonly removed: readonly CursorDiscard[]; readonly retained: number; /** * How many retained cursors were kept because the "is this account * configured" question could not be answered on this pass. * * Disclosed rather than silent: a cursor kept for an unknown reason is * persisted state that nothing has justified, and a sweep whose count never * falls to zero means a caller is permanently unable to answer, which is a * fault worth seeing rather than a leak worth ignoring. */ readonly unresolvedAccounts: number; } export type CursorResolutionKind = 'resumed' | 'first-run' | 'uid-validity-changed'; /** * The Gmail equivalent, with `history-expired` standing exactly where * `uid-validity-changed` stands on the IMAP side. * * Gmail answers `users.history.list` with a 404 when the requested * `startHistoryId` has aged out of its retention window, typically about a * week, sometimes hours. That means the same thing a changed `UIDVALIDITY` * means: the stored position names nothing. Both go through * `establishCursor()` below, so there is one implementation of "what to do * when we lost our place" and not two that can drift. */ export type GmailCursorResolutionKind = 'resumed' | 'first-run' | 'history-expired'; /** The outcome of asking where the Gmail source should resume from. */ export interface GmailCursorResolution { readonly kind: GmailCursorResolutionKind; /** The cursor to use going forward. Already persisted when this is returned. */ readonly cursor: GmailMailboxCursor; /** The discarded cursor, present only for `history-expired`, disclose this to the owner. */ readonly previous?: GmailMailboxCursor | undefined; } /** * The outcome of asking "what cursor should I use for this mailbox right * now", given what the server just reported. Never a signal to replay: a * `first-run` or `uid-validity-changed` result always establishes * `lastSeenUid` at the caller-supplied high-water mark, never at 0 or at the * old value. */ export interface CursorResolution { readonly kind: CursorResolutionKind; /** The cursor to use going forward. Already persisted when this is returned. */ readonly cursor: MailboxCursor; /** Messages that existed before this mailbox was watched (or before its UIDVALIDITY changed) and were deliberately NOT replayed. Always 0 for `resumed`. */ readonly skippedMessageCount: number; /** The discarded cursor, present only for `uid-validity-changed`, disclose this to the owner. */ readonly previous?: MailboxCursor | undefined; } export interface MailboxCursorPolicy { /** Defensive count cap across all (account, mailbox) pairs. */ readonly maxCursors: number; } export declare const DEFAULT_MAILBOX_CURSOR_POLICY: MailboxCursorPolicy; interface CursorSnapshot extends Record { readonly version: 1; readonly cursors: readonly InboundSourceCursor[]; } /** * Validate an IMAP cursor by its parsed content, not by its presence in the * file. Returns `null` for anything torn, oversized, out of range, or written * by a different source. Never throws, never repairs. * * The field checks live in `source-cursor.ts` so the discriminated-union * validator and this one cannot drift into disagreeing about what a valid * IMAP position is. */ export declare function validateMailboxCursor(value: unknown): MailboxCursor | null; /** * The three answers to "is this account still configured for inbound * watching", and why the third one has to exist. * * `true` and `false` are the answers a caller that KNOWS can give. `'unknown'` * is the answer a caller gives when it cannot know yet, the config file has * not been read, the manager is mid-reload, the account list is a promise that * has not settled. Without it, such a caller has to pick one of the two, and * both choices are wrong in a way that costs mail: * * - answering `false` reaps every stored cursor. The next `resolve()` then * answers `first-run` at the mailbox's CURRENT high-water mark, so every * message between the discarded position and that mark is silently skipped * , not replayed, skipped, and the owner is told the mailbox "started * fresh", which is indistinguishable from a genuine first run. Seeded at * UID 900 with the mailbox at 1500, that is 600 messages nobody ever sees * and no line anywhere saying so. * - answering `true` keeps cursors for accounts that really were removed, * which is a bounded leak the count cap already handles. * * So the store treats `'unknown'` as "keep, and say so" rather than making the * caller choose between a silent skip and a leak. Absence of an answer is * never read as an answer of absence. */ export type AccountConfiguredAnswer = boolean | 'unknown'; export interface MailboxCursorStoreOptions { readonly policy?: Partial | undefined; readonly now?: (() => number) | undefined; /** * Injected dependency, not a config read: answers "is this account still * configured for inbound watching". When omitted, the account-reap rule is * inert (nothing is dropped on that basis) rather than defaulting to "drop * everything" or "read config directly". * * A caller that cannot answer yet returns `'unknown'` rather than guessing, * see `AccountConfiguredAnswer`. */ readonly isAccountConfigured?: ((account: string) => AccountConfiguredAnswer) | undefined; } /** * Durable per-mailbox cursor store. Every read re-validates from disk so a * corrupt or stale record written by a crashed process is never honoured. */ export declare class MailboxCursorStore { private readonly store; private readonly policy; private readonly now; private readonly isAccountConfigured; private writeChain; /** The last unreadable-file event, latched so status can name it. */ private corruption; constructor(storeOrPath: PersistentStore | string, options?: MailboxCursorStoreOptions); getPolicy(): MailboxCursorPolicy; /** * The unreadable-file event this store last saw, or null. * * Latched rather than transient because the next write replaces the file: by * the time anyone asks, the evidence on disk is gone, and "the cursors were * discarded" is the one fact that explains a mailbox that resumed from * nowhere. */ getCorruption(): PersistentStoreCorruption | null; private readWithDrops; private mutate; /** * Live, content-validated cursors, filtered to configured accounts when * `isAccountConfigured` was supplied. Read-time filter, does not persist * the drop; `sweep()` is what removes it from disk. * * `'unknown'` keeps the cursor, for the same reason `sweep()` keeps it: * hiding a live position because nobody could say yet whether its account is * configured would make the position look absent to the very caller about to * resume from it. */ list(): Promise; /** * The IMAP cursor for a mailbox. * * A stored GMAIL cursor under the same key answers `null` here rather than * being read as an IMAP one, a `historyId` is not a UID and there is no * honest conversion between them, so the position is treated as absent and * re-established at the high-water mark, which is the same rule a torn * record gets. */ get(account: string, mailbox: string): Promise; /** The Gmail cursor for a mailbox. A stored IMAP cursor answers `null`, for the same reason. */ getGmail(account: string, mailbox: string): Promise; /** * Resolve the IMAP cursor to use for a mailbox given what the server reports * right now (§4). * * - No stored cursor -> `first-run`: establishes `lastSeenUid` at * `currentHighestUid` and reports `currentMessageCount` as skipped. Does * not backfill. * - Stored cursor with the same UIDVALIDITY -> `resumed`: the stored * cursor stands unchanged. * - Stored cursor with a DIFFERENT UIDVALIDITY -> `uid-validity-changed`: * every stored UID is meaningless (the mailbox was recreated), so the * cursor is discarded and re-established at `currentHighestUid`, * reporting `currentMessageCount` as skipped rather than replaying a * year of old mail. The discarded cursor is returned as `previous` for * disclosure. * * A stored GMAIL cursor under the same key is treated as absent, so this * answers `first-run` and REPLACES it, see `establishAt()` for why the * record is replaced rather than kept alongside. */ resolve(input: { readonly account: string; readonly mailbox: string; readonly serverUidValidity: number; readonly currentHighestUid: number; readonly currentMessageCount: number; }): Promise; /** * Resolve the GMAIL cursor to use for a label, given the `historyId` Gmail * reports right now. The mirror of `resolve()`, and the same rule: * establishing or re-establishing NEVER replays. * * - No stored Gmail cursor -> `first-run`: established AT * `currentHistoryId`. Deliberately not backfilled, the daemon starts * listening now, it does not retroactively decide about mail that arrived * before it was asked to. There is no skip count to report because * `users.history.list` cannot say how many records lie below a historyId * without asking for them, and asking for them is the backfill this * refuses. * - A valid stored Gmail cursor -> `resumed`, unchanged. * - `historyExpired: true` -> `history-expired`: the stored position is * discarded and re-established at `currentHistoryId`, and the discarded * cursor comes back as `previous` for disclosure. * * WHY EXPIRY IS A FLAG ON THIS INPUT rather than a second * `resetGmailAfterHistoryExpiry()` method: the store cannot detect the * condition itself. Only Gmail can, by answering `users.history.list` with a * 404 for a `startHistoryId` that has aged out of its retention window, and * the caller is holding that answer. What the caller then needs is the same * thing it needed at start-up, "where do I resume from", so it asks the * same question and gets a `GmailCursorResolution` it has to handle. A * separate reset method would be a second entry point into establishing a * position, callable without ever reading its result, and this file's whole * claim is that there is ONE implementation of what to do when we lost our * place. */ resolveGmail(input: { readonly account: string; readonly mailbox: string; /** * The mailbox's current `historyId`, as a decimal string exactly as Google * sent it, from `users.getProfile` or from the last delta. Never a number. */ readonly currentHistoryId: string; /** * Set when Gmail answered `resync-required` (a 404 on the stored * `startHistoryId`). Any stored position is then discarded. */ readonly historyExpired?: boolean | undefined; }): Promise; /** * Advance the cursor after a message is FULLY processed, matched, * recorded, and notice dispatched or deliberately suppressed (§4). A crash * between fetch and this call means the cursor never moves, so the same * message is fetched again on recovery; dedup (§6) is what turns that * redelivery into a suppressed duplicate rather than a second notice. * * Requires a cursor already established via `resolve()` for this * (account, mailbox) under the SAME uidValidity; refuses rather than * silently accepting a stale write. */ advance(input: { readonly account: string; readonly mailbox: string; readonly uidValidity: number; readonly lastSeenUid: number; }): Promise; /** * The Gmail mirror of `advance()`, with the same contract: called ONCE PER * MESSAGE and only after that message is fully processed, so a crash leaves * the cursor below the message and the next delta fetches it again. * * `historyId` is stored BYTE-IDENTICAL to what was handed in. It is never * parsed, never compared with `<` or `>`, and never round-tripped through * `Number`: `Number('18446744073709551615')` is `18446744073709552000`, and a * cursor that came back four hundred larger than it went in would silently * skip every message in between. */ advanceGmail(key: { readonly account: string; readonly mailbox: string; }, position: { readonly historyId: string; }): Promise; /** * One housekeeping pass: drop malformed records, drop cursors for accounts * no longer configured, and enforce the defensive count cap (oldest by * `updatedAt` first). Idempotent and safe concurrently, recomputes every * removal from the file it just read. * * Deliberately source-BLIND. Every rule here reads only `account`, `mailbox` * and `updatedAt`, which both variants carry, so an unconfigured account's * Gmail cursor is reaped exactly as its IMAP one is. Narrowing to IMAP * anywhere in this method would quietly exempt Gmail records from the reap, * the cap and the disclosure all three. */ sweep(trigger?: HousekeepingTrigger): Promise; runRecoverySweep(): Promise; } export {}; //# sourceMappingURL=cursor-store.d.ts.map