Files
Inboxintel/docs/discovery/multi-provider/01-provider-abstraction.md
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

81 lines
4.7 KiB
Markdown

# 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
```csharp
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](../07-ai-feature-catalogue.md)),
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](07-ux-flows.md)) — never crashes sync.
- **New mail** triggers AI enrichment + embedding jobs (blueprint [08](../08-technical-architecture.md)).
## 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.**