Skip to content

Account setup: provider presets, settings discovery, and progressive disclosure - #393

Merged
kellylford merged 11 commits into
mainfrom
feat/account-setup-providers
Jul 27, 2026
Merged

Account setup: provider presets, settings discovery, and progressive disclosure#393
kellylford merged 11 commits into
mainfrom
feat/account-setup-providers

Conversation

@kellylford

Copy link
Copy Markdown
Owner

Reworks account setup so adding an account asks for three things — provider, address, password — and derives the rest. The full manual form is one collapsed expander away.

Closes the gap that made #369 painful to work around, and supersedes #388.

Why

The Add Account dialog asked for IMAP host, port, SSL, cert, SMTP host, port, SSL, and cert, even when QuickMail already knew every one of those values. The only provider knowledge that existed was hardcoded domain-sniffing in two methods of AddAccountViewModel, so adding a provider meant editing them.

What changed

Provider catalog — Other (first, so Down arrow reaches the rest), Gmail, Outlook.com / Microsoft 365, Yahoo, iCloud. AccountModel.ProviderId persists the choice; Resolve falls back to matching the IMAP host, so accounts created before the catalog need no migration.

Four-tier settings discovery — built-in catalog, Mozilla autoconfig, the domain's Exchange Autodiscover endpoint, then the domain's public DNS. Tiers 2–4 are off when AutoDiscoverOnline=off.

Tier 4 asks where the domain's mail is actually delivered (MX under mail.protection.outlook.com, MX under Google's, or an autodiscover CNAME to Microsoft). An earlier attempt asked Microsoft's realm endpoint whether the domain had a tenant, which is a different question: a domain keeps its tenant after its mail moves away, and companies run their own mail while using M365 internally. Both answered yes, both got Microsoft's servers, and the user's real password went to smtp-mail.outlook.com for a 535. Uses DNS-over-HTTPS through the existing HttpClient — no DNS dependency — and sends only the domain.

