docs: discovery blueprint + multi-provider design (#8)
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
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
This commit was merged in pull request #8.
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
# 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.**
|
||||
Reference in New Issue
Block a user