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.

One message in the outbox: state diagram. The tables below list the same states and transitions.

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)

Transitions of one message in the outbox: the state before, the event, and the state after.
FromEventTo
Startsend with Undo Send or Send LaterQueued
Queuedsend time comes, or Send NowSending
QueuedUndo, Cancel, or EditWithdrawn
Sendingprovider accepts itSent
Sendingsend failsFailed
Sendingapp stopsChecking
Checkingfound in SentSent
Checkingnot in SentSending
Checkingcheck failsFailed
Failedretry time comes (2 min, doubling to 60 min)Sending
Failednew send timeQueued
FailedCancel or EditWithdrawn

In the code

  • apps/macos/Sources/PostmasterNative/SwiftMailEngine+Compose.swift
  • apps/macos/Sources/PostmasterNative/SwiftMailEngine.swift
  • apps/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.

One action on a message: state diagram. The tables below list the same states and transitions.

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)

Transitions of one action on a message: the state before, the event, and the state after.
FromEventTo
Startarchive, star, move, snooze, or deleteShown
Showna refresh arrivesShown
Shownjob queuedWaiting
Waitinglane freeApplying
Applyingserver applies itConfirmed
Applyingserver refuses itDropped
ConfirmedUndo: a new, inverse changeShown

In the code

  • apps/macos/Sources/PostmasterNative/OptimisticMail.swift
  • apps/macos/Sources/PostmasterNative/MailActionQueue.swift
  • apps/macos/Sources/PostmasterNative/MailStore+Actions.swift
  • apps/macos/Sources/PostmasterNative/SwiftMailEngine+Mutations.swift

What can go wrong

  1. 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.swift
    • apps/macos/Sources/PostmasterNative/SwiftMailEngine.swift
  2. 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
  3. 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.swift
    • apps/macos/Sources/PostmasterNative/SwiftMailEngine+Mutations.swift
    • apps/macos/Sources/PostmasterNative/SwiftMailEngine+Sync.swift
    • apps/macos/Sources/PostmasterNative/RecoveryWindowController.swift
  4. 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.swift
    • apps/macos/Sources/PostmasterNative/MailStore+Actions.swift
    • apps/macos/Sources/PostmasterNative/SwiftMailEngine+Mutations.swift
  5. 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.swift
    • apps/macos/Sources/PostmasterNative/SwiftMailEngine+Sync.swift
    • apps/macos/Sources/PostmasterNative/SimpleMailProtocols.swift
  6. 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.swift
    • apps/macos/Sources/PostmasterNative/ReaderImageSchemeHandler.swift
    • apps/macos/Sources/PostmasterNative/SwiftMailEngine+Reading.swift
    • apps/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.swift
  • apps/macos/Sources/PostmasterNative/MailStore+Actions.swift
  • apps/macos/Sources/PostmasterNative/OptimisticMail.swift
  • apps/macos/Sources/PostmasterNative/ReaderImageSchemeHandler.swift
  • apps/macos/Sources/PostmasterNative/RecoveryWindowController.swift
  • apps/macos/Sources/PostmasterNative/SafeMessageWebView.swift
  • apps/macos/Sources/PostmasterNative/SimpleMailProtocols.swift
  • apps/macos/Sources/PostmasterNative/SwiftMailEngine+Compose.swift
  • apps/macos/Sources/PostmasterNative/SwiftMailEngine+Mutations.swift
  • apps/macos/Sources/PostmasterNative/SwiftMailEngine+Push.swift
  • apps/macos/Sources/PostmasterNative/SwiftMailEngine+Reading.swift
  • apps/macos/Sources/PostmasterNative/SwiftMailEngine+Sync.swift
  • apps/macos/Sources/PostmasterNative/SwiftMailEngine.swift

Hard problem, high bar? That’s my favorite kind.