OAKENMAIL
Pre-alpha

Read state

One place in core marks mail read or unread. It happens when you open a message or tell it to, and for nothing else. Every change is local first, written to your mail service afterwards, recorded, and can be undone.

1

What marks a message read, and what never does

These mark a message read:

These never do, and a test makes sure it stays that way:

Background work cannot do it even by mistake: a request from the system itself is refused outright, whatever its reason and even with a confirmation.

2

Local first

Marking a message read changes your local copy at once, so the screen is never waiting on a mail server. Writing it back to the service follows, and a failure never undoes what you see.

sequenceDiagram
    participant U as You
    participant R as Read state
    participant D as raw.db
    participant Q as Job queue
    participant P as Provider
    U->>R: open / mark read
    R->>D: set is_read, set the dirty marker, write the audit row (one transaction)
    R->>Q: one write job for this message
    R-->>U: MessageReadChanged (with reason and audit id)
    Q->>P: set_flags (flags.write)
    P-->>Q: ok
    Q->>D: clear the dirty marker

The state machine

The dirty marker (message_state.local_dirty_json) is what the server has not been told yet.

Local state Dirty marker Something happens What core does
unread none you read it set read, set the marker, audit, queue a write
read none you read it again nothing: no audit row, no write
read read you mark it unread set unread, replace the marker (the one write job picks up the latest), audit
any read or unread, provider can write the write job runs and the provider accepts clear the marker
any marker the provider is down or slow keep the marker, retry with backoff; you keep seeing the local state
any marker the provider refuses for good keep the marker, fail the job once, record why, tell clients (local_only)
any marker, no provider can write flags you mark it change stays local, no job is queued, the reason is recorded, clients are told (local_only)
any marker the server reports a different flag ignored: your change is about to overwrite it
any none the server reports a change (read on another device) local state follows the server; not audited
any marker, then a provider that can write flags appears resume_pending queue the write

Last writer. While a change of yours is waiting, it wins over anything the server says. Once the server has taken it, the server's word is the latest again.

When nothing can write the change. It is not retried on a timer. It stays local, flagged local_only with the reason, until the provider is fixed and resume_pending queues it. A screen for handling these is a later job.

3

Undo and the audit log

Every change writes one row to audit_log in the same transaction as the change itself: who (user, agent or system, with a label), the reason, the operation, and the value of every message before and after. Only internal ids and flags are stored, never any mail.

4

Reasons and the confirm gate

Reason Who Needs confirm? Turned off by auto_mark = false?
user_opened you, by opening a message no yes
user_summarized you, choosing to mark messages read after a summary no no
mcp_summarized an MCP client, after a summary yes no
user_marked_read, user_marked_unread, user_undo, any other user_* you no no
any other reason, or any user_* reason from an agent an agent or a script yes no

The gate is code, not a prompt. The operation registry is the one that decides, and passes the confirmation down; this check stays as a second line of defence.

5

Settings

Setting Default Meaning
ui.mark_read_delay_ms 2000 How long a message must stay open before core marks it read. Core owns the timer, so every client behaves alike
read_state.auto_mark true false turns off opening as a reason to mark read. Marking read or unread yourself still works
read_state.audit_chunk 500 Messages per audit row
6

For clients

A client tells core which message is on screen with message_presented(id) and message_dismissed(id); presenting it again restarts the wait. It does not run a timer of its own.