Released August 4, 2026 — downloads for this release
This is a large release — the most change in a single version since the calendar arrived in v0.8.33.
Adding an account has been rebuilt around three questions instead of a page of server settings. Gmail accounts now need an app password, because Google is no longer granting QuickMail sign-in authorizations to anyone who does not already have one. You can now decide what each row of a message list says out loud, and in what order. You can watch a conversation and have every message in it — including the replies that have not arrived yet — collect in one folder. Mail deleted or filed somewhere else now keeps up while QuickMail is open, instead of waiting for a restart. Microsoft 365 accounts get a run of fixes and can now manage their server-side Inbox rules — the ones Outlook sets up — from inside QuickMail, mail rules change in when they run and which folders they act on, several lists that were unreadable to a screen reader are fixed, and there is a build for ARM PCs.
The last public release was v0.8.36, so if that is what you have been running, everything below is new to you.
v0.8.37 is the first release built for ARM PCs as well as regular ones, so there are four downloads. Take a regular one unless you know your PC has an ARM processor — to check, open Settings → System → About and read System type.
| Download | When to use |
|---|---|
QuickMail-win.msi — Windows
installer |
Recommended for most users. A standard setup wizard with license agreement; installs per-user with no elevation required, adds the WebView2 Runtime if missing, and enables automatic updates. |
QuickMail-win-arm64.msi — Windows
installer, ARM |
The same installer for PCs with an ARM processor, such as the Snapdragon X models of Surface Laptop and Surface Pro. |
QuickMail.exe — standalone portable
executable |
No installation required. Copy it anywhere and run. |
QuickMail-arm64.exe — standalone
portable executable, ARM |
The portable version for PCs with an ARM processor. |
The regular downloads run on every supported PC, ARM ones included — just not as quickly there. The ARM downloads will not start at all on a non-ARM PC, so if you are unsure, the regular one is the safe guess.
All downloads include the .NET 8 runtime — you do not need to install .NET separately.
Setting up a mail account was the hardest thing QuickMail asked anyone to do. That is what changed most in this release.
Adding an account used to mean supplying an IMAP host, a port, an SSL setting, and a certificate rule — and then the same four things over again for SMTP. Ten or so fields, every one of them a chance to get something subtly wrong, for values QuickMail already knew perfectly well.
Adding an account now asks three questions:
QuickMail works out the rest. Choosing a provider fills in every server setting and tells you what it filled in, and typing an address at a provider it recognizes chooses that provider for you — so most people never touch the list at all. The hosts, ports, and SSL settings have not gone anywhere: they moved behind an Advanced settings expander that stays closed unless you need it, and Tab reaches that expander’s heading before its contents, so a single keystroke moves past the whole thing.
The Provider list opens on Other (enter settings manually) rather than ending with it, so Down arrow reaches the rest of the list from wherever the dialog puts you.
The account name is now optional. Leave it blank and the account is labelled with its email address.
An address QuickMail does not recognize is looked up for
you when you leave the Email field, so a work address, or one
at a domain of your own, usually takes no more typing than a Gmail one.
QuickMail checks its own built-in list of providers first — that step is
entirely offline, and nothing leaves your computer — and then tries
three public sources of mail settings, ending with the records that say
where your domain’s mail is actually delivered. Only your
domain is sent to two of those; the third sends your
address to your own provider’s server, exactly as Outlook does. That
step is what makes ordinary business mail work, because a work mailbox
often has no IMAP host to type in the first place — the way in is a
sign-in. If nothing is found anywhere, Advanced settings opens with
focus in the IMAP host box, and a message names the
sign-in route to try for a work or school account. To use the offline
built-in list only, set AutoDiscoverOnline = off in
config.ini.
Gmail, Yahoo Mail, and iCloud Mail need an app password — a password you generate on the provider’s own website for use in a mail program, rather than the one you sign in with. Each of those providers now says so above the password box, and Gmail links straight to the page where you create one. Gmail defaults to this route because Google is not currently granting QuickMail new sign-in authorizations, so an app password is the path that works today. (If your Gmail account already signs in with Google, see the next entry — it keeps working.) (#369)
Work or school Microsoft 365 accounts now connect through Microsoft 365 directly. An address on your organization’s own domain moves onto that connection method as you type it, instead of being left on IMAP — where sign-in ended at “your administrator needs to make a change” for a mailbox that signs in perfectly well the other way. Personal Outlook.com, Hotmail, and Live.com accounts are unaffected and stay on IMAP.
Test Connection checks both halves of your mail. Incoming and outgoing are probed separately and reported separately — “IMAP: OK. SMTP: OK.” A working inbox with a misconfigured send server used to pass this test and then fail on your first message. Microsoft 365 accounts can be tested now as well.
Manage Accounts was simplified in the same way. It has the same Advanced settings expander, so reaching the settings people actually change no longer means moving past hosts and ports on an account that is working perfectly. Each account now shows which provider it belongs to, and Test Connection is on this window too — which is where you want it when an account has stopped working, rather than only when you are setting one up.
The Accounts section of the User Guide covers the whole lifecycle — choosing a provider, adding an account, entering settings by hand, testing, editing, and removing.
Google is no longer granting QuickMail authorizations, so signing in with Google is not available to you unless your account was authorized before that happened. For everyone else — which is nearly everyone — a Gmail account is added with an app password: a password you generate on Google’s own website for use in a mail program, rather than the one you sign in to Google with. QuickMail says so above the password box when you choose Gmail, and links straight to the page where you create one. The User Guide’s Accounts section has the steps.
This is not a limit QuickMail chose and it is not one any QuickMail setting can lift. An attempt to sign in with Google on an account that was not authorized earlier ends in “This app has been blocked” — Google refusing the application, not your account being wrong. If you see that, an app password is the way in.
A small number of accounts were authorized before this happened, and they keep working. If your Gmail account already uses Google sign-in, nothing changes and you need do nothing: it keeps signing in, keeps syncing mail, contacts, and calendar, and Manage Accounts still shows it as a Google account. This release exists in part to make sure those accounts keep their route in — only the offer of Google sign-in to a new account is withdrawn.
If you are one of those people and want to add another Gmail account over Google sign-in, the offer is still there to turn on:
The Provider list then has a Gmail (sign in
with Google) entry directly below plain Gmail.
Choose it and there is no password box at all: Gmail’s servers fill in
as usual and a Sign in with Google button stands where
the password would be. Contact and calendar sync are offered too,
granted as part of the same sign-in. The Google choice also returns to
Advanced settings → Authentication. (The same switch is
available as GoogleAuth = true under
[features] in config.ini, or
--feature GoogleAuth at launch.)
Why the offer is off unless you ask for it. It used to be on for everyone, which meant the one path Google refuses was the one QuickMail put in front of you — and a sign-in that ends in “This app has been blocked” tells you nothing about what to do instead. Off by default, a new Gmail account gets the app-password route that works, and the few people the sign-in still works for have a supported way to ask for it.
Four fixes for accounts that connect through Microsoft 365 — work and school accounts, and Outlook.com accounts added with a Microsoft sign-in. Accounts that connect over IMAP are untouched by everything in this section.
Deleting or moving a message on a Microsoft 365 account removed it from the list, and then the next sync brought it back about a minute later, often with a “Delete may not have completed — refreshing.” message. The cause was that Microsoft 365 changes a message’s identifier whenever the message moves between folders — which happens on any move, from QuickMail, from Outlook, or from a server-side rule. QuickMail’s stored identifier then pointed at nothing, so the delete or move was refused, and the message came back.
QuickMail now asks Microsoft 365 for identifiers that do not change, so a stored identifier stays valid for the life of the message. Delete, move, mark as read, and flag all act on the message you meant. As a second line of defence, an action against a message the server says is already gone is treated as success rather than as a failure to report. (#416, #419)
This brings a one-time re-sync on your first launch after updating. The identifiers already in QuickMail’s local cache are the old, changeable kind, so they are cleared for Microsoft 365 accounts and re-fetched from the server. What that means for you:
It runs once. Later launches start normally.
Two things used to wait for a restart. Mail you deleted or moved from Outlook on the web, from your phone, or by a server-side rule stayed in QuickMail’s list until you restarted — so the list showed messages that were no longer there. And mail a server rule filed straight into a folder of your own did not appear until you opened that folder.
Both are now handled while QuickMail is running. Deletions and moves made elsewhere are reconciled as they happen, and again when you open a folder, so combined views like All Mail stop showing messages that have gone. And every folder of every account is checked periodically — not just the inbox — so filed mail turns up on its own.
This applies to Microsoft 365 accounts and to IMAP accounts alike. (#366)
A Microsoft 365 message was fetched without the identifier that the rest of QuickMail uses to recognize two copies of one message, so nothing could merge them. In All Mail and the other combined views, one message could show up twice. Microsoft 365 mail now carries that identifier and collapses the way IMAP and Gmail mail always has. (#429)
Microsoft 365 reports some messages — certain drafts and system-generated mail — with no read state at all. A single one of those failed the entire folder’s fetch, so none of that folder’s new mail arrived and nothing said why. One real session hit it 49 times. A message with no read state is now treated as unread. (#395)
On some organizations, sign-in finished without ever showing a permission screen — and then every attempt to read mail failed. The account was added, looked connected, and could not reach a single message. It happened where the organization had already approved QuickMail for something else, such as contacts or calendar: Microsoft treats an existing approval as covering the whole request and signs you in silently, so the mail permissions were never asked for and never granted.
Adding an account now always shows the permission screen, so the full set is approved once, up front. You will see it when you add an account, and when you use Sign in in Manage Accounts — which is the button you reach for when an account has stopped working, so asking again there is deliberate. An account whose sign-in has merely expired renews as before, without asking. This also applies to Outlook.com accounts added with a Microsoft sign-in over IMAP. (#391)
The headline here is that the line a message list reads out is no longer fixed: you choose which pieces it says, and in what order.
Every row in a message list is read as one line — sender, subject, date, and so on — and until now that line was fixed. View → Message List Fields… lets you decide which pieces are spoken and where each one falls.
Every field is a check box. Check one to include it, uncheck it to leave it out, and use Alt+Up and Alt+Down to move it. The Up and Down arrows move through the fields and stop at the first and last one, the way they do in any list. Home and End go straight to those ends, and typing a letter jumps to the next field starting with it. Moving a field that is switched off says so, since that changes nothing you can hear.
A Spoken preview box shows the message you had selected when you opened the window, read exactly as the list would read it, updating as you go. There is no OK or Cancel — changes take effect as you make them, and the window is modeless, so you can leave it open, arrow through the list behind it, and hear the result.
Each kind of row keeps its own arrangement: individual messages, conversation groups, and sender and recipient groups.
Status is no longer one lump. It used to be a single word — “replied”, “forwarded”, “unread”, or “read” — that you could take or leave. Unread, Replied, and Forwarded are now separate fields you can place independently, and each offers Speak only when true or Always speak. So “tell me about unread but never say read” is: turn Status (combined) off, turn Unread on, leave it on Speak only when true. The same applies to Attachments, which can now sit anywhere in the line rather than in the middle of it.
Because Status (combined) and Unread both produce the word “unread”, turning one on while the other is already on says it twice. Selecting either field explains what the other is doing, so the two do not quietly fight.
Other fields you can now add: To, Mailing list, and Source folder.
Speak field labels prefixes text fields with their names (“From: Chris Lee. Subject: Budget review.”). States and counts are never labelled, since “unread” and “3 messages” already say what they are.
If you never open the window, nothing changes — the default arrangement is exactly what QuickMail has always said, with one deliberate exception: empty fields are now skipped rather than leaving a gap, so a message with no preview or no subject reads without a pause where it would have been.
Two small consequences. The Announce flag status checkbox has left Settings, because Flag is now one field with its own checkbox; if you had it turned off, Flag starts out unchecked for you. And a conversation or sender group used to keep saying “Has unread” after you had read its last unread message — it now updates. (#457)
Show message status column has also left Settings.
It only ever governed the visible Status column — the one showing New,
Replied, Fwd, or a flag name — and never what a row said out loud, which
is what people reasonably expected of a setting with that name; turning
it off changed nothing you could hear. Speech is now chosen field by
field in the window above. The column itself is always shown, so if you
are among the few who turned it off to reclaim the space on screen, it
comes back and there is currently no way to hide it — say so and it can
be made resizable. The ShowMessageStatus line in
config.ini is no longer read and can be deleted; it will
disappear on its own the next time QuickMail rewrites the file. (#19)
In a view that gathers mail from more than one place, a row never told you where the message actually lives, so an inbox message and one a rule had filed into a custom folder read identically. Each row now ends with its folder — “… 12:24 PM. Inbox.” — and when the view spans more than one account the folder is named with its account, “Work – Inbox”.
This applies to All Mail, All Inboxes, All Drafts, All Sent, All Archive, All Trash, All Flagged, an account’s own All Mail, every saved view, and the contact-mail results that are also new in this release (see find a contact’s mail from the address book). An ordinary single-folder view says nothing extra, because there the folder is already obvious. Source folder is one of the fields in Message List Fields…, so you can move it or switch it off. (#423)
The All Mail group in the folder tree gathers each kind of folder across every account — All Inboxes, All Drafts, All Sent, All Trash, All Flagged — and archived mail was the one thing missing. All Archive is now there too, listed between All Sent and All Trash, and it is available in the folder picker and as a saved view like the others.
It follows each account’s own archive setting rather than guessing. If you pointed an account at a particular folder with Set as Archive Folder, that is the folder All Archive reads, so the list is exactly the mail Move to Archive put there. Accounts with no Archive folder contribute nothing rather than causing an error.
One thing to know if you use Gmail: the guide now recommends creating a Gmail label named Archive and pointing the account at it with Set as Archive Folder, rather than at [Gmail]/All Mail. Both archive correctly, but an account pointed at All Mail contributes its entire mailbox to All Archive, because for that account All Mail is the archive. A label gives you a folder holding only what you archived. (#452)
Some conversations you want to keep an eye on — a release announcement, a bug thread, a trip itinerary. Until now the only way was to flag each message as it landed, which means noticing it first.
Press Ctrl+Shift+W on any message and its whole conversation is watched from then on. Watched Conversations, the last item in the All Mail group of the folder tree, then lists every message in that conversation, from every folder and every account, newest first — including the replies that have not arrived yet, which join on their own as they sync. That is the difference from flagging: a flag marks a message you already have, a watch is a standing subscription to the conversation.
The same key stops watching, from any message in the conversation. It works from the message list, from a Conversations group header, from the reading pane, from a message tab, and from a message window. Watch Conversation is also on the Message menu, with a check mark showing whether the conversation you are on is already watched; on a sender or recipient group header it is dimmed, because a group of that kind is not one conversation. Watching changes nothing on the mail server and nothing in other mail programs; it is remembered on this computer only.
Everything else in the message list works there as usual — view modes, filters, sorting, and the message list fields. Conversations view mode is a natural fit, since each watched conversation becomes one group. You can save Watched Conversations as one of your own views.
Tools → Watched Conversations… lists everything you are watching, with how many messages each has collected and when you started. Press Enter on one to jump to that conversation, Delete to stop watching it, or Rename to give it a clearer label — renaming changes the label only, not which messages are collected. Type a letter to jump down the list. The window stays open while you work.
Settings → Notifications → Show a notification when a watched conversation gets a reply tells you when one arrives. Unlike the ordinary new-mail notification this applies to every folder, not just the inbox, because a watched thread’s next message can land anywhere. The two settings are independent, and a message that is both new inbox mail and part of a watched conversation produces one notification, the watched one.
View → Filter → Watched narrows any folder to watched conversations, and can be saved as part of a view. If you would like each row to say whether its conversation is watched, turn on the Watched field in View → Message List Fields…; it is off to begin with.
Two things worth knowing. QuickMail groups a conversation by its
subject, ignoring Re: and Fwd: prefixes, so a
reply is recognized automatically — but two unrelated messages that
happen to share a subject count as one conversation. And a message with
no subject at all cannot be watched; you will hear “Cannot watch a
conversation with no subject” and nothing changes.
A saved view that used the With Attachments or To Me filter reported its filter as “All” in the View Manager. The view itself always applied the right filter — only the description was wrong.
All Flagged was in the folder tree, but not in the flat folder list or in the Go to Folder picker, and a saved view built on it did not resolve back to the real folder. Every other combined view — All Mail, All Inboxes, All Drafts, All Sent, All Archive, All Trash — was in all three places. It now is too.
v0.8.36 added Alt+A for jumping to the attachment list, and fixed the Shift+Tab path (#350). One part was still missing: with Message open mode set to Window, a message with attachments had no attachment list at all. Alt+A answered “No attachments” and Shift+Tab from the message body skipped straight past it. Reading pane and Tab modes were unaffected, which is what made it look like an Alt+A problem — the list existed, it was just never shown. Both now work in every mode. (#439)
Alt+A now also works while composing. It moves focus to the attachment list of the message you are writing, matching what it does in an open message, and lands on the first file rather than the empty list. Ctrl+Shift+A is still the way to add files. The new command is in the compose window’s Command Palette (Ctrl+Shift+P) as Focus Attachment List; compose shortcuts are fixed and are not among the ones Settings → Keyboard can rebind.
Pressing Enter on a link in a message opened with Message open mode set to Window could land you in a bare window inside QuickMail rather than in your default browser. That window is QuickMail’s own display of a web page: none of your browser’s cookies, saved passwords, passkeys, or extensions are there, so a password manager had nothing to fill and a site you were already signed in to asked you to sign in again.
It depended on the message, which is why it looked intermittent. A great deal of mail — newsletters and anything sent by a mailing tool in particular — marks its links to open in a new window, and those links took a different route out of the message than ordinary ones. QuickMail handled the ordinary route and not the other one. The same thing happened to a link opened with Ctrl or Shift held down, or with the middle mouse button.
Every link in every message now opens in your default browser, from the reading pane, a message tab, or a message window alike. The Markdown preview window is fixed in the same way. Links are still limited to web and mail addresses, as before. (#483)
Mail rules — the ones you set up in Tools → Rules… to file, flag, or delete mail automatically — changed in four ways this release. If you have a Microsoft 365 account, the Rules Manager now shows the rules that live on your Exchange mailbox as well as the ones that run inside QuickMail. For everyone, three things changed about the rules QuickMail runs: which account a rule belongs to, when rules run, and which folders they act on. If you use rules, all of it is worth reading, because together these change behaviour you may be relying on.
If you have a Microsoft 365 (Exchange) account, the Rules Manager now shows that mailbox’s server-side rules — the same rules Outlook calls “Inbox rules” — alongside the rules that run inside QuickMail. A server rule runs on Microsoft’s servers, so it acts on your mail even when QuickMail is closed, and wherever else you read that mailbox. You can create, edit, enable and disable, reorder, and delete them from QuickMail.
Most people will see none of this yet. It needs a Microsoft 365 mailbox connected through Microsoft sign-in, and for a work or school mailbox that means your organization’s administrator has approved QuickMail for your tenant — which few have so far, since few know the app exists. If that is not you, nothing in this entry applies and nothing about your rules has changed shape; the rest of the Mail rules section is what affects you.
If you do have one, the change you will notice first: the Rules Manager is now one account at a time. When you have a Microsoft 365 account, the Rules Manager opens on a single account chosen in an Account list at the top, instead of listing every account’s rules together. Your rules are not gone — they are behind the account picker. Choose the account, and the list below shows that mailbox’s rules. With only one account there is no picker.
The rest of what is worth knowing:
For most work or school accounts this needs your administrator to allow QuickMail to read and change your mailbox rules. Without that permission you will see a message saying so rather than your server rules. Your QuickMail rules are unaffected either way — they are fetched separately, so they still load, still run, and can still be created and edited, whether the permission is missing, the server is unreachable, or the account is not a Microsoft 365 one at all. Rules have not become a Microsoft 365 feature; this adds a second place they can run. (#333)
The “All accounts” rule option has been replaced by scoping each rule to a specific account. This happens automatically the first time rules load after updating, once QuickMail can see your account list:
Rules used to be applied only during a full sync. Mail that arrived afterwards — the mail QuickMail picks up while you are sitting there working — was never looked at, so a rule that should have moved or flagged it simply did not fire until the next full sync. Rules now run on new mail as it arrives, on every sync path. Each message is considered once, so nothing is acted on twice. (#411)
Two things used to happen that they should not have. Rules ran against every folder QuickMail synced, so a message that a server-side rule — or you — had already filed into Sent, Archive, or a folder of your own could be picked up and acted on again when that folder synced. And closing the Rules Manager silently reprocessed your whole cached mailbox, so simply looking at your rules could move mail.
Rules now behave the classic way: they run on the Inbox, on new arrivals, and never retroactively unless you ask. Other folders are still fetched and cached as before — they are just not rule-processed. Closing the Rules Manager no longer runs anything. (#336)
Run on Existing Mail is how you ask. Its reach is now the Inbox of each account rather than every cached folder, so it can no longer move or delete mail you deliberately filed somewhere else. It reports how many messages were moved or deleted. An account whose Inbox cannot be identified is skipped rather than guessed at, and that is recorded in the log. It covers every account either way, not only the one you are looking at. (#346)
Where to find it depends on which Rules Manager you get. Without a Microsoft 365 account it is the button beside New and Delete, where it has always been. With a Microsoft 365 account — where Rules opens the account-at-a-time window described above — there is no button for it in this release: press Ctrl+Shift+P in that window and choose Run Rules on Existing Mail. It does the same thing from there. (#493)
Two things in the Rules Manager, both in the window you get without a Microsoft 365 account:
With a Microsoft 365 account the account-at-a-time window applies the same principle by its own route: it has no editable fields to disable, because editing opens a separate window, and Edit, Delete, Enable/Disable, Move Up, Move Down, and Test each switch off when they have nothing to act on — including for a rule that is shown read-only, and for Test on a server rule, which runs in Exchange rather than in QuickMail. Close always works there too.
“Show field labels in the rules list” now survives a
restart. The setting saved and applied for the session, but was
never written to config.ini, so it reverted to off every
time QuickMail started. Note that it governs the window without a
Microsoft 365 account; the account-at-a-time window does not read it
yet, so with a Microsoft 365 account the checkbox currently changes
nothing. (#493)
Pressing Enter after arrowing to an account in the compose window’s From list sent the message (#201). Not chose the account — sent the mail, half-written, to whoever was already in the To field. Choosing a mode from the compose-mode list, or pressing Enter in the Subject box or the attachment list, did the same thing.
The Send button was marked as the window’s default button, which in Windows means Enter activates it from anywhere in the window that does not use Enter for something of its own. A closed list is exactly such a place: arrowing through it already changes the selection, so Enter had nothing to do there and went to Send instead. That default is now removed. Enter no longer sends from anywhere in the compose window.
Send is still Alt+S, Ctrl+Enter, or Enter or Space with the Send button focused.
Enter on the From list now confirms the account. Since the keystroke no longer sends, it says which account you landed on — “IdeaPlace used as From address”. You also hear it when you pick an account from the expanded list, and when you leave the From field having changed it. Arrowing past accounts stays quiet, because your screen reader is already reading each one. This uses the Announce action results setting, so turning that off turns this off with it.
A report of “sending an email gives no feedback, and does not close the compose window” (#396) turned out to be four separate problems stacked on top of each other. All four are fixed.
A send that fails now says so out loud. The failure message was classed as background progress, so if you had turned Announce background progress off — a reasonable thing to do, since that is the setting that stops every folder announcing itself during a sync — pressing Send produced the button greying out, coming back, and nothing else. Send failures, refusals, and confirmations are now announced as results, which is the category for the outcome of something you just did, and they interrupt rather than queue. The same fix applies to a refused save in Add Account and Manage Accounts.
A message that was accepted is no longer reported as failed. QuickMail closed the connection inside the same step that sent the message, so a server that hangs up the instant it takes your mail — or any hiccup during the sign-off — produced “Send failed” for a message that was already on its way. This is why the reporter saw messages sometimes arrive anyway. The sign-off is now separate and its own failure is ignored, because by then the server has your message.
Your login and your email address can now be different things. There was one box serving as both, and for some accounts they are not the same string: an iCloud mailbox on your own domain logs in under the Apple ID, and some hosted servers want a bare user name. Whichever one you entered, the other use of it was wrong — a login name in the box became the From address on your mail, which servers reject. Advanced settings in both account dialogs now has a Login username box, empty for almost everyone, filled in only when your server logs in under something other than your email address. The Email address box is now only that, and saving an account refuses an entry that is not a full address, pointing at the new box instead.
If you already had an account set up with a login name in the address box, QuickMail copies it into Login username for you the first time it starts. That matters: correcting the address is what you are now asked to do, and without the copy you would be deleting the very thing your account signs in with. You enter the address; the login carries on working. This also covers contact and calendar sync on iCloud, which sign in with the same name.
A wrong encryption setting on a known server is corrected at startup. An account set up by hand before QuickMail knew these providers could end up with Implicit SSL on connect checked while using port 587, which is a STARTTLS port. That combination fails every single send, about a second after you press the button, with an error that names a certificate rather than a checkbox. At startup QuickMail now corrects the encryption setting when — and only when — the account is one you have never saved yourself, the server is one it ships settings for, and the port is the exact port it publishes for that server. Anything else — one of those servers on a different port, or any account you have saved in Manage Accounts — is left exactly as you set it. A corrected connection also requires encryption from then on, rather than falling back to plain text if the server offers none.
The address fields, the subject line, and the body editor were squeezed into a narrow column against the left edge, leaving roughly two thirds of the window blank no matter how large you made it. It had been that way since the compose window was built. They now stretch across the full width and grow when you resize. Nothing else about the window changed — the same fields, the same order, the same keyboard behaviour. (#435)
Select someone in the address book, press Shift+F10, and choose Find mail from this contact or Find mail to this contact. The address book closes and the message list fills with the matches, newest first, drawn from every account and folder QuickMail has cached — not just the folder you were in. Focus lands on the message list and the count is announced (“12 messages from Bob Baker.”), and the window title reads Mail from Bob Baker so the results are easy to tell apart from a folder.
Press Escape in the message list to close the results and return to the folder you started from — the destination and its message count are announced (“Search closed. Inbox, 42 messages.”). There is also a Close button above the results next to the count, and Close Contact Mail Results in the Command Palette, which you can give a keyboard shortcut in Settings → Keyboard. Escape keeps everything else it already did: an open reading pane, the calendar, and tab mode claim it first, and in the search box it still clears your search text.
Both actions are also in the address book’s Command Palette (Ctrl+Shift+P) as Find Mail From Contact and Find Mail To Contact.
Two things to know about the results: mail older than your sync range is not stored locally, so it is not searched, and Find mail to this contact matches the To line — a message where the person was only in Cc does not appear. (#370)
A Filter button sits to the right of the address book’s search box and carries the current filter in its own label, so it reads Filter: All accounts, Filter: Work, and so on. Alt+F reaches it from anywhere on the Contacts tab. The menu offers All accounts, then Local address book, then each of your accounts; the active one is checked and is where the menu opens, and applying one announces the result (“Work, 12 contacts”).
The filter works alongside the search box, survives a contact sync or an edit, and falls back to All accounts if the account it named is removed. A contact filtered out of view is deselected, so Edit and Delete cannot act on a row you can no longer see. It is also in the address book’s Command Palette as Filter Addresses by Account, with no default key, so you can assign one. Where the address book opens with focus is unchanged. (#399)
With focus on the address book’s contact list, typing a letter did nothing. Now it jumps to the first contact starting with that letter, the same way lists behave elsewhere in Windows. Type several letters quickly to match a longer beginning (“br” goes to Brenda rather than Bob), or press the same letter again to move to the next contact starting with it. Contacts saved without a name are matched on their address. The Groups and Group members lists work the same way. (#371)
The appointment editor’s dates went through a calendar popup built for a mouse, and its times were plain text boxes. Nothing responded to the arrow keys, so changing anything meant retyping the whole value.
Start date, Start time, End date, End time, Repeat interval, and Repeat until are now ordinary edit fields that step with the arrow keys. The same field is used for the date in the Go to date window.
| Keys | Date field | Time field | Number field |
|---|---|---|---|
| Up / Down | 1 day | 15 minutes, snapped to the quarter hour | 1 |
| Ctrl+Up / Ctrl+Down | 1 day | 1 minute | 1 |
| Shift+Up / Shift+Down | 1 week | 1 hour | 5 |
| Page Up / Page Down | 1 month | 1 hour | 10 |
| Ctrl+Page Up / Ctrl+Page Down | 1 year | 1 day | 10 |
Typing still works, and takes far more than it used to. Dates accept “8/3”, “August 3”, “2026-08-03”, “today”, “tomorrow”, “yesterday”, weekday names like “fri” or “next tuesday”, a bare day number, and offsets such as “+7”, “-3”, “+2w”, “+1m”, “+1y”. Times accept “9”, “930”, “9:30”, “9:30 AM”, “9p”, “14:30”, “noon”, “midnight”, and “+30” or “-15”. Enter or moving to another field applies what you typed; text that cannot be read as a date or time puts the previous value back rather than guessing.
Two behaviours worth knowing. Stepping a time past midnight carries the date with it — 11:50 PM stepped up becomes 12:00 AM the next day, instead of wrapping round and leaving the date behind. And the end follows the start: moving the start moves the end by the same amount and keeps the appointment’s length, while changing the end sets a new length.
If a save is refused, QuickMail now moves focus to the field at fault and selects its text, and shows the reason on a line above the buttons that clears itself when you fix the field — so the refusal is visible and reachable however your announcement settings are set. (#400)
Pressing N for a new appointment after about 11:30 PM produced a default half-hour range whose end crossed midnight while its end date stayed on the day you started, so the appointment ended before it began and Save was refused over values you had never touched. The only way out was to correct the end date by hand. A new appointment started at 11:45 PM now ends at 12:15 AM the next day and saves. (#378)
Choosing where to move or copy a folder — Shift+F10 on a folder, then Move Folder… or Copy Folder… — presented one long flat list of every folder in every account, with each one spelled out as a full path. Every other place QuickMail asks you to pick a destination folder shows a tree. This picker now does too: folders nested under their parent, everything expanded so arrow keys and type-ahead reach any folder without opening anything first. (#431)
Two destinations it no longer offers, both of which could only fail:
Both folder pickers now open on the folder you came from, rather than on nothing. Moving or copying messages opens on the folder those messages are in — in a combined view such as All Inboxes that is the message’s own folder, not the view. Moving or copying a folder opens on the folder it currently sits under, since the folder being moved is not one of the destinations. Where there is no single place you came from — messages selected from several folders, or a top-level folder with no parent — it opens on the first folder rather than leaving the tree unselected.
Open is unavailable when what is selected is not a folder: an account header, or one of the intermediate names some servers produce for a level that holds folders but is not a mailbox itself. Pressing Enter on one announces “Choose a folder” rather than appearing to ignore the keypress.
Since the picker now opens on where you already are, QuickMail no longer carries out a move or copy that puts something back where it started. It says so instead. That was worth guarding: copying messages into the folder they are already in used to duplicate every one of them, and on Microsoft 365 accounts copying a folder into its own parent left a second copy of the folder and all of its mail.
Two cases that used to end in silence or a server error now say what happened: when the exclusions above leave nowhere to move the folder, QuickMail says so instead of opening an empty picker; and Move Folder… or Copy Folder… on one of the views that sit in the folder tree — All Inboxes, All Mail, an account’s All Mail — now says it is a view rather than a folder. Choosing it on a per-account All Mail previously opened a full picker and failed at the server.
The rule editors were the last place still asking for a destination folder as one long flat list: Choose Target Folder in the Rules Manager, and the move and copy folder in the Microsoft 365 rule editor. Both now show the same tree as everywhere else, and both open on a folder rather than on nothing:
Typing letters in the tree view of the folder picker — the view you get when moving or copying a message — did nothing. The v0.8.32 notes said typing a folder name there would jump to it; the mechanism behind that claim turned out never to have worked, and nothing else was wired in its place. The tree now has the same type-ahead as the main window’s folder tree: type the first letters of a folder’s name and the selection jumps to it, keep typing to narrow the match, repeat a letter to cycle through folders that share it. (#418)
Two related repairs in the same picker:
In the main window’s folder tree, type-ahead now continues a prefix. Typing “s”, “e” in quick succession finds “Sent” rather than treating each letter as a fresh first-letter search. The v0.5.5 notes described the folder tree working this way, but the code never actually did — each letter was always a fresh search; the message list is where continuation really lived. The tree now genuinely does what those notes said.
Repeating a letter now cycles through matches everywhere. Pressing “s” twice quickly used to build the prefix “ss”, which matches nothing, so rapid repeats went dead until the timeout passed. A repeated letter now keeps the single-letter prefix and moves to the next match — the standard list behavior — in the message list, the grouped views, both folder trees, and the picker.
One more small change in the same code: a capital letter now works for type-ahead. Shift+S was silently ignored in the message list and grouped views; it now matches the same as “s”. (Matching was always case-insensitive once the letter got through.)
In Settings, arrowing through a group of options — Message open mode, Message list density, Log format, Spelling suggestions verbosity — moved between them without choosing one; you had to press Space on the option you had landed on, which is not how these behave elsewhere in Windows. Arrow keys now select the option they move to. Tab still enters the group on the option already chosen and changes nothing on the way in or out. The same applies to the Change: this event only / all events in the series choice when editing a repeating appointment.
QuickMail also stopped speaking the option name itself when you choose one. That announcement was added to paper over this same bug — choices went unannounced because arrowing never made one — and with the groups behaving correctly it was QuickMail talking over software you have already set up to speak the way you want. (#441)
The calendar agenda read “QuickMail.Models.CalendarEvent” for every row rather than the appointment. The same fault affected three other lists: Group Manager, Message Properties, and the shortcut list in Settings → Keyboard.
All four are multi-column lists, and each row’s spoken name was coming from the row object rather than from the columns on screen — so the columns were visible but unreadable, and what was spoken was the internal type name. This looked completely correct to anyone reading the screen, which is why it survived. (#448)
The Rules Manager’s status text was wired to speak through two independent mechanisms at once, so it was announced twice — and one of those routes ignored your announcement settings entirely, so turning announcements off did not silence it. It also spoke on opening the window, over the thing you had just moved to.
There is now one route, it respects your settings, and the status is silent on open — press F6 to read it when you want it.
With Message open mode set to Window, the command palette shortcut did nothing. Focus lands inside the message body as soon as the window opens, and the keystroke was not being passed out of it. Found while adding Ctrl+Shift+W to the same window; both work there now.
Message List Density offers Comfortable and Compact. It is in Settings → Appearance, and also directly on the View → Density submenu so you can change it without opening Settings. Choosing one announces “Comfortable density.” or “Compact density.”
Comfortable is the new default and puts a little more space around each message row. Compact is the tighter spacing QuickMail has always used, so choosing it returns the message list to exactly what you had before. The setting changes spacing only — the rows say the same thing either way, and nothing about keyboard navigation changes.
Both options are also available as commands, Density: Comfortable and Density: Compact, in the Command Palette and in Settings → Keyboard, so you can give either one a shortcut. Neither has a shortcut by default. (#421)
Manage Themes… is on the View menu now rather than Tools — choosing how the app looks belongs with the rest of what View controls. The Next Theme and Previous Theme menu items were removed; both are still commands, so they remain in the Command Palette and keep any shortcut you assigned them.
At a Windows text size of 150% the Rules Manager’s boxes and buttons clipped their contents — text ran out of the bottom of fields, and button labels were cut off. The rows had fixed heights that did not grow with the text. They size to their content now. (#449)
At 150% text the Plain text / HTML / Markdown selector at the top of the compose window was squeezed down to about two characters, because the buttons beside it claimed the width first. The selector now gets its width before they do. (#450)
At a Windows text size of 150% the buttons beside the theme list ran past the bottom of the window and Import could not be reached at all. That column scrolls now, and moving to a button with the keyboard brings it into view.
QuickMail now has a build for PCs with an ARM processor — the Snapdragon X models of Surface Laptop and Surface Pro, and similar machines from other manufacturers. Until now those PCs ran the regular build through Windows’ built-in emulation, which works but costs speed and battery life. The ARM build runs on the processor directly.
Nothing changes for anyone on a regular PC, and nothing changes for you automatically: automatic updates stay on whichever version you installed, and QuickMail will never move you across on its own.
If you are on an ARM PC running the regular build, QuickMail says so once, and the Help menu keeps a Get the ARM Version entry that opens the switching instructions in the user guide, where the download is linked. That entry appears only on an ARM PC running the regular build — which makes it the way to check where you stand. Once you are on the ARM version, it is gone.
Switching is a manual uninstall and reinstall, and the uninstall is not optional:
QuickMail-win-arm64.msi.Running the ARM installer on top of a regular QuickMail of the same version does not replace it. Windows treats the two as separate programs, installs the second beside the first, reports success, and leaves the regular build running. Nothing in QuickMail would look wrong afterwards, which is exactly why it is worth saying plainly: uninstall first, and check the Help menu once you have restarted. If it has already happened, uninstalling and running the ARM installer again puts it right — and note that Settings → Apps cannot tell you which build you have, since both report the same name and version there. (#18)
Adding a new account made the accounts you already had report disconnected in the account list, and they stayed that way until you restarted QuickMail. This has been reported several times, and each previous attempt fixed a real connection bug that turned out not to be this one.
Nothing was ever actually disconnecting. Your accounts stayed connected the whole time — mail kept arriving, folders kept working. What broke was the account list’s picture of them. Adding an account makes QuickMail re-read your account file, and connection status is live information that is deliberately never written to that file, so every account came back from the re-read reporting “disconnected”. Accounts that were already working were then skipped by the reconnect pass — correctly, since nothing was wrong with them — so nothing ever corrected the status, and the wrong label stuck.
The status now survives a re-read. Adding, editing, or removing an account leaves the others reporting exactly what they were.
This one hid for so long because it looks exactly like a connection failure: it appears the moment you touch your accounts, it hits every account at once, and it lasts until a restart. It was found by recording what QuickMail’s connections were actually doing at the moment it happened — which is the feature below. (#312)
Settings → Advanced → Record connection diagnostics turns on a record of how QuickMail connects to your mail servers. It is off by default, and most people will never need it.
Turn it on when an account reports the wrong status, mail stops arriving, or an action reports a failure you cannot explain — then reproduce the problem. It starts recording the moment you switch it on, so you do not have to restart first and lose what you were trying to capture.
While it is on, a Connection Diagnostics item appears in the Help menu. It shows each account with what QuickMail believes about it alongside what its mail server actually says, and a Test this account button checks an account directly on a brand-new connection. That is the question this exists to answer: whether the problem is your connection or what QuickMail is reporting about it. Copy report and Save report produce a plain-text file you can attach to a bug report.
What it records: your account names, your mail server names and addresses, connection attempts and their results, and error messages. What it never records: passwords, authentication tokens, and the contents of your mail. Your account names may be email addresses, and mail server names identify your provider, so it is worth knowing what is in a report before you share one.
The record goes into a file named connection.log, kept
beside QuickMail’s other settings. It is capped in size, and it stops
the moment you turn the setting off.
QuickMail can save a picture of each window as you open it, so that how the app looks can be reviewed by someone who is not looking at the screen. It is genuinely useful — it is how the visual problems fixed in this release were found — but it is the one feature here that writes pictures of your actual mail to disk, so it is deliberately hard to switch on and impossible to leave on by accident.
Two separate deliberate acts are required, and neither is remembered.
/debug switch. Without it the
feature does not exist — the setting is not merely hidden, there is
nothing behind it to turn on.It turns itself off when QuickMail closes. The
setting is never written to config.ini — it lives only in
memory for the session. Start QuickMail again with
/debug again and capture is off again, waiting to be
switched on deliberately a second time. There is no way to make it
stick, and that is the point.
While it is on, every window’s title bar says so, ending in ” - SCREENSHOTS ON”. If that text is not in your title bars, nothing is being captured.
Never attach a screenshot to a GitHub issue. A GitHub issue is public and permanent, and a QuickMail screenshot is a picture of your real mailbox — senders, subject lines, and the text of whatever message was open. QuickMail itself never uploads them and never attaches them to a bug report: Help → Report a Bug does not touch them in any form. They stay in a folder on your computer (Open screenshots folder in that same Settings group takes you there), and Settings → Advanced → Delete QuickMail logs removes the whole folder. If a picture is genuinely needed to explain a problem, email it to quickmailissues@theideaplace.net, where it reaches a person rather than a public page — and look at what it shows before you send it.
Whether this ships enabled at all is still being decided. It is in this release so it can be evaluated in real use; it may be restricted further, or removed from production builds entirely, in a future version. If you have a view on that, say so through any of the routes in Reporting Issues. (#175)
QuickMail can leave three kinds of diagnostic file on your computer, and Settings → Advanced → Delete QuickMail logs removes all of them. Two of the three are new in this release, so this is what the command covers rather than a change to what it used to do:
quickmail.log) —
a running record of what QuickMail did. It is always written, and in
more detail if you start QuickMail with the /debug
switch.connection.log) —
the connection record described just above, written only while
Record connection diagnostics is switched on.Delete QuickMail logs removes all three, and says so before it does
it. The usual reason to delete these files is that they carry your email
addresses and mail server names — and screenshots hold pictures of your
actual mail — so leaving one behind because it happens to live in a
different folder would quietly defeat the point. Screenshots are cleared
even when you are running normally, in case an earlier
/debug session left some behind. (#436)
Thanks to everyone who reported problems and tested fixes for this release. The reports behind the Microsoft 365 mail-reappearing investigation (#366), the silent send failures (#396), the compose Enter-sends-the-message report (#201), and the rules-scoping and attachment feedback shaped a great deal of what shipped here.
Everything below is developer detail — implementation notes, test coverage, and build changes. Nothing here is needed to use QuickMail.
GraphHeaders adds the immutable-id preference header; every
read (GraphMailService summary/detail/delta paths) and
every write (delete, move, mark-read, flag) now round-trips an id that
survives a folder move, as does GraphChangeNotifier’s delta
poll. Graph’s default ids change on move, so a cached id went stale the
moment a server rule or another client filed the message — the delete
then 404’d and the next sync resurrected it.App.OnStartup
clears MessageDetail, MessageSummary, and
DeltaToken rows for BackendKind.MicrosoftGraph
accounts only, guarded by a .immutable-id-rebuilt marker
file in the profile directory. CalendarEvent rows are
deliberately untouched, and IMAP caches are untouched so IMAP
invite-source links survive. A failed clear is caught, logged, and
leaves the marker unwritten so the next launch retries; the rebuild is
skipped in --online and --ui-probe.
SyncService.SeedRebuildBaseline makes the first full sync
per folder cache without running rules, so the refetched backlog does
not re-fire rules — a crash between the wipe and that first Inbox sync
can still lose the in-memory baseline, tracked as #454.
Note the downgrade hazard: an older build run against a rebuilt cache
treats the stored immutable ids as mutable.IsAlreadyGone (#416) is retained as
belt-and-braces on delete/move even though #419 removed the cause; it
still covers a message genuinely deleted from another device.SyncService.ReconcileFolderAsync compares the server’s id
set against the store and raises MessagesRemoved for the
difference; it runs on folder open, on the Graph delta poll (which now
consumes @removed tombstones), and on the periodic sweep.
The sweep covers every non-excluded folder of every
account on MailSyncPollMinutes (default 5), not just
inboxes, which is what makes server-rule-filed mail appear without
opening the folder. Probe mode never deletes.internetMessageId is selected on Graph
summaries (#429). Without it
MessageDeduplicator.CollapseKeyFor fell back to the
per-folder key (account + folder + id), which cannot merge copies, so
Graph messages doubled in every aggregate view.GraphMessage.IsRead becomes
bool?, mapped ?? false at both sites.
As a non-nullable bool it threw
JsonException: Cannot get the value of a token type 'Null' as a boolean
mid-batch, and because the throw escaped the whole deserialization, one
message took down the entire folder fetch.OAuthService.PromptForSignIn(firstConnect, username)
centralizes the MSAL prompt choice: the add-account path forces
Prompt.Consent, re-auth keeps
ForceLogin/SelectAccount. Scopes stay
.default for work/school, so requested-equals-declared
still holds by construction (#208) and Azure resolves the set per
account type — an explicit org-only scope list would have broken
personal accounts on a custom domain.
SignInInteractiveAsync(account, ct) is the add path and is
reached from both AddAccountViewModel and
AccountManagerViewModel’s Sign in button,
since both derive from AccountEditorViewModel. The #202
identity-mismatch guard still refuses to adopt a different identity that
completes sign-in, so the protection moves from prevention to detection
rather than disappearing. .default never surfaces a
newly declared permission to a user who already holds an older
grant — any permission added in future must be requested explicitly at
the point it is needed, as contacts and calendar already do.SyncService.ApplyRulesToArrivalsAsync is the
single chokepoint for rule application, called from the full
sync and from both IDLE paths (cached and online). Previously only the
full sync applied rules, so IDLE-delivered mail permanently escaped
them. Dedupe authority is the store in cached mode (an id snapshot taken
before the upsert) and an in-session set in online mode.
Rule-removed messages are stripped from the returned batch and from the
store so they never flash in the origin folder.folder.Kind == SpecialFolderKind.Inbox || folder.FullName == "INBOX".
For Graph accounts FullName is an opaque id that never
equals "INBOX", so Kind is the only
thing keeping client rules alive on a Graph inbox — pinned by
SyncServiceRuleApplicationTests.GraphInbox_ByKind_RunsRules_EvenWithOpaqueFolderId.
Any new sync entry point handing this method a Graph inbox with
Kind == None would silently stop running rules on it.RuleService.MigrateAllAccountRules
runs from LoadRules() and is not feature-gated. It defers
entirely when the account list is empty — an empty read can be transient
(startup ordering, a locked accounts.json) and migrating
against it would drop every unscoped rule. A genuine Graph-only profile
still drops, and logs each dropped rule by name.
AccountOptions lists every account including Graph ones, so
a user can rescope a dropped rule and it will run.ApplyRulesToExistingAsync takes an
account→Inbox-FullName map and filters cached mail
to those pairs Ordinal, skipping accounts absent from the map
fail-closed rather than guessing an Inbox. The caller
builds the map from CachedFolders, logs unresolved
accounts, and runs under a 60-second timeout. The Rules Manager’s
Closed handler no longer calls it — it only refreshes the
status text.AutomationProperties.LiveSetting="Polite" live region
and the gated custom Announce path on the same
text. The live region ignored the user’s announcement config entirely
and double-spoke the count. LiveSetting is removed from all
three rules status TextBlocks — the unified window’s and the client
window’s two; the line keeps its AutomationProperties.Name
and stays focusable, so it never auto-announces on open or refresh but
F6 reads the count on demand, and action outcomes still go through the
gated path. Verified with the screen-reader user against a build with
the surface enabled. The unified editor window is documented as a
deliberate New-Window-Checklist exception to the F6 requirement: it is a
single linear form (Name → conditions → action → Save/Cancel) with no
distinct panes, so Tab-only is correct there rather than missed
(#466).AccountId null with nothing in AccountOptions
matching it, so the combo rendered blank. Introduced 77 minutes after
the v0.8.36 tag and fixed 35 minutes later, so no shipped build ever had
it — which is why it is not in the user-facing notes.FeatureFlag.ServerRules now defaults to true
in ConfigFeatureGate.Defaults — the flag existed to keep
the surface hidden while list/create/edit/delete/reorder and the unified
per-account window were built, and they are done.
MainWindow builds UnifiedRulesWindow when the
flag is on, a ServerRuleService exists,
and a Graph account is present; a profile with no Graph
account still gets the client-only RulesManagerWindow,
unchanged. Note what the flip actually swaps for a Graph user: the whole
Rules Manager, not an extra section — so the client-only window’s
per-rule Account column gives way to the unified window’s account
picker, and every action the old window offered had to exist in the new
one. Turn the surface off again with
ServerRules=false under [features] in
config.ini, or --no-feature ServerRules at
launch. There is no Settings UI for it.RulesManagerWindow has had a Test button
since before the unified window existed;
UnifiedRulesViewModel had no equivalent, so enabling the
flag would have silently removed rule testing for every Microsoft 365
user — including for their client rules on IMAP accounts, since one
Graph account swaps the window for all of them. Caught in review of the
flag flip, not of the window that omitted it.
CanTestSelected gates on the row being a client rule, and
the button, context-menu item, and command-palette entry all follow it.
Testing a server rule is deliberately a disabled control rather
than an enabled one that announces “not available”: that message was
Result-category, so a user running with announcements off
would have pressed a button and perceived nothing.RowFieldCatalog / RowLayoutService
/ RowSpeechBuilder replace the six hand-written
accessible-name converters. The catalog is the single source of field
ids, display names, labels, bound property paths, and formats; the
default order is
["flag", "status", "attachments", "from", "subject", "preview", "date", "folder"],
which reproduces the previously shipped speech exactly except that empty
fields are skipped rather than emitting a bare ". ".
MessageAccessibleNameConverter is gone.AnnounceFlagStatus setting is
honoured once at seed time in RowLayoutService:
false means the Flag field starts unchecked. The Settings checkbox is
deleted; the config key remains readable so an existing
config.ini still lands correctly.FieldCheckList uses real CheckBox
controls, not a ListBox. A
ListBoxItem wrapper carries a second copy of the row’s
name; making each row the actual check box means role and checked state
come from the platform and Space toggles natively. The cost
is that Home/End and first-letter navigation —
free in a ListBox — are implemented on the control, using
TypeAheadPrefixTracker rather than WPF
TextSearch (which requires a Selector). It is
therefore not a TypeAheadWiringTests
site.StampFolderDisplayNames fills
FolderDisplayName on aggregate rows only;
AggregateSpansMultipleAccounts decides
account-qualification by view identity, and an uncached folder appends
nothing rather than a raw backend id.WatchService stores a JSON watch list
(watches.json) keyed on
ConversationBuilder.NormalizeSubject — the same key the
Conversations view mode already uses — rather than an
is_watched column. Future messages are therefore already
members without anything having to notice them arrive, and watches
survive a cache clear and work unchanged in --online mode.
MailMessageSummary.IsWatched is transient and derived,
stamped in SetMessages and OnFolderSynced, and
is deliberately not in
ReconcileMessageState: a freshly fetched summary has never
been stamped, so copying it would clear the flag on every aggregate
merge. FetchAllMailAsync,
FetchViewFoldersAsync, and
FetchAccountAllMailAsync insert via
InsertMessageSorted, bypassing SetMessages, so
they stamp explicitly — without it the newest rows, the ones this
feature exists for, spoke no watch state.SelectedMessage.
GroupedMessageTreeController assigns
SelectedMessage only for a MailMessageSummary,
so selecting a group header leaves it stale — reading it watched the
wrong conversation and announced that wrong subject, in Conversations
view, which is exactly where the command gets used.
MainWindow supplies a WatchTargetResolver
(same shape as RegisterAccountBackend): a conversation
group resolves to its own subject, a From/To group header to null so the
command is unavailable, otherwise the selected message.
isAvailable reads the target without storing it, since
availability is polled and must not mutate state.'W'. Focus is inside the document as soon as a
message opens, so neither the WPF key ladder nor the local registry is
reachable. The reading pane’s relay and MessageWindow’s
separate one both forward the chord; the test must accept
'W' as well as 'w' because the browser reports
the upper-case key with Shift held, and the branch is ordered
before the lower-case ctrl-w branch so
closing and watching stay distinct. Ctrl+Shift+P had the
identical pre-existing gap in MessageWindow and is relayed
too. MessageWindow raises WatchToggleRequested
and MainWindow routes it to MainViewModel,
keeping one writer for the watch list so main-window rows and the
watched folder stay in step.--online mode. The count read moved into
Task.Run because Sqlite’s *Async completes
synchronously, so the read and its GroupBy were running on
the dispatcher. Rename is deliberately label-only: the matching key is
the normalized subject, and editing it would silently change which
messages the watch collects while presenting as a rename. Identity is
captured at BeginRename, since selection stays reachable
while renaming (F6, arrows) and a plain keyboard sequence otherwise
renamed the wrong watch silently.Alt+G/R/S/C are
wired explicitly and guarded on Modifiers == Alt exactly,
so AltGr (Ctrl+Alt) still reaches type-ahead, and they are suppressed
while a rename is in progress. Escape cancels the rename rather than
closing the window: PreviewKeyDown tunnels
root-to-leaf, so the window override runs before the text box’s handler.
The list is a constructor-time snapshot while Ctrl+Shift+W
stays live behind it, so both stale paths now say so and reload, and
stopping a watch here routes through RefreshWatchStateFor —
one writer, one place that reacts._notifiedMessageKeys and
NewMailFilter.SelectNew consumes whatever it is shown:
handing it the full list would claim ordinary inbox mail and silently
suppress the new-mail toast, and running it second would give a watched
inbox message the generic toast instead of the informative one. A
watched inbox message produces exactly one toast, the watched one.
NotifyOnWatchedConversation defaults off and fires in every
folder, unlike the inbox-only new-mail path.MessageFilter value touches eleven
sites and nothing enumerated the enum, so a missed site failed
silently. The new coverage test walks every value through
FilterKey → SetFilterCommand →
FilterLabel, asserting keys are unique, nameable, and that
none can describe itself as “All” — and it immediately surfaced two real
pre-existing bugs. ViewManagerViewModel’s two summary
builders labelled the current filter from
CurrentFilter.ToString().ToLowerInvariant(), but the enum
name and the stored key are not the same string for every value
(WithAttachments lowercases to
withattachments, which FilterLabel has no arm
for); both now route through FilterKey, the single writer
of that field.ApplyViewAsync resolves a saved view’s
VirtualFolderKey against — so a view saved over it fell
through to a fabricated folder instead of the live singleton. Flagged
twice as pre-existing debt during this work; the fix is two lines.docs/planning/watched-conversations-pm-dev-spec.md and
docs/planning/watched-conversations-phase2-pm-dev-spec.md.
Section 14A of the first and 13A of the second record each defect found
by adversarial review and why it is re-introducible.TypeAheadPrefixTracker +
TypeAheadMatcher (new) — the hand-rolled prefix
accumulator and wrap-around matcher, extracted from
MainWindow so they can be tested without a window (#415).
The tracker takes a TimeProvider, so
TypeAheadLogicTests exercises the 1-second reset window
deterministically, including its exact boundary — coverage that
previously required synthesized keystrokes racing a real clock
(#380/#414). The tracker’s peek/commit split also closes a latent
double-append: the PreviewKeyDown route now peeks and
commits only on a match, so an unmatched keystroke is recorded once (by
PreviewTextInput), not twice.TypeAheadWiringTests now fails any
TreeView declaring
TextSearch.TextPath. WPF disables text search by
default on TreeView/TreeViewItem (verified
against the control defaults;
ListBox/ListView/ComboBox enable
it), and even enabled it matches one level’s items only — which is how
the picker’s inert attribute shipped in v0.8.32 with a release note
claiming it worked. Trees must wire PreviewTextInput to the
shared tracker instead.MainViewModel.LoadAccountList carries
IsConnected/TotalUnread across a
reload, keyed by account id. Both are runtime state
deliberately excluded from accounts.json, so rebuilding the
models produced objects defaulting to disconnected, and replacing the
Accounts collection made the whole list read that way at
once. RefreshAccountList reconnects only accounts failing
AccountsNeedingConnect, so healthy accounts were never
re-evaluated and the false label persisted until restart. The carry-over
is skipped when the account’s connection identity changed (host, port,
login, auth or security settings), so an edited account is not vouched
for by connections belonging to the old server; duplicate ids in
accounts.json no longer throw on reload. Guarded by
AccountListReloadStatusTests (4 of its 5 fail with the
carry-over removed) and
AccountListCarryOverGuardTests.ApplyAccountStatus
genuinely is the only writer of IsConnected, so no write
was ever observable — the journal’s silence next to a plainly visible
symptom was itself the evidence. LoadAccountList now
records an accounts-reloaded event closing that blind spot,
and ApplyAccountStatus takes a source tag from
all eight call sites.ConnectionJournal (new) — bounded,
self-rotating connection.log plus a 2000-event in-memory
ring backing the diagnostics window. Gated on
ConfigModel.ConnectionDiagnostics (default
false). Record returns on a volatile read
before allocating, but arguments to the eager overload are
evaluated first — so call sites whose detail invokes
HostConnectionCensus (which resolves DNS) use a
Func<string> overload or check Enabled
themselves. An independent review caught the original version computing
the census on every pool rent with the feature off. Enable and disable
each write a marker, so a journal that stops is distinguishable from one
switched off mid-investigation. File writes take a separate lock from
the ring, so a background disk write never blocks a UI-thread
Snapshot().HostConnectionCensus (new) — live
socket counts per host with cached DNS resolution, and shared-address
detection. Our cap is per account; a server’s is per
user+IP, and on shared hosting per IP overall. Registrations
live in a ConditionalWeakTable keyed by client so release
is idempotent. Documented limitation: the count is decremented only via
Released(), which every disposal path funnels through — a
client collected without being disposed would leave the counter
high, so an implausibly high census is to be treated as suspect rather
than as proof and cross-checked against the pool= figure on
the same line.IConnectionProbe /
ConnectionTruthProbe (new) — independent
reachability verification on a connection sharing nothing with the pools
or watchers, emitting a greppable verdict line stating what
the UI shows against what the server said. Serialized process-wide and
rate-limited per account, since a per-IP limit is a plausible suspect
and the probe must not become part of what it measures. Gated on
the setting at every entry point: an independent review found
the first version starting its verification loop regardless, so a single
IDLE failure on a default install opened an authenticated connection
outside the pool cap every 60 seconds, indefinitely — against exactly
the host already refusing connections. RetainOnly also
abandons verification for an account removed while it was showing
disconnected, which otherwise probed a deleted mailbox for the rest of
the session.ProbeResult carries a three-state
ProbeOutcome
(Reachable/Unreachable/NotSupported).
The first live run wired the probe straight to
ImapMailService, which answered “not registered with the
IMAP service” for a Graph account; collapsed to a
boolean that read as unreachable, it reported a healthy account as
broken. Unreachable is false for NotSupported,
so the two cannot be conflated again. GraphMailService
implements the interface via the Inbox counts, and
MailServiceRouter dispatches per account with no
default-backend fallback — defaulting to IMAP was the original
defect.ImapMailService.RaiseReachability
funnels AccountReachabilityChanged so every raise carries a
reason. Worth noting for anyone reading the connection code: the IDLE
watcher is still the only source of reachability, and it marks an
account unreachable after a single failure, before any retry
(#314).ConnectionDiagnosticsWindow is
modeless per the modal-dialog rules, with its own F6 ring, Escape
handling, focus restoration, a CancellationTokenSource
field cancelled in OnClosing (closing mid-test previously
left a probe holding the process-wide probe semaphore for up to 45
seconds), and a window-scoped command palette listing
its own actions rather than the main window’s. Pane names on F6 announce
as Status, not Hint, so turning hints off does
not make the ring silent. Refresh preserves the event
filter — rebuilding the combo dropped its selection, wrote null back
through the TwoWay binding, and blanked the journal pane permanently.
help.connectionDiagnostics is registered and unregistered
by ApplyConnectionDiagnosticsSetting rather than at
startup, so the palette and the Help menu never disagree about whether
the feature exists.quickmail.log, connection.log (and its
rolled-over .1, clearing the in-memory ring with it), and
the whole debug-screenshots tree.
ScreenshotCaptureService.DeleteAllCaptures is static and
profile-keyed so it works in a normal launch where only the null capture
service is wired; in-flight PNG saves are flushed first so nothing
survives by holding a handle (#436).AccountModel.LoginUsername (persisted,
nullable) plus computed AuthUsername =
LoginUsername ?? Username. Every password
authentication uses AuthUsername — IMAP, all three SMTP
entry points, and the iCloud CardDAV/CalDAV Basic auth in
ICloudContactSource and
GraphCalendarSyncService, which is the same credential
pair. OAuth still uses Username, the mailbox the token was
issued for. Username is now documented as the email address
and nothing else — it is the From header, the provider-catalog match,
and the autodiscovery domain. SameConnectionSettings
includes LoginUsername so a corrected login invalidates the
pooled client.EmailAddressValidator (new) parses
with AllowAddressesWithoutDomain = false — MimeKit’s
default accepts a bare local part, which is exactly the input that
produced MAIL FROM:<fastfinge>. Deliberately does not
require a dot in the domain. TryNormalize returns
mailbox.Address, and that normalized form is what both
editors save: MailboxAddress.TryParse accepts
Kelly Ford <kelly@example.com>, an angle-addr, and
padded input, while the MailboxAddress(name, address)
constructor MimeMessageBuilder calls throws on all three —
so validating without normalizing would only have moved the failure from
a refused save to a rejected send. Enforced in
AccountEditorViewModel.IsEmailAddressUsable (shared by
IsReadyToSave and
AccountManagerViewModel.SaveAccount) and as a pre-send
guard in ComposeViewModel.SendAsync.AccountStartupRepair (new) runs in
OnStartup against the loaded account list and does two
things. It corrects ImapUseSsl/SmtpUseSsl
where the account carries no ProviderId (the marker for
predating the catalog — SaveAccount backfills it, so a
deliberate pairing the user has saved is never overruled), the host
equals a catalog provider’s host, and the port equals that
provider’s published port; a leg moved to STARTTLS also gets
RequireStartTls, matching what ApplyProvider
sets for the same host and port, so a repaired account is not left
weaker than a freshly added one. It also copies a non-address
Username into LoginUsername on password
accounts, so correcting the address does not destroy the working login.
Matched on host rather than ProviderCatalog.Resolve, whose
email-domain fallback would claim an address relayed through a
third-party server.SmtpService.DisconnectQuietlyAsync
replaces the in-try DisconnectAsync in
SendAsync, SendIcsReplyAsync, and
VerifyAsync, so a failed sign-off cannot be reported as a
failed send.AnnouncementCategory StatusCategory on
ComposeViewModel and AccountEditorViewModel,
with SetStatusOutcome/SetProgress.
One-shot — it returns to Status after
every raise, matching
MainViewModel.StatusAnnouncementCategory, because both VMs
assign StatusText directly in dozens of places and a
latched Result would re-classify all of them as
interrupting outcomes. The setter also clears StatusText
first so an identical repeated message still raises
PropertyChanged; without that, pressing a button twice on
the same unfixed field announced nothing the second time. Replaces
AddAccountDialog’s local _statusCategory
field, and removes three double-announce sites in
ComposeWindow.
AccountManagerViewModel.EmailAddressRejected lets the View
open Advanced settings and focus the address box, since the refusal
names a control behind a collapsed expander.FeatureFlag.GoogleAuth default flips to
false. It gates only the offer — no
runtime authentication path consults it, so saved
AuthType.OAuth2Google accounts are unaffected. New
ProviderCatalog entry gmail-oauth (“Gmail
(sign in with Google)”), DefaultAuthType = OAuth2Google, no
app-password hint, exposed as
IProviderCatalog.GmailGoogleSignIn. It carries the
gmail.com domains but sits after the plain Gmail entry, so
MatchByEmail and Resolve’s host fallback still
answer gmail for every Gmail address; it is reached only by
an explicit pick or a saved ProviderId.
AccountEditorViewModel.Providers is an
ObservableCollection<MailProvider> built from the
catalog minus the Google entry, with
EnsureGoogleSignInListed() inserting it after Gmail —
absent from the list rather than collapsed, so it is out of the keyboard
order and the accessibility tree entirely.
ShowGoogleAuthOption is
IsGoogleAuthEnabled || AuthType == AuthType.OAuth2Google,
and that second clause is what keeps an existing Google account’s
Authentication combo populated. ConfigModel.Features is an
OrdinalIgnoreCase dictionary so
SettingsViewModel.GoogleSignIn updates the existing key
instead of adding a second one in different capitalization.TextBox, not a TextBlock. A TextBox
exposes its Text as a value alongside the
LabeledBy name; on a TextBlock, LabeledBy
overrode the automatic name (its own text) and left no value behind it,
so the binding was announced as nothing.Theme.FocusHaloThickness
(= FocusThickness + 2) is consumed by
AccessibleStyles.xaml as a halo stroke under the focus
dash. ThemedControls.xaml gained a ToolBarTray
style and the seven ToolBar.*StyleKey item styles — WPF
default chrome ignores theming, which is why the unstyled ToolBar
shipped washed-out for months and passed every sighted spot-check.
Ember, Fjord, and Heather went from 4-token files inheriting from
Parchment to full 26-token palettes.
ThemedControlCoverageTests requires every WPF control type
used in Views/ and Controls/ to have an
implicit style or a reviewed exemption; contrast is computed by the WCAG
math in BuiltInThemeTests, never eyeballed.Theme.ListRowPadding is Thickness(2,1,2,1)
compact versus Thickness(4,3,4,3) comfortable; the UIA tree
and announcements are identical in both modes by design. Compact
reproduces the message list’s original shipped rendering, whose row
template hardcoded 2,1. ThemeService now
separates its publish signature from its announce signature so a density
change does not announce a theme change.ScreenshotCaptureService is constructed only under
/debug; otherwise a null service is wired, so the feature
is structurally inert rather than merely hidden. The Settings group is
bound to IsDebugDiagnosticsVisible
(LogService.DebugMode && _screenshotCapture != null),
capture defaults off and is session-only, output goes to
<profileDir>\debug-screenshots\<yyyyMMdd-HHmmss>\,
and every window title gains a ” - SCREENSHOTS ON” suffix while it is
running.scripts/ui-probe.ps1,
scripts/ui-probe-plan.json,
scripts/ui-review-prompt.md, and the
Tools/QuickMail.Fixtures project, which is referenced only
by the test project and never packaged
(installer/quickmail.iss ships a single file). In-app it is
a launch mode only — --ui-probe <surface> implies
/debug, forces offline services, drives the surface,
captures, and exits; malformed args shut down with exit 64. The script
refuses to run on a locked desktop, since DWM never composites new
windows there and captures come out white.ComposeWindow’s form grid was not the
DockPanel fill child. The autocomplete
Popup was declared last, so LastChildFill
applied to it — a Popup occupies no layout space — and the
grid fell back to Dock=Left at content width. Declaring the
popup before the grid fixes it; a Popup’s position in the
child list has no layout or rendering effect because placement is set in
code-behind. Element set, tab order, and UIA tree are unchanged.RadioGroupNavigation (new,
Helpers/) — an opt-in attached behaviour,
SelectionFollowsFocus="True", set on the container of a
radio group. Arrow keys move to the next or previous enabled button of
the same GroupName (wrapping, to match
DirectionalNavigation="Cycle"), focus it, and check it;
every other key is left to WPF. Walks the logical tree,
so it works before the group has been rendered. It checks the button
before focusing it, so the focus that follows lands on
an option already selected and the state reported is the new one, and it
announces nothing itself. Applied to the four
SettingsDialog groups and the
EventEditorWindow edit-scope group. Deliberately
not applied to the FlagManager colour swatches, whose
buttons carry a Command: ButtonBase invokes it
from OnClick(), which a programmatic
IsChecked = true does not raise, so arrowing there would
show a swatch as chosen while the command that applies the colour never
ran.
SettingsDialog.RadioButton_Checked is
deleted, not suppressed. f71f86f added it so that choosing an
option was announced at all, on the reading that a bare
StackPanel is not a UIA selection container. That was
treating a symptom: the reason a choice went unannounced is that
arrowing never made one — it moved focus and left the selection behind.
With selection following focus the platform reports the change itself,
and an app that speaks over that is overriding a decision the user has
already made in their own software. The suppression flag the first cut
of this fix introduced (IsMovingSelection) is gone with it;
a mechanism whose only job is to mute an announcement that should not
exist is a sign the announcement is the bug. Guarded by
RadioGroupWiringTests.SettingsRadioButtons_DoNotAnnounceThemselves.
Controls/DateTimeField is a plain
TextBox with no automation peer of its own. Three shapes
were built and evaluated with three screen readers before this one was
chosen: an edit field, an edit field claiming
AutomationControlType.Spinner, and a purpose-built spinner
implementing IValueProvider and
IRangeValueProvider. All three announced correctly, so the
one that invents nothing won — replacing TextBox.Text
raises the UIA value-change event screen readers already act on. There
is no AccessibilityHelper.Announce in the
stepping path, and there must not be: a programmatic announcement is
filtered by the user’s announcement settings, a native value change is
not. Stepping and parsing live in
Helpers/DateTimeFieldParser.cs (pure, unit-tested); a time
field holds a full instant rather than a TimeSpan, so
stepping past midnight carries into the date, and
TryParseTime explicitly rejects date-shaped
text because DateTime.TryParse("8/3") succeeds
with a TimeOfDay of zero.
MailMessageDetail.Attachments keeps the
inherited HasAttachments flag in sync — nothing
else ever set it on a detail. ImapMailService,
GraphMailService, and
LocalStoreService.LoadDetailAsync all populate the list
only, and MainViewModel patched the summary after
loading, which is exactly why the reading pane looked right.
MessageWindow.xaml binds the attachment list’s
Visibility to MessageDetail.HasAttachments
with FallbackValue=Collapsed, so the always-false flag hid
the list, and FocusAttachmentList’s visibility check then
reported “No attachments.” Fixing the three producers one at a time
would have left the next one free to reintroduce it.
MessageDetailAttachmentTests covers the invariant, the
local-store round trip, and reads MessageWindow.xaml to
assert that whatever property the Visibility binding names really is
true for a message with attachments.
WebView2 splits link activation across two events, and
every host must handle both. An ordinary link raises
NavigationStarting; a link carrying
target="_blank", or one activated with
Ctrl/Shift/middle-click, raises NewWindowRequested
instead — the parent CoreWebView2 never
sees a NavigationStarting for it. Unhandled, WebView2’s
default is to open the URI in its own popup window against QuickMail’s
user-data folder, which is a browser with none of the user’s cookies,
passkeys, or extensions. MainWindow handled both;
MessageWindow and MarkdownPreviewWindow
handled only the first, so window mode leaked links into the app. Both
now set args.Handled = true and route the URI through
ExternalUriPolicy, same as the reading pane.
TryStripHeavyHtml additionally strips the
target attribute, so the rendered document does not depend
on any host getting the second event right.
ExternalLinkWiringTests discovers WebView2 hosts by
scanning Views/*.xaml for the element tag — not the bare
word, which appears in a comment in ThemeManagerWindow.xaml
and as a leftover xmlns in ComposeWindow.xaml
— and asserts each code-behind handles NewWindowRequested
and routes through the policy, so a new WebView2 window is covered the
day it is added. (#483)
WpfTests
collection. The radio-group wiring tests are plain XML parsing
and were first written inside RadioGroupNavigationTests,
which carries [Collection("WpfTests")]. That reliably broke
six AccountDialogHintTests (“focusing AccountNameBox
produced no hint. Heard: (nothing)”) across three full runs, while
main was green under the same conditions — the collection
exists to serialize window-loading tests, and non-STA tests scheduled
inside it disturb them. Moving them to their own class fixed it. Worth
remembering the next time a full-suite run goes red in a class the
change never touched.AccountStartupRepairTests (17),
AccountLoginUsernameTests (20),
ComposeViewModelSendFeedbackTests (13),
EmailAddressValidatorTests (26),
GoogleSignInOptInTests (20),
RadioGroupNavigationTests (10),
RadioGroupWiringTests (6),
SyncServiceRuleApplicationTests,
MessageDetailAttachmentTests,
TypeAheadLogicTests,
ConnectionDiagnosticsTests,
ConnectionDiagnosticsSettingTests,
ConnectionDiagnosticsWindowTests,
ConnectionDiagnosticsReviewFixTests,
AccountListCarryOverGuardTests,
AccountListReloadStatusTests,
WatchServiceTests, WatchedConversationsTests,
WatchedConversationsPhase2Tests,
ExternalLinkWiringTests, MainViewModelTabTests
and TabSessionModelTests (tab and window management, Phase
6, closing #40), ImapConnectionInstrumentationTests (real
connect path against a closed port and a hang-up listener, asserting
socket error codes are captured and the census does not drift), plus
login-identity tests in CardDavContactSyncTests and
CalDavCalendarSyncTests.
StatusAnnouncementRecorder captures announcements inside
the PropertyChanged notification, the way the View does —
asserting the category afterwards would pass against a broken
implementation now that the category is one-shot.LogServiceTests is
immune to parallel writers; the capture service no longer crashes across
threads in parallel runs (#433); two flaky tests that depended on the
real clipboard and on window activation were repaired (#410); and
synthesized-input type-ahead tests are gated behind
QUICKMAIL_RUN_INPUT_TESTS (#414).bin/obj are
excluded from the default Compile glob via
Directory.Build.props, and the RID and TFM no longer appear
in output paths, so Dependabot can read the dependency graph (#382).
CI’s -warnaserror gate is now real rather than nominal
(#446).