Gmail defaults to an app password, since Google OAuth sign-in is currently blocked for new accounts (#369, #226). Google sign-in stays available under Advanced settings. Yahoo and iCloud need app passwords too, and say so with a link.

Work-or-school Microsoft accounts connect over Graph. The IMAP path requests outlook.office.com/IMAP.AccessAsUser.All + SMTP.Send, which most tenants have never consented to, so sign-in ended at "your administrator needs to make a change". Consumer Outlook.com/Hotmail/Live keep IMAP — no admin-consent model, and IMAP+OAuth works. A personal account on a vanity domain is corrected back to IMAP once sign-in reveals the tenant id (#233's signal), because the domain guess can't tell those apart.

Test Connection now probes both legs and reports each separately — a working inbox with a misconfigured send server used to pass and then fail on first send — and probes Graph accounts via GET /me. MailServiceRouter gained a backend selector so an unregistered probe account routes by its BackendKind instead of defaulting to IMAP.

Dialogs — server settings behind an Advanced expander in both dialogs, matching the rules editor. Field hints go through AccessibilityHelper.Announce rather than AutomationProperties.HelpText, so AnnounceHints actually governs them.

Security posture of discovery

Discovery accepts settings on the user's behalf, so it is deliberately stricter than manual entry:

  • Encryption is required, not preferred. UseSsl=false maps to MailKit's StartTlsWhenAvailable, which connects in plaintext and authenticates anyway when the server offers no STARTTLS. New AccountModel.RequireStartTls maps to StartTls, which fails instead. Set for the catalog and every discovery tier; cleared the moment the user edits a server field, because settings they typed are their call. Existing accounts.json deserializes unchanged.
  • Redirects are host-restricted. Only the host just asked, the queried domain, or an explicit Microsoft list (outlook.com, office365.com, microsoftonline.com) — present solely because M365 custom domains 302 to autodiscover-s.outlook.com. 301/302/303 downgrade to a bodyless GET so the Autodiscover envelope, which carries the user's address, never goes out twice.
  • Response bodies capped; HTTPS only; only the domain leaves the machine for tiers 2 and 4.

Testing

1859 unit tests (Release), plus live discovery checks against real domains behind QUICKMAIL_LIVE_DISCOVERY=1, covering both false-positive shapes: a domain that left Exchange Online, and a tenant whose mail is elsewhere.

Two reviews ran over this branch. Every fix from the second was verified by reverting the production change and confirming the specific test fails — which caught five tests that would have passed with their fix reverted, including hint tests that were text greps and would have passed if the hints had been deleted rather than moved.

Relationship to other PRs

kellylford and others added 11 commits July 27, 2026 10:30
…cover

Adding an account asked for IMAP host, port, SSL, cert, SMTP host, port, SSL,
and cert even when QuickMail already knew every one of those values. The only
provider knowledge that existed was hardcoded domain-sniffing in two methods of
AddAccountViewModel, so adding a provider meant editing them.

Now the dialog asks for a provider, an email address, and a password. Everything
else is derived, and the full manual form is one collapsed expander away.

- ProviderCatalog: Gmail, Outlook.com / Microsoft 365, Yahoo, iCloud, Other.
  Each MailProvider carries hosts, ports, SSL modes, default auth, and an
  app-password hint plus the URL where the user creates one. AccountModel gains
  ProviderId; Resolve falls back to matching the IMAP host, so accounts created
  before the catalog need no migration.
- AutoDiscoverService: local catalog, then Mozilla autoconfig (domain only),
  then the domain's own Exchange Autodiscover endpoint. HTTPS only, no redirect
  following, per-tier and overall timeouts, and every failure falls through
  rather than propagating. Off switch: AutoDiscoverOnline in config.ini. This
  Autodiscover client is also the prerequisite for shared mailboxes (#31).
- Gmail defaults to an app password with a prominent hint, since Google OAuth
  sign-in is blocked for new accounts (#369, #226). Google sign-in stays
  available under Advanced settings.
- Add Account and Manage Accounts both move server settings behind an Advanced
  settings expander, matching the pattern already used by the rule editor.
  Microsoft's IMAP-vs-Graph choice becomes "Connection method" inside it, still
  defaulting to IMAP so no new account silently changes backend.
- Test Connection now probes SMTP as well as IMAP and reports each leg
  separately: a working inbox with a misconfigured send server used to pass and
  then fail on first send. Graph accounts are probed via GET /me instead of
  being told to press a different button. First test coverage this command has
  ever had.
- ProviderCatalog.IsICloud replaces an ImapHost == "imap.mail.me.com"
  comparison that was duplicated across six files.

Announcements follow the category rules: provider consequences and app-password
requirements are Hints, lookup progress is Status, lookup and verification
outcomes are Results. No instructional text is baked into any
AutomationProperties.Name, and MailProvider overrides ToString so the provider
combo announces "Gmail" rather than its type name.

70 new tests; full suite 1741 passing in Release.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Security
- Discovery never accepts a cleartext configuration. UseSsl=false maps to MailKit's
  StartTlsWhenAvailable, which connects in plaintext and authenticates anyway when the
  server advertises no STARTTLS — so a socketType of "plain", an Autodiscover
  <SSL>off</SSL>, or SMTP on port 25 would have let whoever answers for a typosquatted
  domain name a server that harvests the password in the clear, with the user seeing only
  "Settings found" because Advanced settings stays collapsed. All three are now rejected.
  A user who types a plaintext host into Advanced settings themselves is unaffected.
- "Settings found" now names the discovered hosts. They are about to receive a password
  and they arrived over the network, so they are stated rather than applied silently.
- Cap discovery responses at 512 KB.
- Follow 30x responses manually, HTTPS-only, max 3 hops. Automatic redirects stay off (a
  hop could downgrade to HTTP) but treating every redirect as "nothing here" made the
  Microsoft 365 custom-domain path — a 302 to autodiscover-s.outlook.com — dead.

Correctness
- ProviderCatalog.Resolve no longer treats a persisted "other" as an answer. It meant an
  iCloud account added via Other with the Apple host typed by hand resolved as Other
  forever, so IsICloud said false, contact and calendar sync silently never ran, and the
  checkboxes vanished from Manage Accounts so it could not be fixed.
- Correcting a typo'd address now drops the old provider. Typing kelly@gmail.com and then
  editing it left Gmail selected, saved the account against imap.gmail.com, and skipped
  the settings lookup entirely because a provider was already chosen.
- MailServiceRouter routes an unregistered account by its BackendKind. Test Connection's
  probe account uses a throwaway Guid, so the Graph branch was probing the IMAP backend
  against a host it had just cleared and reporting the IMAP error as a Graph failure.
- Discovery is invokable while a lookup is in flight (AsyncRelayCommand reported
  CanExecute=false, silently dropping the second lookup so the first one's servers landed
  on the corrected address), and a result whose domain no longer matches is discarded.
- Switching Connection method back to IMAP no longer collapses the expander the user is
  standing in, which stranded keyboard focus on the window with no announcement.
- Port and SSL changes count as user edits, not just host names.
- Add Account refuses to save without servers and moves focus to what is missing; the
  fields are behind a collapsed expander now, so this was silently saveable.
- The View is told when the VM clears the password, so the PasswordBox stops showing dots
  for a password that no longer exists.

Accessibility
- Manage Accounts' provider value is announced. An explicit AutomationProperties.Name
  overrides a TextBlock's automatic name, so it read "Provider" twice and never the value.
  Also dropped Focusable, which put it last in the tab order despite being visually first.
- App-password link gets a TabIndex; it was landing after the signature box.
- Resolved three new access-key collisions (SSL/calendar, method/Microsoft, signature/Google).
- Trimmed AutomationProperties.Name values back to short labels.

35 new tests, one per fix; full suite 1776 passing in Release.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Tab order
Opening Advanced settings put every control it revealed BEHIND the header that
revealed them, so reaching them meant Shift+Tab backwards. Cause: the Expander's
header button takes WPF's default TabIndex of int.MaxValue, while the content had
explicit 10-20, so the content sorted first. Measured, not guessed — the order was

  ... DisplayNameBox -> AuthTypeCombo -> ImapHostBox -> ... -> SignatureBox -> HeaderSite -> Cancel

Removing the explicit indices inside both expanders leaves header and content tied
at the default, and WPF falls back to tree order:

  ... DisplayNameBox -> HeaderSite -> AuthTypeCombo -> ImapHostBox -> ... -> Cancel

AddAccountTabOrderTests walks real WPF focus traversal (MoveFocus, not TabIndex
values) so this cannot silently come back.

Microsoft 365 custom domains
A work account on its own domain — the common shape for business mail — fell all
the way through to "enter your IMAP host", for an account whose only way in is a
browser sign-in. Verified against a real tenant: ISPDB 404s, and BOTH Autodiscover
hosts fail to resolve, so no amount of redirect handling reaches them.

Added a fourth tier: Microsoft's getuserrealm lookup, queried with a fixed
"discover@" local part so only the domain leaves the machine. On a hit, the
Microsoft provider is selected and Sign in with Microsoft is put in front of the
user — which is what the previous experience offered and this one had lost.

It is deliberately a suggestion, not a configuration. The lookup answers "does
this domain have an Entra ID tenant", which a company running its own mail but
using Microsoft 365 internally also answers yes to, so the wording is "appears to
be" and changing the provider is one keystroke. Managed/Federated counts only when
DomainName matches the domain asked about: gmail.com and yahoo.com both answer
Federated with DomainName live.com, and must not be read as tenants.

When even that misses, the not-found message now names the sign-in routes instead
of only demanding an IMAP host.

Also: per-request timeout 5s -> 4s and overall 12s -> 15s, so four tiers fit.

Live checks against real domains live in QuickMail.IntegrationTests, gated behind
QUICKMAIL_LIVE_DISCOVERY=1. All four pass: the tenant domain resolves to Microsoft,
a domain with no mail service returns nothing, a catalog domain never touches the
network, and nothing is sent at all when AutoDiscoverOnline is off.

Full suite 1788 passing in Release.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The dialog opens with "Other" selected, so having it last in the list left Down
arrow with nowhere to go — the real providers were unreachable by keyboard from
the position the dialog puts you in. Moved it to the front.

Also adopts the useful half of CityDweller's PR #388: Manage Accounts kept the
Test Connection button visible with no account selected, because
OnSelectedAccountChanged returns early on a null selection and leaves BackendKind
at its default. ShowTestConnection => IsEditing fixes that.

Deliberately NOT gated on IsImapBackend as #388 has it. That gate was right while
Test Connection could only probe IMAP; this branch made it probe Graph accounts
too, via the backend's GET /me, so the gate would now hide the button exactly
where it just started working. Same reason the Add Account dialog leaves it
visible rather than taking #388's IsImapBackend binding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signing in with a Microsoft 365 work address ended at "your administrator needs to
make a change" — the same error the IMAP+OAuth path gave back when Graph was behind
the feature gate. The live app does not do this, because there you pick
"Microsoft 365 / Outlook.com" by hand, which selects the Graph backend.

Cause is scope selection (OAuthService.DefaultScopesFor):

  BackendKind != MicrosoftGraph -> outlook.office.com/IMAP.AccessAsUser.All + SMTP.Send
  BackendKind == MicrosoftGraph -> graph.microsoft.com/.default

.default resolves to exactly the permissions the app registration declares and a
tenant admin has already consented to. Most tenants have never consented to the
IMAP/SMTP pair, and many disable IMAP outright. So the Microsoft provider's
DefaultBackend of ImapSmtp — kept deliberately to avoid changing behaviour — was
the wrong default for precisely the accounts the new realm tier now finds.

A Microsoft account whose domain is NOT one of Microsoft's own consumer domains is
a work or school tenant, so it now moves to the Graph backend. Consumer
Outlook.com/Hotmail/Live accounts are the opposite case — no admin-consent model,
IMAP+OAuth works — and keep the IMAP default, so nothing changes for them. With
the Graph gate off there is nothing better available and the account stays on IMAP.

Two things fixed along the way:

- ApplyDiscovered called ApplyProvider again after assigning SelectedProvider. The
  assignment already applies the provider and any backend preference layered on
  top, so the second call re-applied the provider's DEFAULT backend and silently
  undid the Graph switch. Caught by the end-to-end discovery test.
- Only a provider the typed address selected may be reset when the address stops
  matching. Choosing Microsoft 365 by hand and then typing a work address was
  throwing that choice away mid-typing.

Scope-level tests assert both directions, because the scopes are where this
actually bites.

Full suite 1803 passing in Release; live discovery checks still pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ists

Adding a theideaplace.net account failed with SMTP 535. The domain was on Exchange
Online until March; the mail moved away but the Entra ID tenant stayed. Tier 4 asked
Microsoft "does this domain have a tenant", got yes, filled in
outlook.office365.com / smtp-mail.outlook.com, and the real password for the real
mail host was offered to Microsoft's SMTP server. 535.

The general fault is that the tier asked the wrong question. "Does this organisation
have a Microsoft tenant" is not "is this domain's mail in Exchange Online", and the
two answers diverge in at least two ordinary situations:

  - a domain whose mail has moved away but whose tenant remains (theideaplace.net)
  - a company running its own mail while using Microsoft 365 internally (fastmail.com)

Both answered yes. Both would have been given Microsoft's servers.

Tier 4 now reads the domain's public DNS instead, which states where the mail
actually goes:

  MX under mail.protection.outlook.com   -> Exchange Online
  MX under aspmx.l.google.com            -> Google Workspace
  autodiscover CNAME -> autodiscover.outlook.com

The CNAME is checked as well as MX because organisations commonly front Exchange
Online with a filtering gateway, which replaces the MX while the mailboxes stay put.
Verified against real domains: icanbrew.com resolves to Microsoft, theideaplace.net
and fastmail.com are correctly left alone, wikipedia.org returns nothing.

Uses DNS-over-HTTPS through the existing HttpClient, so no DNS dependency is added,
and only the domain is sent — the same privacy bar as the ISPDB tier, and better than
the realm lookup it replaces, which received an address-shaped string.

Google Workspace detection falls out of the same signal, so custom domains on Google
now resolve to Gmail's servers instead of dead-ending.

Full suite 1809 passing in Release; six live discovery checks pass, including both
false-positive cases as explicit regression tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Arrowing Other -> Gmail wrote "Gmail" into the account name. Arrowing on to
Microsoft did not correct it, because the box was no longer blank — so the account
ended up named Gmail while pointing at Microsoft, unless the user noticed and fixed
it. Worse for anyone not looking at the field, since selecting a provider fires for
every item arrowed past, so passing through a provider was enough to name the
account after it.

Writing into one field as a side effect of changing another is the fault. Removed,
along with the compensating logic that existed only to undo it when the address
stopped matching.

It bought nothing: AccountModel.AccountLabel already falls back to the email address
when the name is blank, which is a better default anyway for anyone with two
accounts at the same provider. The account-name field says so in its help text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AutomationProperties.HelpText is read by the screen reader directly, so it ignores
QuickMail's announcement preferences entirely — a user who has turned hints off
still hears every one. AccessibilityHelper.Announce routes through AnnounceHints,
which is the point of having the setting.

This is the same rule the Accessibility Checklist states for
AutomationProperties.Name; HelpText is the same mistake wearing a different
attribute. Both account dialogs now carry no HelpText at all, and speak their field
hints as AnnouncementCategory.Hint when the field takes keyboard focus, via one
bubbling GotKeyboardFocus handler per dialog.

Guarded by tests that read the XAML: no HelpText in either dialog, the focus handler
is wired in both, and no AutomationProperties.Name is a sentence — a sentence in a
Name is an instruction wearing a label's clothes and bypasses the preferences the
same way.

Full suite 1816 passing in Release.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three defects found by review, each one a way for whoever answers a
lookup to end up holding the account's password.

1. The redirect follower re-sent the address to any HTTPS host.

SendFollowingHttpsRedirectsAsync rebuilt the request from newRequest()
on every hop, so the Autodiscover POST — whose body carries
<EMailAddress>the user's full address</EMailAddress> — went out again at
each new URL, and only the scheme was checked. A 302 from
autodiscover.<domain> to any HTTPS host therefore POSTed the address
there, and that host's <Server> values then became the account's IMAP and
SMTP hosts: not a redirect, a handover. A hop is now allowed only to the
host just asked, to the queried domain or a subdomain of it, or to an
explicit Microsoft list (outlook.com, office365.com, microsoftonline.com)
— which exists because an M365 custom domain legitimately 302s to
autodiscover-s.outlook.com, the case redirect support was added for.
Anything else is refused and logged (domain and refused host; never the
address). 301/302/303 additionally downgrade to a bodyless GET, as
browsers and HttpClientHandler do; 307/308 may keep the method and body,
to an allowed host. ISPDB and DNS-over-HTTPS pass their own fixed service
host as the scope, so a hop off autoconfig.thunderbird.net or
cloudflare-dns.com is refused too. The comment claiming redirects are not
followed was false and is corrected.

2. The cleartext refusal blocked a label, not the downgrade.

ImapUseSsl / SmtpUseSsl false mapped to MailKit's StartTlsWhenAvailable,
which connects in PLAINTEXT and authenticates anyway when the server
advertises no STARTTLS. Rejecting socketType "plain" and <SSL>off</SSL>
removed the label while leaving the mechanism: ISPDB socketType STARTTLS,
Autodiscover IMAP on 143, and Autodiscover SMTP on 587 all still produced
UseSsl == false, so a hostile or typosquatted responder could name a host
that simply never offers STARTTLS and collect the password in the clear,
while the dialog said only "Settings found". AccountModel gains a
persisted RequireStartTls (default false, so accounts already in
accounts.json are unchanged), and the new MailSecurity maps UseSsl to
SslOnConnect, else RequireStartTls to StartTls — which fails rather than
falling back — else StartTlsWhenAvailable as before. It is set for
settings QuickMail chose on the user's behalf: the built-in catalog and
every discovery tier. Editing a server field by hand clears it, because
from then on the settings are the user's and the permissive behaviour is
their choice to make. Carried through DiscoveredSettings, ApplyProvider,
ApplyDiscovered, ToAccountModel, the Manage Accounts load/save, the Test
Connection probe, and the IMAP connection-pool identity.

AutodiscoverOnPort143IsAcceptedAsStartTlsNotImplicitSsl asserted only
that port 143 came back with ImapUseSsl false — which was exactly the
hole — so it is replaced rather than extended.

3. MatchesAnySuffix claimed every host under google.com.

GoogleMailHostSuffixes held a bare "google.com", so an MX of
not-really-mail.google.com was read as "this domain's mail is Gmail" and
filled in Gmail's servers plus its app-password requirement. Google
Workspace delivers under l.google.com; the bare entry is dropped.

Verified by reverting each fix in turn: 5 tests fail without the redirect
restrictions, 8 without the STARTTLS requirement, 2 without the suffix
fix. Full suite 1840 passing; the live discovery checks still pass, and
the M365 custom-domain path is unaffected — autodiscover-s.outlook.com
answers 401 to an anonymous POST as well as to a GET, and that detection
comes from the DNS tier in practice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… router leak

A. A personal Microsoft account on a vanity domain was routed to Graph.
   The backend was chosen from the domain alone: "not a Microsoft consumer
   domain, therefore a work tenant, therefore Graph". A personal account on a
   custom domain is indistinguishable from a work tenant by that test, and on
   Graph it draws work scopes that under-deliver for it (#217/#233/#239); on
   IMAP it worked. MSAL reports the truth in the token, which is why #233 added
   IsPersonalMicrosoftAccount, and sign-in happens before the account is
   created. AccountEditorViewModel now calls a new OnMicrosoftSignInCompleted
   hook after sign-in; AddAccountViewModel overrides it to move a personal
   account back to the IMAP option, which restores the provider hosts via
   OnSelectedBackendChanged rather than leaving them blank. The domain rule now
   also refuses to send a known-personal account to Graph at all.

B. Typing "kelly@outlook.com" ended on Graph with blank hosts.
   UsernameBox binds with UpdateSourceTrigger=PropertyChanged, so the decision
   ran on every keystroke and the prefix "kelly@o" matched no consumer domain:
   the form switched to Graph and ClearHostsForGraph blanked the hosts and the
   password mid-word. The finished address then re-selected the SAME provider,
   so MatchProviderFromUsername returned early, and the rule only ever moved
   toward Graph, so nothing came back. Two changes: the decision is no longer
   made per keystroke but at the point the address is finished (a new
   CommitUsername, called from UsernameBox_LostFocus -- the same moment the
   settings lookup already waits for -- and from AddButton_Click, since Enter on
   the default button never takes focus out of the field); and it now chooses
   IMAP as readily as Graph, because re-selecting the same provider is not a
   path that can restore anything.

C. A hand-picked connection method was undone by the next keystroke.
   _providerCameFromUsernameMatch protected a user-picked provider; nothing
   protected a user-picked backend, so a work account the user moved to IMAP in
   Advanced settings was forced back to Graph by editing the address -- with no
   feedback, the combo being inside a collapsed expander. Added the symmetric
   guard, _backendUserChosen, set unless the assignment came from
   SetBackendInternally. The personal-account correction in A deliberately
   overrides it: the tenant id is ground truth, not an inference.

D. ApplyProvider's Graph branch assigned the password field directly, so the
   ClearPassword call below it hit its already-empty early return and
   PasswordCleared never fired. A PasswordBox cannot be data-bound, so the box
   would keep showing dots for a password that was gone. Uses ClearPassword.

E. Manage Accounts tab order. The New / Delete / Set Default buttons and the
   account list carried no TabIndex, so they defaulted to int.MaxValue along
   with the Advanced-settings contents, while the form fields kept low explicit
   indices -- putting the left column in the middle of the form, between the
   password box and Advanced settings. The left column is now 0-3 and the form
   4-13. ProviderText, which lost Focusable when an incorrect
   AutomationProperties.Name was removed from it, is reachable again and sits
   with the fields rather than at the end.

F. The SMTP SSL checkbox lost its port information. Shortening the
   AutomationProperties.Name to a label was right, but an explicit Name
   OVERRIDES the visible Content, so "port 465 / STARTTLS on 587" survived for
   sighted users only. Added a HintFor entry in each dialog, spoken as
   AnnouncementCategory.Hint on focus, where AnnounceHints applies.

G. OnFieldFocused re-announced the hint whenever keyboard focus returned to the
   same control through no action of the user (window re-activation after OAuth,
   a dropdown closing). It now tracks the last-hinted element and skips a
   repeat. And AddButton_Click, refusing to save, announced its reason as
   Status: a refused save is an action outcome, so a user with background
   progress turned off got silence while focus jumped to a field. It is
   announced as Result.

H. Tests that could not fail.
   - AccountDialogHintTests were file-text greps ("no HelpText", "a focus
     handler is wired up") that would both have passed if the hints had been
     DELETED rather than moved. Replaced with behaviour tests that focus each
     named control in a real window and assert a non-empty Hint announcement,
     via a new internal AccessibilityHelper.AnnouncementObserver seam. The two
     negative greps are kept alongside -- they assert an anti-pattern is absent,
     which no behaviour test covers.
   - DiscoveryStaysInvokableWhileAnEarlierLookupIsStillRunning used a stub that
     completed synchronously, so CanExecute was asked of an already-finished
     command and the test passed with or without AllowConcurrentExecutions
     (verified). The stub now blocks until released.
   - The two provider tests that assigned a whole address in one shot are
     rewritten to type it one character at a time, as the dialog does; that is
     what B hid behind.
   - Added AccountManagerTabOrderTests, measuring real MoveFocus traversal like
     AddAccountTabOrderTests. This dialog had none, which is why E slipped.

I. MailServiceRouter leaked an entry per Test Connection.
   ConnectAsync TryAdds an unregistered account so the following DisconnectAsync
   (which carries only a Guid) reaches the same backend, but BuildProbeAccount
   mints a fresh Guid per probe and UnregisterAccount had no production caller --
   the routing table grew for the life of the process. Connect-created bindings
   are now tracked separately and released by the matching Disconnect, leaving
   registered accounts untouched. TestConnectionAsync also disconnects the probe
   in a finally: a probe that failed to connect skipped the disconnect entirely,
   so the router fix alone would not have covered the failing case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The user guide's "Adding Accounts" section no longer matched the feature. Replaced
with a lifecycle-ordered "Accounts" section: choosing a provider, adding an account,
what QuickMail works out for you, entering settings yourself, testing a connection,
editing, removing. Per-provider material kept and reworked rather than discarded, and
the per-provider headings are unchanged so their anchors survive. Only #adding-accounts
became #accounts; its one reference was updated and every internal link resolves.

Release notes for v0.8.37 gained a section on the setup rework — the largest
user-visible change in the release, and previously unmentioned.

Two corrections that predate this branch: the guide told users to open
Settings -> Accounts, but SettingsDialog has no Accounts tab, and gave Ctrl+Shift+A
for it, which is Add attachment. account.manage has no default key at all. Corrected
to File -> Manage Accounts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@kellylford
kellylford merged commit 1a0efb7 into main Jul 27, 2026
4 checks passed
@kellylford
kellylford deleted the feat/account-setup-providers branch July 27, 2026 22:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant