Engineering note
The reindex that threw away everything it had
A user wrote in with a bug report and three screenshots. Everything was granted — Automation, Accessibility, the extension enabled — and indexing his sent mail failed completely, with the message “MailTwin couldn’t complete that in Mail, so nothing was changed.” Nothing was changed was the accurate part, and it was the bug.
What the code was doing
Style learning reads sent mail in batches of five, one Apple Event per batch, and asks Mail for the full body of each message. It allowed fifteen seconds per batch.
Fifteen seconds is enormous when the messages are on the disk in front of you. It is a coin flip when they are not. On an IMAP or Exchange account that Mail has not yet cached — a fresh install, a new Mac, a recently added account — content of message is a network fetch, and five of them in sequence over a slow link is simply a different operation from the one the number was chosen for.
So the timeout was not really a timeout. It was an assumption about where the bytes were, written as a number.
The part that was worse than the timeout
The batching loop looked like this, and the shape is the point:
while offset < limit {
let batch = try fetch(offset, count) // one Apple Event
messages.append(contentsOf: batch)
offset += count
}
A try with no catch around it. If batch forty timed out, the throw travelled up past thirty-nine batches that had already succeeded and the caller reported total failure. Two hundred messages were the target; a hundred and ninety-five had been read; the user got nothing and a sentence telling him nothing had changed.
That is not a timeout bug. That is a decision nobody made. Nobody sat down and chose “if the last batch fails, discard the previous thirty-nine” — it is what you get for free when a loop propagates errors, and free is exactly why it survived.
Retry once, then keep what you have
Mail being slow for one batch is the common case, not the fatal one, so a timed-out batch is retried once at the same offset. If it fails again the loop stops and returns what it collected.
Partial voice data is worth enormously more than none. A style profile built from a hundred and ninety-five messages is not meaningfully worse than one built from two hundred; it is infinitely better than an empty one, and the difference between them is the difference between a working feature and a support ticket.
Only timeouts are survivable
The interesting question is not what to catch. It is what to keep catching narrowly.
A timeout means Mail was slow. Everything already read is still valid, so keeping it is sound. But the same loop can also throw because the mailbox scope changed underneath us — and if that happens, the messages already collected may belong to a different account than the one we think we are indexing. Keeping those would silently poison a per-account voice profile with another account’s writing, which is the exact failure the per-account design exists to prevent.
So the scope error still aborts, and cancellation still propagates so the Stop button keeps working. Survivability is a property of the specific failure, not of failures in general. A catch that is one clause too wide is how you turn a loud bug into a quiet one.
The typed error asked two questions
None of this was expressible, because every timeout arrived as an untyped string:
case scriptError(String) // "Mail.app did not respond within 15 seconds."
Indistinguishable from any other failure without matching on English prose. Adding case mailTimeout(seconds: Int) immediately broke two exhaustive switches, and both breaks were the compiler asking a question worth answering.
The first was the local Activity receipt, which records a closed error class rather than free text. A timeout earned its own class: Mail was too slow to answer is something a person can act on — sync first, try again — where script error is not. The second was the compose path, where a timeout means Mail did not answer, which for that surface is the same outcome as Mail being unavailable and wants the same remedy.
Two switch statements is a small price for making a failure mode nameable. It is also the whole argument for typed errors in one example: the type system made us decide, in both places, what this failure means to the person on the other side.
Testing a thing that only happens on someone else’s network
The batching loop takes its fetch as a closure, so the tests inject one that throws on a chosen offset. Three cases: a batch that fails twice keeps everything read before it and retries exactly once; a batch that succeeds on retry keeps its own contents and the run continues; a non-timeout failure still aborts the whole run.
Then the only step that matters — revert the fix and confirm the tests fail. Two of the three did. A test that passes with and without the change is a test of nothing, and this class of bug is where they collect, because the happy path is identical either way.
The number is still a guess
Forty-five seconds is a better guess than fifteen. It is still a guess, and on a slow enough link it will be wrong again.
What changed is what happens when it is wrong. The old design needed the number to be right. The new one needs it to be roughly right, and degrades by returning less rather than returning nothing. That is the part worth keeping: when a value is a guess about someone else’s network, spend the effort on the failure path rather than on the guess.
MailTwin is a menubar app for Apple Mail. Keywords in Mail’s own reply window become a finished reply in your own writing style, learned locally per mailbox from your sent mail. Eight AI providers with your own key; Apple Intelligence and Ollama run entirely on the Mac. mailtwin.ai