/** * Read-model facade in front of ImapService and the local mirror. * * Every read tool goes through this service; writes stay on ImapService and * SmtpService (and emit `email:changed` so the mirror resynchronizes). Reads * are correctness-first: * * 1. A mirror reconciled within the debounce window (and not marked dirty) is * served directly — this collapses the burst of reads inside one agent * turn to zero network round trips. * 2. Otherwise one STATUS round trip compares the server's UIDVALIDITY / * UIDNEXT / HIGHESTMODSEQ / MESSAGES against the stored watermarks — the * IMAP equivalent of a conditional request. Unchanged ⇒ serve the mirror. * 3. Changed ⇒ await the sync, bounded; past the bound the mirror is served * marked `stale: true` while the sync finishes in the background. * 4. IMAP unreachable ⇒ serve the mirror marked `offline: true` (RFC 5861 * stale-if-error, and the offline mode the mirror exists for). * * Mailboxes the mirror has never synced are served live and warmed in the * background. With the cache disabled the facade is a pure pass-through. */ import type BlobStore from '../cache/blob-store.js'; import type CacheStore from '../cache/store.js'; import type SyncEngine from '../cache/sync-engine.js'; import type { Email, EmailMeta, PaginatedResult } from '../types/index.js'; import type ImapService from './imap.service.js'; /** Where a read was answered from, surfaced to the MCP client (additive). */ export interface CacheMarker { source: 'cache'; /** Set when a change is known but the bounded sync wait expired. */ stale?: true; /** Set when the IMAP server was unreachable and the mirror answered. */ offline?: true; } export type CachedList = PaginatedResult & { cache?: CacheMarker; }; /** Process-lifetime counters, surfaced via check_health / cache status. */ export interface MailReadMetrics { /** Reads served from the mirror without any network round trip. */ hits: number; /** Reads served from the mirror after a STATUS confirmed it current. */ validatedHits: number; /** Reads that triggered a blocking sync because the mirror was behind. */ syncedReads: number; /** Reads served stale because the bounded sync wait expired. */ staleServes: number; /** Reads served from the mirror because IMAP was unreachable or failing. */ offlineServes: number; /** Reads that fell through to the live path. */ liveFallbacks: number; /** * STATUS probes that disagreed with the mirror — each one is a change the * event pipeline missed, so a growing number means invalidation is broken. */ revalidationsChanged: number; } interface MailReadOptions { /** Reads within this window of the last reconcile skip revalidation. */ debounceMs?: number; /** How long a read waits for a triggered sync before serving stale. */ syncWaitMs?: number; } export default class MailReadService { private readonly imapService; private readonly store?; private readonly syncEngine?; private readonly blobStore?; private readonly debounceMs; private readonly syncWaitMs; private metrics; constructor(imapService: ImapService, store?: CacheStore | undefined, syncEngine?: SyncEngine | undefined, options?: MailReadOptions, blobStore?: BlobStore | undefined); getMetrics(): Readonly; /** In-flight revalidations, so a batch of reads shares one STATUS probe. */ private inFlightRevalidations; /** * Decide whether the mirror may answer for `mailbox`, revalidating or * resynchronizing as needed. Never throws: every failure path degrades to * either a marked cache serve or the live path. * * Coalesced per mailbox: `get_emails` fans out N concurrent reads, and * without this each one would pay its own STATUS round trip on a connection * that serializes commands anyway. */ private revalidate; private runRevalidate; private decideFreshness; /** * Does the mirror hold every message the server reports for this mailbox? * * `totalMessages` is the server's own EXISTS from the last sync, so this * compares what was mirrored against what exists. Unknown server count is * treated as covered: the alternative is never using the cache at all on a * server that withholds the count. */ private static covers; /** * Compare a STATUS response against the stored watermarks. Absent fields on * either side are treated as changed — no evidence is not evidence of * freshness. */ private static statusUnchanged; private static race; listEmails(account: string, options?: Parameters[1]): Promise; getEmailFlags(account: string, emailId: string, mailbox?: string): ReturnType; /** In-flight single-message loads, so concurrent get_emails coalesce. */ private inFlightEmails; getEmail(account: string, emailId: string, mailbox?: string): Promise; private loadEmail; /** * Best-effort write-back of a live-fetched body. Never throws. * * `uidValidity` is the epoch the read was served from, captured BEFORE the * fetch: if a concurrent sync rotated the epoch meanwhile, the UPDATE * matches zero rows and the fill correctly disappears. */ private persistBody; searchEmails(account: string, query: string, options?: Parameters[2]): Promise; getThread(account: string, messageId: string, mailbox?: string): ReturnType; findEmailFolder(account: string, emailId: string, sourceMailbox: string): ReturnType; /** * Serve an attachment from the content-addressed blob store when its bytes * are already held, otherwise download once and index them. * * Attachment content is immutable per (uidValidity, uid, part), so a hit is * served with no revalidation at all — not even a STATUS probe. The epoch in * the index key is the fence: after a UIDVALIDITY change those rows are gone. */ downloadAttachment(account: string, emailId: string, mailbox: string, selector: string | { filename?: string; partId?: string; }, maxSizeBytes?: number): ReturnType; } export {}; //# sourceMappingURL=mail-read.service.d.ts.map