Files
Inboxintel/docs/discovery/multi-provider/01-provider-abstraction.md
T
cesnimda 4ce2df0a2b
CI / backend (push) Successful in 52s
CI / frontend (push) Successful in 14s
Deploy Staging / deploy (push) Successful in 18s
CI / backend (pull_request) Successful in 52s
CI / frontend (pull_request) Successful in 15s
Security / secrets (push) Successful in 4s
Security / dependencies (push) Successful in 55s
Security / secrets (pull_request) Successful in 4s
Security / dependencies (pull_request) Successful in 54s
docs: discovery blueprint + multi-provider design (#8)
2026-07-01 19:59:53 +02:00

4.7 KiB

01 — Provider Abstraction (Part 1)

A unified layer so Gmail, Outlook/Graph, and future IMAP look identical to the rest of the app. No provider-specific logic in Domain; specifics live in Infrastructure adapters.

Layering

Domain            : Account, EmailMessage, EmailThread, Label (provider-agnostic)
Application       : IEmailProvider (contract) · ISyncOrchestrator · DTOs
Infrastructure    : GmailProvider · OutlookProvider · ImapProvider  (adapters)
                    ProviderFactory (ProviderType → adapter) · ITokenStore

The contract

public enum ProviderType { Google, Microsoft, Imap }

public interface IEmailProvider {
    ProviderType Type { get; }
    ProviderCapabilities Capabilities { get; }          // read, modifyFlags, folders, delta, send?

    // Auth (details in 02-auth-and-signin.md)
    Task<OAuthResult> ExchangeCodeAsync(string code, CancellationToken ct);
    Task<TokenSet>    RefreshAsync(TokenSet current, CancellationToken ct);
    Task<ProviderIdentity> GetIdentityAsync(TokenSet tokens, CancellationToken ct); // sub + email

    // Sync (pull-based, incremental)
    Task<SyncPage> SyncAsync(SyncCursor cursor, TokenSet tokens, CancellationToken ct);
    Task<RawMessage> FetchMessageAsync(string providerMessageId, TokenSet tokens, CancellationToken ct);

    // Mutations (only if Capabilities allow; mirrors current gmail.modify scope)
    Task ApplyFlagAsync(string providerMessageId, MailFlagChange change, TokenSet tokens, CancellationToken ct);
}
  • SyncPage = { IReadOnlyList<RawMessage> upserts, IReadOnlyList<string> deletes, SyncCursor next, bool hasMore }.
  • RawMessage is the provider-shaped payload; a normaliser maps it to the domain EmailMessage. The rest of the app never sees RawMessage.
  • Capabilities let the UI/engine degrade gracefully (e.g., an IMAP server without CONDSTORE falls back to full-scan sync; no send today for any provider).

Provider implementations

Provider API Incremental cursor Threading Folders/Labels Notes
Gmail Gmail REST historyId (History API) threadId labels Reuses existing client; read + modify (no send), as today
Outlook/365 Microsoft Graph delta query @odata.deltaLink conversationId mailFolders OAuth via Microsoft identity platform
IMAP IMAP4rev1 UIDVALIDITY+UIDNEXT, HIGHESTMODSEQ (CONDSTORE/QRESYNC) heuristic (References/In-Reply-To) folders Fallback = periodic UID scan if no CONDSTORE; MailKit already a dependency

Normalisation (the unified model)

Each adapter maps provider fields → domain via a IMessageNormaliser:

Domain field Gmail Graph IMAP
ProviderMessageId message id message id UIDVALIDITY:UID
ProviderThreadId threadId conversationId derived (References)
flags (unread/star/important/trashed/inbox) labelIds isRead/flag/folder \Seen \Flagged, folder
labels/folders labels mailFolders folders
sent/received, from, subject, snippet, body, attachments, size headers/parts message resource RFC822 parse (MailKit)
  • Threads are per-account (each provider defines its own). Cross-provider thread linking is a later semantic/AI feature (see blueprint 07), not part of core normalisation.

Sync engine

  • ISyncOrchestrator replaces the Gmail-specific worker: for each active Account, it loads the SyncCursor, calls provider.SyncAsync, upserts normalised messages (idempotent on (AccountId, ProviderMessageId)), applies deletes, and persists the next cursor atomically.
  • Runs as the existing hosted-worker pattern (AccountSyncWorker), one logical job per account, bounded concurrency, Polly backoff, resumable.
  • Token refresh: SyncAsync/mutations get a valid TokenSet from ITokenStore, which refreshes on expiry/401 and re-encrypts at rest; a failed refresh flips the account to reauth_needed (surfaced in UI, see 07) — never crashes sync.
  • New mail triggers AI enrichment + embedding jobs (blueprint 08).

Search across providers

Because all providers normalise into one email_messages store scoped by UserId, search (structured + FTS + semantic) already spans every account a user has connected — no per-provider search code. An optional AccountId facet lets users scope to one mailbox.

Extensibility

Adding a provider = one IEmailProvider adapter + one normaliser + register in ProviderFactory + a feature flag to enable it. Zero changes to Domain, search, or AI.