
Postmaster as state machines.
A native Mac mail client for Gmail, Outlook, and any IMAP or JMAP account. Each action shows at once and reaches the server later. A send survives a crash without a second copy, and the HTML of a message cannot run code or load anything without consent.
- Product
- Mac app · Email
- Year
- 2026
The problem
A good mail client feels instant: a message leaves the inbox when you press a key. But the server confirms the change later, over IMAP, JMAP, or the Gmail API. Between the two, the app can quit, the network can drop, the server can refuse, and a refresh can arrive with the old state.
Sending has a harder rule: a message must go out one time. Not zero times when the app crashes, and not two times when it starts again. And each message that arrives is HTML from a stranger, which must show as its sender made it, but must not run code or tell the sender that it was opened.
Ali's role
Ali made Postmaster: the product concept, the interface design, the native macOS app in Swift and AppKit, and the website.
The app speaks IMAP, SMTP, JMAP, and the Gmail API with its own clients. It has no server and no helper process: one Swift actor in the app owns the connections, the local store, and the background work. Its only package dependency is Sparkle, for updates.
- Stack
- Swift, AppKit, IMAP and JMAP
The state machines
Each diagram comes from the same data as the tables under it. The tables give the same facts as text.
1. One message in the outbox
With Undo Send or Send Later, a message goes into the outbox in the local profile, and a timer sends it. The profile is written atomically before each step. The message gets its Message-ID when it is queued, and keeps it for each attempt.
States (6)
- Queued
- The message and its send time are in the profile. A timer waits for the send time.
- Sending
- The status “sending” is in the profile before the provider call starts.
- Checking
- At launch, a message is still “sending”: the app stopped during the send. The app looks for its Message-ID in the Sent folder.
- Failed
- The send failed. The outbox shows the error and the time of the next try.
- Sent
- The message leaves the outbox, and its draft is deleted.
- Withdrawn
- Undo, Cancel, or Edit removed the message. Edit opens it in a composer again.
Transitions (12)
| From | Event | To |
|---|---|---|
| Start | send with Undo Send or Send Later | Queued |
| Queued | send time comes, or Send Now | Sending |
| Queued | Undo, Cancel, or Edit | Withdrawn |
| Sending | provider accepts it | Sent |
| Sending | send fails | Failed |
| Sending | app stops | Checking |
| Checking | found in Sent | Sent |
| Checking | not in Sent | Sending |
| Checking | check fails | Failed |
| Failed | retry time comes (2 min, doubling to 60 min) | Sending |
| Failed | new send time | Queued |
| Failed | Cancel or Edit | Withdrawn |
In the code
apps/macos/Sources/PostmasterNative/SwiftMailEngine+Compose.swiftapps/macos/Sources/PostmasterNative/SwiftMailEngine.swiftapps/macos/Sources/PostmasterNative/MailStore+Actions.swift
2. One action on a message
The app keeps two layers of message state: what the server confirmed, and a projection with the pending changes on top. The list shows the projection. Jobs go to the server in lanes, one lane for each conversation.
States (5)
- Shown
- The change is in the projection, so the list shows it at once. The confirmed state stays under it.
- Waiting
- The job waits for its conversation lane. At most 4 jobs run at a time, and undo jobs go first.
- Applying
- The provider call runs. A change to more than one message first stores an intent in the profile.
- Confirmed
- The change becomes part of the confirmed state, and leaves the projection.
- Dropped
- The server refused. The change leaves the projection, so the confirmed state shows again, and the rows come back.
Transitions (7)
| From | Event | To |
|---|---|---|
| Start | archive, star, move, snooze, or delete | Shown |
| Shown | a refresh arrives | Shown |
| Shown | job queued | Waiting |
| Waiting | lane free | Applying |
| Applying | server applies it | Confirmed |
| Applying | server refuses it | Dropped |
| Confirmed | Undo: a new, inverse change | Shown |
In the code
apps/macos/Sources/PostmasterNative/OptimisticMail.swiftapps/macos/Sources/PostmasterNative/MailActionQueue.swiftapps/macos/Sources/PostmasterNative/MailStore+Actions.swiftapps/macos/Sources/PostmasterNative/SwiftMailEngine+Mutations.swift
What can go wrong
The app stops in the middle of a send
The app crashes or quits after the server took the message, but before the outbox recorded it. A blind retry sends a second copy.
How the design handles it
- The message gets its Message-ID when it is queued, and keeps it for every attempt.
- The outbox writes the status “sending” to the profile before the provider call. The profile is written atomically, so it is never half-written.
- At launch, the app sets a timer again for each message in the outbox. A message that is still “sending” is marked as interrupted.
- Before it sends an interrupted message again, the app looks for its Message-ID in the Sent folder, over IMAP, JMAP, or the Gmail API. If it is there, the message leaves the outbox and is not sent again.
apps/macos/Sources/PostmasterNative/SwiftMailEngine+Compose.swiftapps/macos/Sources/PostmasterNative/SwiftMailEngine.swift
A send fails
The network is down, the server refuses, or the token expired. The person thinks that the message went out.
How the design handles it
- A failed send stays in the outbox with the message “Delivery failed. Postmaster will retry automatically.” and the time of the next try.
- The next try comes after 2 minutes, then 4, 8, and so on, up to 60 minutes.
- The person can give a failed message a new send time, cancel it, or edit it. A message that is sending at that moment cannot change.
- The outbox holds at most 1,000 messages of at most 32 MiB each, so it cannot grow without a limit.
apps/macos/Sources/PostmasterNative/SwiftMailEngine+Compose.swift
A refresh brings back the old state
You archive a message, and a sync that started a second earlier arrives with the message still in the inbox. Without care, the message comes back.
How the design handles it
- In the interface, a server snapshot does not replace a message that has a pending change. The change stays on top of the confirmed state until the server answers.
- In the engine, each local change gets a number from a clock. A sync that started before the change does not overwrite the flags of that message, and does not remove it from the cache.
- A batch change stores an intent in the profile before the first server call. Until the intent is cleared, sync protects every message in it. After a crash, Recover Messages shows the intent, and the person can apply it again.
apps/macos/Sources/PostmasterNative/OptimisticMail.swiftapps/macos/Sources/PostmasterNative/SwiftMailEngine+Mutations.swiftapps/macos/Sources/PostmasterNative/SwiftMailEngine+Sync.swiftapps/macos/Sources/PostmasterNative/RecoveryWindowController.swift
The server refuses an action, or an undo comes late
The archive of 4 messages fails for one of them. Or you undo an archive after you also starred the message, and the undo removes the star.
How the design handles it
- A refused change leaves the projection, so the confirmed state shows again without a manual rollback. The rows come back in the list, and the selection is restored.
- The error says how much worked, for example “Archive: 3 completed; 1 failed.”
- An undo is a new change. It restores only the fields that still have the value that the first change gave them, so a later change stays.
- If an undo fails, the failed part stays on the undo stack.
- Over IMAP, a move can be undone only when the server returned the new UID of the message. Without it, the app does not offer an undo that could move the wrong message.
apps/macos/Sources/PostmasterNative/OptimisticMail.swiftapps/macos/Sources/PostmasterNative/MailStore+Actions.swiftapps/macos/Sources/PostmasterNative/SwiftMailEngine+Mutations.swift
New mail is late, or a folder changes on the server
A push connection dies without an error, a sleeping laptop wakes up, or the server rebuilds a folder and gives its messages new UIDs.
How the design handles it
- For each account, one connection waits in IMAP IDLE on the inbox, or on the JMAP push stream. The IDLE command is renewed every 285 s, before routers drop a quiet connection.
- When the connection drops, it opens again after 1 s, doubling to 300 s, with random jitter. A connection that lived longer than 60 s starts the delay at 1 s again.
- Push events that come close together cause one refresh, 300 ms after the first.
- A sync loop also runs every 60 s while the app is in front or notifications are on, and every 300 s when not.
- IMAP sync uses QRESYNC or CONDSTORE when the server has them, so it fetches only the changed flags and the removed messages. When nothing changed, it stops after SELECT.
- When UIDVALIDITY changes, the old UIDs mean nothing, so the app clears its cache for that folder and loads it again.
apps/macos/Sources/PostmasterNative/SwiftMailEngine+Push.swiftapps/macos/Sources/PostmasterNative/SwiftMailEngine+Sync.swiftapps/macos/Sources/PostmasterNative/SimpleMailProtocols.swift
A message tries to run code or to track
Message HTML is written by a stranger. It can carry scripts, forms, and remote images that tell the sender when and where the message was opened, or that point at a server on the local network.
How the design handles it
- The reader turns off JavaScript, sets a content security policy with default-src none, cancels every navigation, and uses a data store that is not kept. Sender styles stay, so a message looks as its sender made it.
- Scripts, frames, and embedded objects are removed. Form tags are removed but their content stays, and the policy refuses every form action. Fonts and style sheets from the network are never fetched.
- Remote images are blocked until the person approves them. Then the app fetches them itself, not the web view, and gives them to the page as data.
- An approved image must use HTTPS and resolve to a public address: the app checks the DNS answer and each redirect, and refuses localhost and private ranges.
- Each image can be at most 8 MiB, and all images of a message together at most 20 MiB. An image shows only after ImageIO decodes it, so its bytes decide that it is an image, not its content type.
apps/macos/Sources/PostmasterNative/SafeMessageWebView.swiftapps/macos/Sources/PostmasterNative/ReaderImageSchemeHandler.swiftapps/macos/Sources/PostmasterNative/SwiftMailEngine+Reading.swiftapps/macos/Sources/PostmasterNative/SimpleMailProtocols.swift
Numbers
Only numbers from the code or from a count. Each one says where it comes from.
- longest Undo Send
- 30 s
- undoDelay in send, in apps/macos/Sources/PostmasterNative/SwiftMailEngine+Compose.swift.
- between tries of a failed send
- 2 → 60 min
- retryDelay in deliverScheduled, in apps/macos/Sources/PostmasterNative/SwiftMailEngine+Compose.swift.
- between renewals of IMAP IDLE
- 285 s
- renewAfter in SimpleIMAP.watch, in apps/macos/Sources/PostmasterNative/SimpleMailProtocols.swift.
- for one remote image, and for all of a message
- 8 · 20 MiB
- readerImage and resolvedImages in apps/macos/Sources/PostmasterNative/SwiftMailEngine+Reading.swift.
Sources
Postmaster is a private repository. Paths are at commit 22e0c83. Every claim on this page comes from these files.
apps/macos/Sources/PostmasterNative/MailActionQueue.swiftapps/macos/Sources/PostmasterNative/MailStore+Actions.swiftapps/macos/Sources/PostmasterNative/OptimisticMail.swiftapps/macos/Sources/PostmasterNative/ReaderImageSchemeHandler.swiftapps/macos/Sources/PostmasterNative/RecoveryWindowController.swiftapps/macos/Sources/PostmasterNative/SafeMessageWebView.swiftapps/macos/Sources/PostmasterNative/SimpleMailProtocols.swiftapps/macos/Sources/PostmasterNative/SwiftMailEngine+Compose.swiftapps/macos/Sources/PostmasterNative/SwiftMailEngine+Mutations.swiftapps/macos/Sources/PostmasterNative/SwiftMailEngine+Push.swiftapps/macos/Sources/PostmasterNative/SwiftMailEngine+Reading.swiftapps/macos/Sources/PostmasterNative/SwiftMailEngine+Sync.swiftapps/macos/Sources/PostmasterNative/SwiftMailEngine.swift