From 8b66f46d46472f97cbde58467778bcd793e0a14d Mon Sep 17 00:00:00 2001 From: Timothy Spaulding Date: Tue, 28 Jul 2026 08:09:43 -0400 Subject: [PATCH] =?UTF-8?q?Docs:=20shared-mailbox=20spec=20(#31)=20?= =?UTF-8?q?=E2=80=94=20rebase=20onto=20main=20+=20refresh=20for=20what=20s?= =?UTF-8?q?hipped?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebuilt on current main and refreshed to match what actually shipped since the 2026-06-09 draft, per review on #59: - §1: status note — the Graph backend, M365 default-on (0.8.35), per-account rules (#364), and server rules (#333/#367) have all landed; shared mailboxes are next after the unified rules manager. The design still holds. - §5.2: the linked-account model's reuse is no longer speculative — #364 made rules per-account and the shipped Graph backend runs the same per-account pipeline, which is the strongest argument for Approach B. - §6.1: corrected the scope model — OAuthService no longer uses one static `Scopes` array; the EWS discovery scope is a separate resource requested on its own. Fixed the consent claim: `.default` does NOT re-prompt existing users for a newly-added scope (Example 3, #391/#392) — it must be requested explicitly. - §6.2/6.3: collapsed the app-registration/consent explanation to reference docs/ENTRA-APP-REGISTRATION.md (now authoritative) instead of restating it, to stop the two drifting; kept only the shared-mailbox-specific EWS points. - §14: refreshed the sequencing note. Dropped the two stale graph-backend-spec edits from the old branch — that consent-model content now lives, more completely, in ENTRA-APP-REGISTRATION.md. Co-Authored-By: Claude Opus 4.8 --- docs/planning/shared-mailboxes-pm-dev-spec.md | 225 ++++++++++++++++++ 1 file changed, 225 insertions(+) create mode 100644 docs/planning/shared-mailboxes-pm-dev-spec.md diff --git a/docs/planning/shared-mailboxes-pm-dev-spec.md b/docs/planning/shared-mailboxes-pm-dev-spec.md new file mode 100644 index 00000000..b9f11277 --- /dev/null +++ b/docs/planning/shared-mailboxes-pm-dev-spec.md @@ -0,0 +1,225 @@ +# Shared Mailboxes (M365 / Exchange) — PM + Dev Specification + +**Issue:** [#31](https://github.com/kellylford/QuickMail/issues/31) +**Status:** Proposed — **parked behind the Microsoft Graph backend work.** This spec is submitted for review now so the design is agreed; implementation does not start until the Graph backend PRs (dev spec PRs 4–8) and the rest of the in-flight roadmap are complete. + +--- + +## Table of Contents + +- [1. Executive Summary](#1-executive-summary) +- [2. User Problem & Opportunity](#2-user-problem--opportunity) +- [3. How It Works (the three mechanisms)](#3-how-it-works-the-three-mechanisms) + - [3.1 Access — existing IMAP/OAuth stack, no Graph](#31-access--existing-imapoauth-stack-no-graph) + - [3.2 Auto-detection — Exchange Autodiscover](#32-auto-detection--exchange-autodiscover) + - [3.3 Generic IMAP — RFC 2342 NAMESPACE](#33-generic-imap--rfc-2342-namespace) + - [3.4 Why not Graph](#34-why-not-graph) +- [4. Scope](#4-scope) +- [5. Data Model & Storage](#5-data-model--storage) + - [5.1 Representing a shared mailbox — two approaches](#51-representing-a-shared-mailbox--two-approaches) + - [5.2 Recommended: linked-account model](#52-recommended-linked-account-model) + - [5.3 SQLite impact](#53-sqlite-impact) +- [6. Authentication, Scopes & the Microsoft Consent Model](#6-authentication-scopes--the-microsoft-consent-model) + - [6.1 The scopes](#61-the-scopes) + - [6.2 App registration & consent — see ENTRA-APP-REGISTRATION.md](#62-app-registration--consent--see-docsentra-app-registrationmd) +- [7. Autodiscover Client](#7-autodiscover-client) +- [8. IMAP Connection for a Shared Mailbox](#8-imap-connection-for-a-shared-mailbox) +- [9. User Experience](#9-user-experience) +- [10. Accessibility](#10-accessibility) +- [11. PR Breakdown](#11-pr-breakdown) +- [12. Edge Cases & Error Handling](#12-edge-cases--error-handling) +- [13. Testing](#13-testing) +- [14. Open Questions for Review](#14-open-questions-for-review) + +--- + +## 1. Executive Summary + +Add support for **Microsoft 365 / Exchange Online shared mailboxes** (e.g. `support@company.com`, `info@company.com`) so they appear alongside the signed-in user's own account — and **auto-detect** which shared mailboxes the user already has access to, rather than making them type addresses by hand. + +The headline finding from #31: **shared-mailbox *access* needs no new technology** — it works on QuickMail's existing IMAP + OAuth2 stack today. Only **auto-detection** requires new plumbing (Exchange Autodiscover and one additional OAuth scope). A manual "add by address" fallback covers the cases auto-detection can't reach. + +This is **independent of the Graph backend** (it neither needs nor is helped by Graph — see §3.4), so it can ship on the IMAP stack whenever the roadmap reaches it. + +> **Status refresh (2026-07-28).** This spec was written 2026-06-09 and deliberately parked behind the Graph/M365 work, which has since shipped end to end: the Graph backend (read/send/mutations/folders/change-notifier), **M365/Exchange default-on in 0.8.35**, per-account rules migration (#364), and the server-rules stack (#333/#367). None of that invalidates the design below — the access mechanism is still the IMAP/OAuth stack, and #364 in particular **de-risks** it (the per-account pipeline this spec reuses is now shipped and proven, not speculative). Two things did move and are corrected here: the OAuth **scope model** was rebuilt (per-resource `.default` for work/school, explicit scopes for personal — §6), and the **Microsoft consent model is now authoritatively documented in `docs/ENTRA-APP-REGISTRATION.md`**, which §6 now references instead of restating. Shared mailboxes are the next roadmap item after the unified rules manager. + +## 2. User Problem & Opportunity + +Shared mailboxes are ubiquitous in workplaces — support/info/sales aliases that a team monitors collectively. Today a QuickMail user with Full Access to `support@company.com` has no way to open it. Outlook surfaces such mailboxes automatically (via automapping); a screen-reader-friendly client that does the same — **and announces them clearly** — is a strong differentiator versus both Outlook (accessibility friction) and Thunderbird (no automapping). + +**Target persona:** a blind/low-vision worker on a corporate M365 tenant who is a delegate on one or more team mailboxes and wants them to "just appear" after sign-in. + +## 3. How It Works (the three mechanisms) + +### 3.1 Access — existing IMAP/OAuth stack, no Graph + +An O365 shared mailbox is opened over IMAP with OAuth2 by authenticating with the **delegate user's own access token** while putting the **shared mailbox's SMTP address** in the XOAUTH2 `user=` field: + +```csharp +// token = the signed-in delegate user's access token (already obtained today) +new SaslMechanismOAuth2(sharedMailboxAddress, accessToken) +``` + +The scope QuickMail already requests — `IMAP.AccessAsUser.All` — is sufficient for access, **provided the user has Full Access** to the mailbox. No new scope is needed for access itself. (refs: [MailKit #1486](https://github.com/jstedfast/MailKit/issues/1486), [Microsoft: OAuth for IMAP/POP/SMTP](https://learn.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth)). + +Practically: connect to `outlook.office365.com:993`, SSL, authenticate with `SaslMechanismOAuth2(sharedAddress, token)`, then enumerate folders normally. Sending uses the analogous SMTP XOAUTH2 with the shared address as `user=`, so replies come *from* the shared mailbox. + +### 3.2 Auto-detection — Exchange Autodiscover + +Discover the user's shared mailboxes by calling Exchange **Autodiscover** and parsing the `AlternativeMailbox` entries (display name + SMTP address) — the same automapping data Outlook consumes. + +- **Requires the `EWS.AccessAsUser.All` OAuth scope** — *for discovery only*; access itself (§3.1) needs no new scope. **This also requires the Azure app registration behind the shared client ID to grant `EWS.AccessAsUser.All`.** That is an external prerequisite owned by the app-registration maintainer (Kelly) — see §14. +- Autodiscover endpoint (SOAP): `POST https://autodiscover-s.outlook.com/autodiscover/autodiscover.svc` with the user's OAuth bearer token, requesting the user settings that include the automapped `AlternativeMailbox` collection. +- Mailboxes granted with **automapping disabled** are *not* returned by Autodiscover — hence the manual fallback (§9). + +### 3.3 Generic IMAP — RFC 2342 NAMESPACE + +Non-O365 IMAP servers expose shared / other-user mailboxes via the **RFC 2342 NAMESPACE** extension: the `SharedNamespaces` and `OtherUsers` namespaces. MailKit surfaces these on `ImapClient.PersonalNamespaces` / `SharedNamespaces` / `OtherUsers`. Less reliable and less common than Autodiscover, but worth enumerating where the server advertises NAMESPACE — it gives the same feature to non-Microsoft IMAP accounts at low marginal cost. + +### 3.4 Why not Graph + +The **Graph API cannot return auto-mapped mailboxes** — confirmed in the issue. Autodiscover is the only path to the automapping list, even after the planned Graph backend lands. So this feature deliberately sits on the IMAP stack and does **not** wait on, or benefit from, the Graph work. (Graph accounts could later gain shared-mailbox *access* via Graph's `/users/{shared}/messages` with delegated permissions, but detection still routes through Autodiscover.) + +## 4. Scope + +**In scope** +- Auto-detect O365 shared mailboxes for OAuth2 accounts via Autodiscover. +- Open and read shared mailboxes over IMAP (delegate token + shared address in XOAUTH2 `user=`). +- Send *from* a shared mailbox over SMTP XOAUTH2. +- Manual "Add shared mailbox by address" fallback. +- Enumerate RFC 2342 `SharedNamespaces` / `OtherUsers` for generic IMAP accounts where advertised. +- Folder-tree UI surfacing each shared mailbox, with screen-reader announcements. + +**Out of scope (future)** +- Permission management (granting/revoking Full Access) — read/use only. +- Shared-mailbox calendar/contacts. +- POP3 shared access. +- Per-shared-mailbox rules (rules continue to operate per owning account; revisit if requested). + +## 5. Data Model & Storage + +### 5.1 Representing a shared mailbox — two approaches + +**Approach A — mailbox dimension on the cache key (issue's suggestion).** Add a `mailbox` column to the SQLite PK `(message_id, account_id, folder_name)` → `(message_id, account_id, mailbox, folder_name)`, and nest shared mailboxes under the parent account in the folder tree. Requires a schema migration (v3 → v4) and threading a `mailbox` argument through `IMailService`/`ILocalStoreService`/sync — a wide change, echoing PR 2. + +**Approach B — linked-account model (recommended).** Model each shared mailbox as its own account-like entity with its **own `Guid` Id** but `AuthLinkedAccountId` pointing at the parent for token acquisition. The existing `(message_id, account_id, folder_name)` key then separates mailboxes for free (each has a distinct `account_id`), and the entire per-account pipeline — folder tree grouping, sync loop, `MailServiceRouter` registration, IMAP connection pool — is reused unchanged. **No schema migration.** + +> This reuse is no longer speculative: **#364 made rules per-account and retired "All accounts,"** and the shipped Graph backend runs the same per-account sync/router/connection machinery today. The linked-account approach rides pipelines that are now in production, which is the strongest argument for B over A. + +### 5.2 Recommended: linked-account model + +```csharp +// AccountModel additions: +public bool IsShared { get; set; } // true for a discovered/added shared mailbox +public Guid? ParentAccountId { get; set; } // the delegate account whose token authorizes access +public string SharedAddress { get; set; } = ""; // SMTP address placed in the XOAUTH2 user= field +``` + +- A shared mailbox is persisted in `accounts.json` like any account but flagged `IsShared` and linked to its parent. It holds **no credentials of its own**; `OAuthService` acquires the token for `ParentAccountId` and the IMAP/SMTP layers pass `SharedAddress` as the XOAUTH2 username. +- `ImapMailService` already pools connections by `accountId`; a shared mailbox gets its own pool keyed by its own Id, connecting with `SaslMechanismOAuth2(SharedAddress, parentToken)`. +- `MailServiceRouter.RegisterAccount` registers shared mailboxes to the IMAP backend exactly like primary accounts. + +**Trade-off to confirm in review:** Approach B shows shared mailboxes in the account list as first-class entries (grouped/indented under the parent in the folder tree). Approach A nests them more tightly but is a far larger change. Recommendation: **B**, for the dramatically smaller blast radius and reuse of proven machinery. (This is the main open design question — §14.) + +### 5.3 SQLite impact + +- **Approach B: none** — no schema change; shared mailboxes are just more `account_id`s. +- Approach A (if chosen): schema v3 → v4 rebuild-and-rename migration adding `mailbox` to both tables' PK, plus a `mailbox` parameter across the store/service interfaces. (The v2 migration in `LocalStoreService` is the template.) + +## 6. Authentication, Scopes & the Microsoft Consent Model + +### 6.1 The scopes + +`OAuthService` no longer uses a single static `Scopes` array — it selects per resource and per account type (`ImapSmtpScopes` for IMAP/SMTP; per-resource `.default` for work/school Graph; explicit `GraphMailScopesPersonal` for personal accounts). Shared-mailbox **access** (§3.1) rides the existing IMAP/SMTP path and needs **no new scope**. Only **auto-detection** adds the EWS scope, requested on its own — EWS is a different resource host (`outlook.office365.com`) from the IMAP scopes (`outlook.office.com`), so it can't ride the same token request: + +```csharp +// New: discovery-only, requested when auto-detection first runs. +public static readonly string[] AutodiscoverScopes = +[ + "https://outlook.office365.com/EWS.AccessAsUser.All", +]; +// (offline_access is added by MSAL automatically.) +``` + +- **Consent impact — corrected from the original draft.** Adding a scope does **not** silently re-prompt existing users. `.default` (and any existing grant) returns what's already granted and never drives incremental consent for a newly-added permission — Microsoft's "`.default`" **Example 3**, the mechanism behind #391/#392. So the EWS scope must be **requested explicitly** the first time discovery runs (exactly the pattern `RequestContactsConsentAsync` / `RequestCalendarConsentAsync` already use), which drives a one-time consent; or an admin re-grants consent for the org. A brand-new account picks it up through the add-time `Prompt.Consent` only if EWS is declared before that user signs in. +- Access does not depend on this scope; if discovery is unavailable (scope absent / tenant blocks EWS), the feature degrades gracefully to the manual "add by address" fallback. + +### 6.2 App registration & consent — see `docs/ENTRA-APP-REGISTRATION.md` + +The original draft explained the Microsoft app-registration and consent model in full here. That content is now documented **authoritatively in `docs/ENTRA-APP-REGISTRATION.md`** (and the Graph specs), so it is not restated — to avoid the two drifting. In short: one registration is QuickMail's own identity in the maintainer's tenant; the maintainer **declares** scopes once, each user's tenant **consents** at sign-in, and end users never create a registration. The registration is multi-tenant + personal-MSA (a prerequisite the existing OAuth path already relies on). + +**Shared-mailbox-specific points:** +- The **`EWS.AccessAsUser.All`** scope for auto-detection (§6.1) must be **declared** on the registration like any other — a one-time maintainer action, user-consentable by default. Confirm it isn't blocked by an admin-consent-required tenant policy (the same GA-checklist check applied to `MailboxSettings.ReadWrite`). +- **Access** itself (§3.1) uses scopes the registration already declares (`IMAP.AccessAsUser.All` + `SMTP.Send`), so it adds **no** new consent surface — only detection does. +- Tenant-policy friction (admin-consent handling via `AADSTS65001`/`90094` + an admin-consent URL, the unverified-publisher warning, the per-account custom-client-ID escape hatch for locked tenants) is handled exactly as the Entra doc describes; none of it is shared-mailbox-specific. + +## 7. Autodiscover Client + +New `Services/AutodiscoverClient.cs` (raw `HttpClient`, consistent with the Graph dev spec's SDK-avoidance rationale): + +- Input: parent `AccountModel` + delegate access token (with EWS scope). +- `POST` the SOAP `GetUserSettings` request to `https://autodiscover-s.outlook.com/autodiscover/autodiscover.svc`, `Authorization: Bearer {token}`, requesting `AlternativeMailbox` (and the EXternal/EWS URL settings as needed). +- Parse the `AlternativeMailbox` entries → list of `(DisplayName, SmtpAddress, Type)`; keep `Type == "Delegate"`/shared entries. +- Honor `Retry-After` on 429; treat 401/403 (no EWS consent) as "discovery unavailable" → fall back to manual, do not error the account. +- Returns an empty list (not an exception) when nothing is auto-mapped. + +## 8. IMAP Connection for a Shared Mailbox + +- Reuse `ImapMailService`'s per-account pool, keyed by the shared mailbox's own Id. +- On connect: acquire the token for `ParentAccountId` via `OAuthService`; build `new SaslMechanismOAuth2(account.SharedAddress, token)`; connect to the parent's IMAP host (`outlook.office365.com:993`, SSL). +- Folder enumeration, sync, message open, mark-read, move, etc. all flow through the existing IMAP code unchanged — the only difference is the SASL username. +- **SMTP:** sending from a shared mailbox uses the same delegate token with `SaslMechanismOAuth2(sharedAddress, token)` so the `From` is the shared address. Confirm the tenant permits "Send As"/"Send on Behalf"; surface a clear error if the server rejects the From. + +## 9. User Experience + +- **Folder tree:** each shared mailbox appears as its own top-level node (Approach B) labelled with its display name + address, with its folders nested beneath — visually and in the accessibility tree grouped after the owning account. +- **Auto-detection timing:** run Autodiscover after a successful OAuth connect; newly discovered mailboxes are added to `accounts.json` (flagged `IsShared`) and the folder tree, with a status announcement ("2 shared mailboxes detected: Support, Info"). +- **Manual fallback:** an "Add shared mailbox…" action (Account Manager / folder-tree context menu) prompts for an SMTP address and the parent account, then connects directly — covering automapping-disabled grants. +- **Removal:** a shared mailbox can be removed from the view without affecting the parent account. +- **No duplicate auth:** shared mailboxes never prompt for their own password/sign-in; they piggyback the parent's token. + +## 10. Accessibility + +- Each shared mailbox node carries a full `AutomationProperties.Name` ("Support, shared mailbox, 3 unread"), mirroring the account-node pattern. +- Detection results announced via `AccessibilityHelper.Announce(..., AnnouncementCategory.Status)`; the manual-add outcome as `Result`. +- The "Add shared mailbox" dialog follows the existing dialog accessibility rules (labelled fields via `LabeledBy`, focus on open, no instructional text baked into `AutomationProperties.Name`). +- No screen-reader product names in any UI text or docs. + +## 11. PR Breakdown + +Sequenced for small, independently reviewable PRs (estimates assume the linked-account model, Approach B): + +1. **Scope + model (1 evening):** add `EWS.AccessAsUser.All`; add `IsShared`/`ParentAccountId`/`SharedAddress` to `AccountModel`; serialization defaults keep existing accounts unchanged. +2. **Shared-mailbox IMAP access (2–3 evenings):** `ImapMailService` connects with `SaslMechanismOAuth2(SharedAddress, parentToken)` when `IsShared`; `OAuthService` token lookup follows `ParentAccountId`. Manual "add by address" path end-to-end (no detection yet). +3. **Autodiscover client + detection (3–4 evenings):** `AutodiscoverClient`; post-connect detection; merge results into the account list; graceful degradation. +4. **Folder-tree UI + announcements (2–3 evenings):** render shared mailboxes, manual-add affordance, removal, accessibility. +5. **Generic IMAP NAMESPACE (1–2 evenings):** enumerate RFC 2342 `SharedNamespaces`/`OtherUsers` where advertised. +6. **Tests + smoke (1–2 evenings):** Autodiscover parsing (canned SOAP responses via `HttpMessageHandler` stub), model serialization, router registration of shared mailboxes; manual smoke on a real tenant. + +(If Approach A is chosen instead, insert a schema-migration PR after #1 and widen #2 — adds ~3–5 evenings.) + +## 12. Edge Cases & Error Handling + +| Situation | Behavior | +|---|---| +| User lacks Full Access to a discovered mailbox | IMAP connect fails; mark the shared node errored with a clear message; don't block the parent account | +| Automapping disabled for a grant | Not returned by Autodiscover; user adds it via manual fallback | +| EWS scope not consented / tenant blocks EWS | Discovery returns "unavailable"; feature silently falls back to manual; no account error | +| Shared address already added (auto + manual) | De-dupe by `SharedAddress` (case-insensitive) | +| Parent account removed | Cascade-remove its shared mailboxes from the view | +| Token refresh while a shared pool is open | Existing per-account refresh applies; shared pool uses the parent's refreshed token | +| Non-Exchange IMAP without NAMESPACE | No shared mailboxes offered; manual add still available | +| "Send As" denied by tenant | Surface the SMTP rejection clearly; the mailbox remains readable | + +## 13. Testing + +- **Unit:** `AutodiscoverClient` parsing against canned SOAP `AlternativeMailbox` payloads via an `HttpMessageHandler` stub (mirrors the planned `GraphMailServiceTests` pattern); `AccountModel` serialization round-trip with the new fields; de-dup logic; `MailServiceRouter` registration of `IsShared` accounts. +- **No live credentials in tests** — all network paths stubbed, per `StubServices` conventions. +- **Manual smoke (per the dev-spec checklist style):** on a real tenant with a Full-Access shared mailbox — sign in, confirm auto-detection, open the shared mailbox, read/mark-read/move a message, reply (verify `From` = shared address), add a second mailbox manually, remove one. + +## 14. Open Questions for Review + +1. **Azure app registration (blocking):** are you able to add `EWS.AccessAsUser.All` (Office 365 Exchange Online, delegated) to the shared client ID's app registration, and is admin consent needed for your tenant? Discovery can't be built end-to-end without it (access works regardless). +2. **Model approach:** OK to go with the **linked-account model (Approach B)** — shared mailboxes as first-class, parent-linked accounts, no schema migration — rather than the cache-key `mailbox` dimension from the issue? This is the single biggest design decision. +3. **Send-As policy:** is sending *from* a shared mailbox in scope for v1, or read-only first with send as a fast-follow? +4. **Sequencing:** the Graph backend and server-rules work this was parked behind have now shipped (see the status refresh under §1). Shared mailboxes are the next roadmap item after the unified rules manager (§20 of the server-rules spec) lands.