# Architecture ## Service Layer **App.xaml.cs** is the manual DI composition root — no container. Services are wired in `OnStartup` in dependency order: `ProfileContext` → `AccountService` → `CredentialService` → `ConfigService` → `ProviderCatalog` → `AutoDiscoverService` → `OAuthService` → `ImapMailService` → `SmtpService` → `LocalStoreService` → `ContactService` → `TemplateService` → `RuleService` → `SyncService` → `ConnectivityService` → `OutboxService` → `ViewService` → `CommandRegistry` → `MainViewModel` → `MainWindow`. Every service has a matching interface in `Services/I*.cs`, making them fully substitutable in tests. **ImapMailService** uses MailKit and leases `ImapClient` instances from a bounded per-account pool. Foreground operations (message open, attachment download, mutating user actions) use foreground leases. Background work (sync, UID checks, polling, preview fetches, prefetch) uses background leases capped below the full pool so sync cannot starve interactive work. `MaxImapConnectionsPerAccount` defaults to 6 and is clamped to 1-15. **LocalStoreService** (SQLite via `Microsoft.Data.Sqlite`) caches messages in `mail.db` with WAL journaling. It stores `MessageSummary` rows for list panes and `MessageDetail` rows for body/attachment metadata, and handles column-addition migrations at startup. Schema version is tracked via `PRAGMA user_version` so data migrations run exactly once. **BugReportService** submits **Help → Report a Bug** through a Cloudflare Worker relay rather than to the GitHub API directly, so no GitHub credential ships inside the executable and user-filed issues are authored by a bot rather than the maintainer. It falls back to clipboard + a pre-filled `issues/new` URL whenever the relay is unavailable — including every local build, which carries no relay credentials by design. Operations and troubleshooting: [BUG-REPORTING.md](BUG-REPORTING.md). Relay setup: [`relay/README.md`](../relay/README.md). **SyncService** runs background IMAP sync. It raises `FolderSynced`, `MessagesRemoved`, and `RulesApplied` events; `MainViewModel` subscribes and merges new data into observable collections. UI is populated from the SQLite cache immediately, then background sync fills gaps. **OutboxService** (#637) is the local queue of mail written while the server could not be reached: drafts waiting to upload and messages waiting to send. Rows live in the `Outbox` and `OutboxAttachment` tables as the compose model (JSON) plus attachment blobs — the model rather than MIME, because a MIME round-trip loses Bcc, the Markdown source, the compose mode and the reply linkage. `ComposeViewModel` tries the server first and enqueues on failure; the drain (`FlushAsync`) runs one at a time, oldest first, through the same router and send service as a live send, and classifies every failure with `ConnectionFailure.IsConnectionFailure`: a transport failure keeps the row pending with doubling backoff, a server rejection parks it as Failed for the user. Drains are triggered by the startup connect, the fallback sync tick, the Send Outbox Now command, and (through `IConnectivityService`) a reconnect. `MainViewModel` shows the queue as the `\0Outbox` virtual folder. **ConnectivityService** (#637) is the app's one answer to "are we online?". Two inputs: the machine-level signal from `NetworkChange.NetworkAvailabilityChanged` (behind `INetworkAvailabilitySource`, so tests need no network card) and the outcome of real operations per account, fed by `MainViewModel.ApplyAccountStatus` (the chokepoint every connect outcome passes through) and the folder, message and attachment fetch paths via `NoteOperationOutcome`. The verdict is network-up AND not-every-known-account-offline: one reachable account keeps the app online while the account list shows the others' state. Going offline is debounced five seconds so a blip never announces itself; coming back publishes at once. The service is told, never subscribes to the backends itself. `MainViewModel` derives `ConnectionStatusText` from its verdict plus a phase (`ConnectionStatusFor`), announces "Offline. Showing cached messages." / "Back online." once each, reconnects every offline account when the network returns, retries on a backoff (30 s doubling to 5 min) while the network is up but nothing answers, and drops an account from `_connectedAccountIds` when it goes unreachable — the one place an id leaves that set other than account removal. The Outbox drains on the same events. Null in ui-probe mode, which is structurally offline and must not say so in its captures. **SoundService** (#912) plays QuickMail's sounds on one background worker from a short queue. Its methods return at once and never throw, because no audio problem may delay mail, and a player is opened per sound so it follows the Windows default device. `SoundPolicy` holds the flood rules and `NAudioSoundOutput` is the engine, behind `ISoundOutput`. Built-in sounds are embedded resources; custom ones are copied into the profile's `sounds` folder and referred to by a bare file name (`SoundRef`), so a value in a settings file can't point anywhere else on disk. New mail has its own de-duplication set (`MainViewModel.MaybePlayMailSounds`) because the toast path's set is consumed in a tested order. Null in ui-probe mode. See [the spec](planning/sounds-pm-dev-spec.md), including what was measured. **ConnectionFailure** is the one shared answer to "could the server be reached?": a server that answered — even to say no (`SmtpCommandException`, `ImapCommandException`, an HTTP status other than 502/503/504, an authentication failure) — is reachable; a transport failure anywhere in the exception chain (`SocketException`, `IOException`, a timeout, `ServiceNotConnectedException`, `AccountNotConnectedException`) is not. `ImapMailService.IsConnectionDrop` is deliberately separate: it decides whether a pooled connection is worth one transparent retry, a narrower question. **OAuthService** wraps MSAL (`Microsoft.Identity.Client`) for Microsoft 365 / Outlook OAuth2. Token refresh is handled automatically; passwords for OAuth accounts are not stored in Credential Manager. **ConfigService** reads/writes `config.ini` (INI format, human-editable) and `hotkeys.json` (JSON). Settings include `PreviewLines`, `ViewMode`, `SyncDays`, `InitialSyncCount`, `AutoDiscoverOnline`, with optional per-account `[account:{guid}]` overrides. Results are cached after first load. **ProviderCatalog** (`IProviderCatalog`) is the built-in table of well-known mail providers — Gmail, Outlook.com / Microsoft 365, Yahoo, iCloud, and an "Other" catch-all. Each `MailProvider` carries IMAP/SMTP hosts, ports, SSL modes, a default `AuthType` and `BackendKind`, and an optional app-password hint plus the URL where the user creates one. `MatchByEmail` maps an address's domain to a provider; `Resolve(AccountModel)` prefers the persisted `AccountModel.ProviderId` and falls back to matching the IMAP host, so accounts created before the catalog existed still resolve without a migration. A static `ProviderCatalog.IsICloud(account)` replaces what used to be an `ImapHost == "imap.mail.me.com"` comparison duplicated across six files. Values in the catalog are lifted verbatim from the host autofill that was previously hardcoded in `AddAccountViewModel`. Gmail deliberately defaults to `AuthType.Password` with an app-password hint (Google OAuth sign-in is blocked for new accounts, #369). Microsoft deliberately defaults to `BackendKind.ImapSmtp`, preserving the pre-catalog default for new accounts; Graph remains an opt-in "Connection method" under Advanced settings. A second Gmail entry, `gmail-oauth` ("Gmail (sign in with Google)", `IProviderCatalog.GmailGoogleSignIn`), defaults to `AuthType.OAuth2Google` and carries no app-password hint. It exists for the accounts Google authorized before it stopped granting new ones, and is offered only when `FeatureFlag.GoogleAuth` is on — off by default, set from **Settings → Advanced**, `[features] GoogleAuth` in `config.ini`, or `--feature GoogleAuth`. **Its position in the catalog array is load-bearing:** it holds the gmail.com domains (without them `MailProvider.IsOther` would be true and it would behave as the manual-settings catch-all), but sits *after* the plain Gmail entry, and both `MatchByEmail` and `Resolve`'s host fallback take the first match — so every Gmail address resolves to the app-password entry, and the OAuth one is reached only by an explicit pick or a saved `ProviderId`. The gate covers the offer only: no runtime authentication path consults it, so an account already saved as `OAuth2Google` connects normally regardless, and `AccountEditorViewModel.ShowGoogleAuthOption` ORs in `AuthType == OAuth2Google` so such an account is never shown a blank Authentication combo. **AutoDiscoverService** (`IAutoDiscoverService`) finds IMAP/SMTP settings for an address in four tiers, returning the first hit and `null` when all are exhausted: (1) the local `ProviderCatalog` — offline, instant, nothing leaves the machine; (2) Mozilla's autoconfig database at `autoconfig.thunderbird.net`, which receives only the domain; (3) the domain's own Exchange **Autodiscover** endpoint, tried at `autodiscover.` then ``, which receives the full address as Outlook does; (4) the domain's public DNS over DNS-over-HTTPS (`DiscoverySource.DnsMailHost`) — an MX host under `mail.protection.outlook.com` means Exchange Online, an MX under Google's `aspmx.l.google.com` means Google Workspace, and an `autodiscover.` CNAME to `autodiscover.outlook.com` catches Microsoft tenants that front inbound mail with a filtering gateway. Only the domain is sent, and no DNS library is needed — it is `HttpClient` plus `System.Text.Json`. Tier 4 exists because most Microsoft 365 and Google Workspace customers use a custom domain and publish nothing tiers 2–3 can read — `icanbrew.com` is the worked example: ISPDB 404s and both Autodiscover hosts fail to resolve, so tiers 1–3 all miss and the user was asked for an IMAP host that does not exist. **The question this tier asks is deliberately about mail hosting, not about the organisation.** An earlier version asked Microsoft's `getuserrealm.srf` whether the domain had an Entra ID tenant, which is a different question with a different answer: a domain keeps its tenant after its mail moves away (`theideaplace.net`), and companies run their own mail while using Microsoft 365 internally (`fastmail.com`). Both answered yes, got Microsoft's servers filled in, and sent the user's real password to `smtp-mail.outlook.com` for a 535. MX and the autodiscover CNAME answer the question actually being asked. Tiers 2–4 are skipped entirely when `AutoDiscoverOnline` is off. HTTPS only, 4 s per request and 15 s overall, and every failure mode — DNS, TLS, 404, timeout, malformed XML or JSON — falls through to the next tier rather than propagating. Automatic redirects are off (a hop could downgrade to HTTP) but 30x responses are followed manually, HTTPS-only, up to 3 hops, because Microsoft 365 custom domains commonly answer Autodiscover with a 302. Discovered settings that would authenticate in the clear are rejected outright — `socketType` `plain`, Autodiscover `off`, and SMTP port 25 — because `UseSsl=false` maps to MailKit's `StartTlsWhenAvailable`, which connects in plaintext when the server advertises no STARTTLS. It owns an `HttpClient` and is disposed in `App.OnExit`. The Autodiscover client here is also the prerequisite for shared-mailbox detection (#31). The XML parsers (`ParseIspdb`, `ParseAutodiscover`) are `internal static` so they are testable without HTTP; `AutoDiscoverServiceTests` drives them through a stub `HttpMessageHandler`. **ContactService** stores the address book in `contacts.json` and contact groups in `groups.json`. Both files share a single `SemaphoreSlim` load lock so concurrent group and contact writes cannot tear. Upserts by email address (case-insensitive); `SearchContactsAsync` returns up to 10 results ordered by `LastUsedTicks`. Contacts are auto-upserted when mail is sent. Groups (`GroupModel`) are flat, local-only, and keyed by an incrementing integer `Id` with a list of `MemberContactIds`. `LoadAllGroupsAsync` returns groups sorted by `LastUsedTicks` descending; `TouchGroupAsync` bumps the timestamp so a freshly-inserted group sorts to the top. The model exposes computed `ResolvedMemberCount` and `MissingContactCount` (both `[JsonIgnore]`) and a human-readable `Display` ("Name, N members" / "Name, N members (M missing)" / "Name, no members"), always led by the name, plus `MemberSummary` (the same without the name), so the UI does not have to recompute them. The full group surface is on `IContactService`: `LoadAllGroupsAsync`, `CreateGroupAsync`, `RenameGroupAsync`, `DeleteGroupAsync`, `AddMemberAsync`, `RemoveMemberAsync`, `ListGroupsForContactAsync`, `TouchGroupAsync`. A corrupt `groups.json` is renamed to `groups.json.bak-{timestamp}` and treated as empty, the same recovery `FlagService` and `RowLayoutService` use. Client-side rules deliberately do not: see RuleService below. **RuleService** loads/saves client-side mail rules from `rules.json`. Each `MailRule` has AND-combined conditions (FromContains, ToContains, SubjectContains, BodyContains, MustHaveAttachments), the four text ones each gated by a `Use*Condition` flag, and one or more actions (MarkAsRead, MarkAsUnread, CopyToFolder, MoveToFolder, Delete). `Action` is the main one; `Actions` lists them all when there is more than one (#682), and `AllActions()` returns them in the order they run — marking, then copying, then moving or deleting. `Action` is the last of them that a build from before #682 understands — the move or delete, else the marking, and `CopyToFolder` only for a copy-only rule — so such a build, which reads that field alone, does the most of the rule it can rather than nothing. `ApplyRulesAsync` runs on incoming Inbox mail; `ApplyRulesToExistingAsync` runs on demand over the Inbox of the accounts it is handed, and the rules window hands it only the selected account. Every rule belongs to one account (`AccountId`); a legacy rule with a null `AccountId`, an old "all accounts" rule, is copied to each non-Graph account on the first load once accounts exist, and dropped with a log line when there is none. It is never copied onto a Graph mailbox. File writes use an atomic temp-then-rename pattern. A `rules.json` that is there but can't be read or parsed is neither renamed nor read as empty: `LoadRules` throws `RulesFileUnreadableException` (#700), because every writer loads the whole list, changes it and saves it back, so an empty read made the next save wipe the file. Only a missing file or folder counts as no rules. `SaveRules` reads first when nothing has been read yet, so it throws rather than replace such a file. The failure is not cached, so a briefly locked file recovers on the next read. Sync treats it as no rules; the Rules Manager's status line and the status bar report it; a Microsoft 365 conversion remaps saved views and settings and leaves the rules to a later launch. Arriving mail reaches the rules through `SyncService.ApplyRulesToArrivalsAsync`: the store records every message it caches as waiting for rules (`MessageSummary.rules_applied = 0`; rows cached before the column existed default to done), and `SettlePendingRulesAsync` runs the rules on an Inbox's waiting mail that is *arriving* and records all of it done, one pass at a time per Inbox, recording other folders' waiting mail done without running anything. Arriving means past the folder's line in `RulesWatermark` — a higher UID on IMAP (UIDs follow arrival; the Date header doesn't), a receive time no more than an hour before the newest on Microsoft 365 (Exchange can show a message minutes after stamping it), and anything at all on POP3, whose cache holds only what it has downloaded — so older mail a view caches for the first time, when the sync range widens, never goes through rules. An Inbox with no line yet, holding waiting mail the caller didn't fetch itself (a view cached it), gets a first line drawn from the server: the newest of everything the server holds there apart from the waiting mail (POP3, which has no listing, asks its record of collected messages whether this is its first collection instead). The line lives in its own table, and only rises, because a rule that moves mail out deletes those rows; wiping an account's cache (the #366 rebuild, a conversion to Microsoft 365) clears it, and the rebuild baseline draws it again. QuickMail doesn't track UIDVALIDITY: a server that renumbers a folder already breaks its incremental sync, and would also keep rules off that folder's new mail until its UIDs pass the old line. Being cached is deliberately not the test, because the view fetches — opening a folder, All Mail, All Inboxes, saved views — cache mail without running rules, and a message one of them cached first used to be skipped for good (#712); they now ask for a pass straight after caching, through `ISyncService.ApplyPendingRulesAsync`. The store-less `--online` path keeps its own in-session guard. Server-side rules on work/school Microsoft 365 accounts go through **GraphServerRuleService** (the Graph `messageRule` API) instead. `UnifiedRulesViewModel` lists both kinds for one account at a time, and `ServerRuleEditorViewModel` is the one editor for both. A new rule is classified as server or client when it is first saved; an edit keeps the rule's kind. **ViewService** persists `SavedView` objects to `views.json`. A `SavedView` bundles one or more folders with a view mode, filter, sort order, optional `Hotkey` gesture string, `IsDefault` flag, and optional `DaysOfMail` limit. File is written atomically. **TemplateService** persists `MessageTemplate` objects to `templates.json`. Thread-safe via `SemaphoreSlim`; writes use atomic temp-then-rename. Templates are ordered alphabetically by title on load. **CommandRegistry** holds all `CommandDefinition` instances (id, category, title, default gesture, execute action). Commands are registered in `MainWindow`'s constructor. `FindByGesture()` checks user overrides from `hotkeys.json` first, then falls back to defaults. The command palette (`Ctrl+P`) uses `CommandPaletteViewModel` to display and invoke them. **ProfileContext** resolves the data directory for the process. All services that write files derive their paths from it. Supports `--profileDir ` CLI override for alternate profiles (e.g. during testing). All file-writing services take `ProfileContext` in their constructor — do not accept raw `string` paths. **Static utilities** (no DI required): - `ConversationBuilder` — groups messages by normalized subject (strips all leading Re:/Fwd: chains); used for Conversations view mode - `SenderGroupBuilder` — groups messages by From or To; used for From/To view modes - `MimeMessageBuilder` — builds a `MimeMessage` from `ComposeModel` + `AccountModel`; shared by `SmtpService` and `ImapMailService` (draft saving) - `AddressParser` — splits comma/semicolon-delimited address strings into `MailboxAddress` objects - `MessagePropertiesBuilder`, `FolderPropertiesBuilder`, `AccountPropertiesBuilder`, `ContactPropertiesBuilder`, `GroupPropertiesBuilder`, `AttachmentPropertiesBuilder` — transform model objects into `(title, sections[])` for `PropertiesViewModel`. Pure static functions, no DI, testable without UI. Each takes the relevant model(s) and returns a list of `PropertySection` records containing `PropertyItem` (label/value) rows. - `FolderTreeBuilder` — builds `FolderTreeNode` hierarchy from a flat `MailFolderModel` list; auto-detects IMAP path separator (`.` or `/`) ## Helpers **`BatchObservableCollection`** (`Helpers/BatchObservableCollection.cs`) — subclass of `ObservableCollection` that suppresses individual `CollectionChanged` notifications during bulk mutations and fires a single `Reset` when the batch closes. This prevents screen readers from re-announcing the focused item after every insert during background sync. Always use `BeginBatchScope()` in a `using` block (auto-closes on exception); the manual `BeginBatch()` / `EndBatch()` pair is available for explicit `try/finally` sites. Nesting is safe — the outermost scope fires the reset. ## ViewModel State **MainViewModel** owns application state. Key patterns: - Cancellation token sources are scoped by activity: connect, folder load, message load, background sync, and prefetch. - Version stamps discard stale async results after folder/message/view changes. - View modes: Messages, Conversations, From, and To. - `OnFolderSynced` and `OnMessagesRemoved` must use key-based lookups, not repeated `Messages.Any()` / `FirstOrDefault()` scans over large All Mail views. - `_suppressFolderSyncUpdates` silences sync events during startup; `_suppressFilterRebuild` prevents stale filter application during folder transitions. **AddressBookViewModel** owns the Address Book window's two-tab state (Contacts and Groups). It exposes `Groups: ObservableCollection` sorted by `LastUsedTicks` descending and a derived `SelectedGroupMembers: ObservableCollection` rebuilt whenever `SelectedGroup` changes. Confirmation dialogs and screen reader announcements are surfaced via `event Func>? ConfirmRequested` and `event Action? AnnouncementRequested`; the View subscribes and shows the dialog or calls `AccessibilityHelper.Announce` directly. The `InsertGroup(Action)` private helper iterates `SelectedGroupMembers` in recency order and calls the inserter for each — the Compose window sets those inserters via `SetInsertActions(to, cc, bcc)` so a group can be dropped into any address field with one command. **GroupManagerViewModel** is a sibling VM that powers the standalone `GroupManagerWindow` (`Ctrl+Shift+M`); it shares the same `IContactService` instance and raises `GroupsChanged` for the parent to refresh. ## Runtime Modes QuickMail has startup flags that alter which services are available. New code that calls any service must account for the mode it may be running in. | Flag | Effect on services | |---|---| | *(normal)* | All services available. SQLite schema fully created. `LocalStoreService` returns cached data. | | `--online` | SQLite schema is **not created**. `LocalStoreService` methods will throw `SqliteException` on any call. All data must come from the backend (IMAP or Graph). No Outbox: `IOutboxService.IsAvailable` is false, the Outbox folder is not shown, and a draft save or send that cannot reach the server fails in the compose window as it always did. | | `--profileDir ` | All file-based services use the alternate path. No effect on availability. | **Offline is not a mode.** `--online` means the local store is never initialised; being offline means the store is the only source that answers. They are orthogonal: `--online` with no network shows nothing, says "Offline." rather than "Offline. Showing cached messages.", and has no Outbox. Every new local-store call still follows the two-scope fetch pattern below, whichever state the app is in. **Graph accounts in `--online` mode:** fully supported, no behavioral difference. `GraphMailService` has **no `ILocalStoreService` dependency** — every read goes straight to the Graph REST API, so the uncreated SQLite schema never matters. (`GraphMailServiceTests.GraphMailService_HasNoLocalStoreDependency_SoOnlineModeWorks` guards this structurally.) The local-store-first / backend-fallback pattern below lives in `MainViewModel`, which dispatches the fallback through the `MailServiceRouter` to whichever backend owns the account. **The standard fetch pattern for message bodies** — used wherever a message detail is needed: ```csharp MailMessageDetail? detail = null; try { detail = await _localStore.LoadDetailAsync(accountId, folder, uid); } catch { /* local store unavailable (e.g. online mode) — fall through to IMAP */ } if (detail == null) detail = await _imap.GetMessageDetailAsync(accountId, folder, uid, ct); ``` This pattern must use **two separate try/catch scopes** — one for the local store call and one (outer) for the whole method. A single outer catch that covers both the local store and the IMAP call will swallow the local store exception and silently skip the IMAP fallback, leaving the UI blank with no visible error. - ✅ Inner catch around `LoadDetailAsync` only — falls through to IMAP on any failure. - ❌ Single outer catch around both calls — IMAP is never reached when local store throws. When adding any new code that calls `LocalStoreService`, ask: does this work in `--online` mode? If the answer is "it will throw," wrap that call in its own catch with an explicit fallback. ## Virtual Folders Virtual folders use `FullName` sentinel strings starting with `\x00` to distinguish them from real IMAP folders. | FullName | Scope | |---|---| | `\x00AllMail` | Union of all non-excluded folders across all accounts | | `\x00AllInboxes` | Inbox only from each account | | `\x00AllDrafts` / `\x00AllSent` / `\x00AllTrash` | Global drafts/sent/trash | | `\x00AllArchive` | Every account's archive destination | | `\x00AllFlagged` | Flagged messages across all accounts | | `\x00AccountMail:{guid}` | All folders for one account | | `\x00Outbox` | Queued drafts and sends across all accounts (#637), read from the local store only. Not a folder-scoped aggregate, never a startup folder, never a saved-view target; absent in `--online` mode. Enter reopens a row in compose, Delete removes it from the queue. | Trash, Junk, Sent, and Drafts are excluded from `\x00AllMail` via `folder.ExcludeFromAllMail`. When a virtual folder is selected, `MainViewModel` queries multiple real folders and merges results sorted newest-first. `AllInboxes`, `AllDrafts`, `AllSent`, `AllTrash`, and `AllArchive` are the **folder-scoped aggregates**: their contents are the union of a specific set of real folders on every account. `IsFolderScopedAggregate` identifies them and `FolderScopedAggregateSources` resolves the (account, folder) pairs — the first four by `SpecialFolderKind`, `AllArchive` through `ResolveArchiveFolder` so a per-account `ArchiveFolderFullName` override is honoured and the aggregate always shows exactly where **Move to Archive** writes. Both `FetchVirtualFolderAsync` and the live-arrival filter in `OnFolderSynced` read that one helper, so they resolve membership by the same rule rather than each reimplementing it. They still resolve it at different *times* — the list at fetch, the filter at sync — so anything that changes an aggregate's sources while it is displayed must reload it. `AllArchive` is the only one a user action can change (`SetArchiveFolder`), and it reloads for exactly that reason. `AllMail` (union of everything non-excluded) and `AllFlagged` (a message-level predicate) resolve their sources differently and are deliberately not in this family.