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
4.7 KiB
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 }.RawMessageis the provider-shaped payload; a normaliser maps it to the domainEmailMessage. The rest of the app never seesRawMessage.- Capabilities let the UI/engine degrade gracefully (e.g., an IMAP server without
CONDSTORE falls back to full-scan sync; no
sendtoday 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
ISyncOrchestratorreplaces the Gmail-specific worker: for each activeAccount, it loads theSyncCursor, callsprovider.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 validTokenSetfromITokenStore, which refreshes on expiry/401 and re-encrypts at rest; a failed refresh flips the account toreauth_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.