+--------------------------------------------------+
| imt (bin) |
| clap subcommands, logging, bootstrap |
+----+--------------------------+------------------+
| |
+----v---------+ +------------v------+
| imt-tui | | Snapshot adapter |
| (Ratatui) |<->| + command_worker |
+--------------+ +-----+-------------+
|
+--------v---------+
| imt-sync |
| per-account |
| workers + IDLE |
+--+---------+-----+
| |
+--------v--+ +--v---------+
| imt-net | | imt-store |
| IMAP/SMTP | | SQLite |
| OAuth2 | | + FTS5 |
+-----------+ +------------+
imt-core (shared types)
Pure data types: Account, Folder, Message, Thread, Draft, Address, Flag, SyncEvent, NewAccountForm, OAuthProvider. No I/O. No async. Every other crate depends on this.
OAuthProvider variants: Google, Microsoft { tenant }, Yahoo, Custom { auth_url, token_url, scope }.
Account/NewAccountFormcarrykeep_on_server: bool(defaulttrue,#[serde(default)]). Whenfalse, a message is removed from the IMAP server once its body has been downloaded locally (POP3-style "do not leave a copy"); the local copy is kept.Messagecarrieshas_attachments: bool(#[serde(default)]), set at envelope-sync from the serverBODYSTRUCTURE(or a Content-Type header heuristic) so the list can show a 📎 marker without downloading the body, and corrected to the exact value once the full body is fetched.
SQLite persistence layer (sqlx + migrations).
- WAL journal mode, foreign keys on, 5s busy timeout
- Tables:
accounts,folders,messages,threads,drafts,folder_attachment_scan+ FTS5messages_fts - Repos:
AccountRepo,FolderRepo,MessageRepo,DraftRepo,SearchRepo - Migrations (applied in lexical order, recorded idempotently):
idx_messages_folder_dateon(folder_id, internal_date DESC)+ FTS5 triggers (0001-0003),messages.has_attachmentscolumn (0004),folder_attachment_scantable tracking the one-time per-folder attachment rescan (0005),accounts.keep_on_servercolumn defaulting to 1 (0006) secretsmodule: file storage at~/.local/share/indicium-mail-tui/secrets/<id>:<kind>(0600). SetIMT_USE_KEYRING=1to route through the OS keyring instead. Keys stored per account:imap_password,smtp_password,oauth_access_token,oauth_access_expiry,oauth_refresh_token,oauth_client_secret.
Protocol adapters behind the MailBackend async trait.
ImapBackend: connect (implicit TLS / STARTTLS / plain), folder list, fetch envelopes (incl.BODYSTRUCTUREfor attachment detection without downloading parts), fetch body, append, set flags,move_uid,expunge_folder,delete_uid(single-message removal:UID STORE \Deletedthen a targetedUID EXPUNGEwhere the server advertises UIDPLUS, otherwise a folderEXPUNGE), RFC 2177 IDLE push (auto-renewed every 28 minutes; falls back to 30s STATUS poll on servers without IDLE). At connect time it probes capabilities once forIDLE/MOVE/UIDPLUS. XOAUTH2 SASL wired in for OAuth2 accounts. Logs aWARNwhen connecting inTls::Noneplaintext mode.SmtpSender: lettre-based SMTP with the same TLS modes. XOAUTH2 SASL for OAuth2 accounts. Logs aWARNwhen sending overTls::None.oauth: Authorization Code + PKCE (RFC 7636) + Refresh flow with CSRF state.- Providers: Google (
accounts.google.com), Microsoft 365 (login.microsoftonline.com/<tenant>), Yahoo (api.login.yahoo.com), Custom (arbitrary endpoints). OAuthFlow::authorize_url(redirect_uri, login_hint)- returns(Url, PkceVerifier, CsrfToken). The TUI delegates here instead of building auth URLs inline.OAuthFlow::exchange_code()- trades authorization code + PKCE verifier for access/refresh tokens via HTTP POST.OAuthFlow::refresh()- silently exchanges a refresh token for a new access token.xoauth2_token()- builds the base64-encoded XOAUTH2 SASL string for IMAP/SMTP authentication.
- Providers: Google (
Event-driven sync engine.
SyncEngineowns per-account async workers, each:- calls
ensure_fresh_tokens()to refresh OAuth2 access tokens if within 60 seconds of expiry - connects (emits
AccountConnecting/AccountConnected) - lists folders, persists, emits
FolderListUpdated - for each folder: select, fetch envelopes for new UIDs, persist, emit
MessageAdded. The first sync of each folder forces a full UID rescan (regardless oflast_uid_next) so messages that pre-date attachment detection gethas_attachmentsset fromBODYSTRUCTURE; the folder is then marked scanned infolder_attachment_scan. Neither sync path deletes local rows for messages that have vanished from the server. - enters IDLE on the inbox; on
EXISTS/EXPUNGE/FETCHre-syncs and re-enters
- calls
- Exponential backoff (5s -> 5min) on connection errors
password.rs:imap_provider_for(&account)andsmtp_provider_for(&account)return auth-method-awarePasswordProviderclosures (loadimap_passwordfor password accounts,oauth_access_tokenfor OAuth2 accounts);ensure_fresh_tokens()handles silent token refresh - missing or malformedoauth_access_expiryis treated as expired (forces refresh); a missing refresh token returns an explicit"please re-authenticate the account"error.move_message: on server move success but DB delete failure, the error propagates to the caller and aSyncFinishedevent is emitted to schedule reconciliation. After a successful move, recomputes total/unread counts for both the source and destination folder from the local message table, persists them viaFolderRepo::update_counts, and emitsFolderCountsChangedfor each so the sidebar reflects the move in every folder immediately (not only the one currently loaded).empty_trash(folder_id): marks every UID in the folder\Deletedvia the newMailBackend::expunge_foldermethod (IMAPUID STORE 1:* +FLAGS \Deleted+EXPUNGE), then callsMessageRepo::delete_by_folderand emitsFolderCountsChanged { total: 0, unread: 0 }. The TUI binds this toShift+Eand refuses to run unless the current folder's role isTrash.send: relies on the next folder sync to fetch the canonical Sent envelope from IMAP rather than inserting aUID(0)stub locally - eliminates the duplicate-row bug after a transient DB error.fetch_body: downloads and persists the body, then - if the account haskeep_on_server == false- callsMailBackend::delete_uidto remove the message from the server. The local copy is kept (the sync paths never purge vanished messages, so it still shows in the client); a deletion failure is logged and leaves the copy on the server.- Public methods:
add_account(account, password, oauth_exchange),update_account,remove_account,sync_folder,fetch_body,send,move_message,empty_trash,shutdown
OAuthExchange (in engine.rs): { client_id, client_secret, code, verifier, redirect_uri } - passed through from the TUI onboarding form when saving an OAuth2 account; the engine performs the async HTTP code exchange and stores resulting tokens in secrets.
Ratatui application.
Appis the state machine;run()owns the terminal lifecycleDataSourcetrait is sync (zero-cost reads from a snapshot)Modeenum:Normal,Compose,Help,Search,Onboarding,Settings,Accounts,Move,Info,FilePicker,AttachmentViewer,HtmlViewer,Thread,Menu- Components in
ui/:sidebar,list,reader,compose,onboarding,help,search,status,layout,attachment_viewer,menubar,thread,file_picker,move_modal,accounts,info,settings,toast App::tick()runs every 250ms; pulls fresh state from the data source so background sync events become visible automatically
Mouse: the menu bar and all three panes are clickable. App::handle_mouse routes by the pane/menu rects stashed during ui::draw (ui_menu / ui_sidebar / ui_list / ui_reader). Clicking a top menu opens its dropdown or runs it; clicking a sidebar account toggles its expansion and a folder switches to it; clicking a message opens it in the reader; the scroll wheel scrolls the pane under the cursor. Pane dividers and the compose window remain drag-to-resize/move, and the layout persists.
AI reply (ai.rs): Ctrl-G in compose drafts/refines a reply via a local AI CLI (Claude/Gemini/Codex, chosen in Settings) in the background; Ctrl-T opens an instruction/context dialog first. The CLI runs in an isolated temp working directory with an attachments/ subdir; any files the model writes there are auto-attached to the draft. For a brand-new compose (not a reply/forward) the background-selected message is ignored.
Thread view (thread.rs, Mode::Thread, key t): reconstructs the conversation on demand from RFC 822 references (Message-ID / In-Reply-To / References) across all loaded folders - grouping is by references only, never by subject. Messages with attachments are marked and openable in the attachment viewer.
HTML viewer: App::open_html_viewer() converts the selected HTML part to plain text using html2text::from_read() with a 120-character line width and stores it in app.html_viewer: Option<(String, u16)>. The HtmlViewer mode renders a scrollable modal; scroll offset is the u16.
Attachment viewer: is_viewable(mime, filename) returns true for text/*, common code and data file extensions (.md, .txt, .rs, .py, .js, .ts, .json, .toml, .yaml, .csv, .log, etc.) and false for binary MIME types regardless of filename. Text attachments are shown inline; binary files display their MIME type, size, and a save-to-disk prompt.
Onboarding modal: dynamically adapts its field layout based on the use_oauth2 toggle on OnboardingState. OAuth2 path: Display name, Email, IMAP host/port/TLS, SMTP host/port/TLS, Username, Auth type, Client ID, Client Secret, auth URL display, Auth Code. Password path: same minus the four OAuth2-specific fields. Both paths end with a Keep copy on server checkbox (OnboardingField::KeepOnServer, toggled with Space or ←/→, default on) that maps to Account::keep_on_server. Tabbing to Auth Code triggers ensure_oauth_url_generated() which delegates to imt_net::OAuthFlow::authorize_url(redirect_uri, login_hint) - returning a (URL, PKCE verifier, CSRF state) triple - then spawns xdg-open to open the URL. The verifier and state are stored on OnboardingState and threaded through to NewAccountForm for code exchange. The same modal is reused for editing an existing account (prefilled from the stored Account).
Binary, integration layer.
Snapshot(RwLock) caches accounts/folders/messages, hydrated from the DB at startup, kept fresh by a tokio task consumingSyncEvents.messages_by_folderentries are kept pre-sorted byinternal_date DESCat insert time viainsert_message_sorted, so the TUI's per-tick reads avoid a sort.- Message bodies live in a sibling
Arc<Mutex<lru::LruCache<MessageId, MessageBody>>>capped at 1000 entries - prevents unbounded memory growth on long sessions. SyncDataSourceimplementsDataSourceagainst the snapshot for reads and aCommandchannel for writes. Optimistic snapshot mutations (move, toggle flag) only run after the engine command is successfully enqueued, so a closed channel never leaves the UI in an inconsistent state.delete_messagereturns an explicit error when no Trash folder is configured rather than silently dropping the local row.command_workerconsumesCommands and dispatches toSyncEngine.Command::AddAccountcarries an optionalOAuthExchange; when present, the worker passes it toengine.add_account()for async token exchange.- Account workers spawn in parallel at startup via
futures::future::join_all(bothrun_tuiandrun_mcp). - CLI subcommands handle account management without launching the TUI.
--imap-tls/--smtp-tlsreject invalid values at parse time (clapvalue_parser).
TUI calls a sync DataSource method -> SyncDataSource reads from Snapshot (RwLock, no I/O) -> returns to UI.
TUI calls a write method (e.g. send) -> SyncDataSource posts a Command on an unbounded mpsc -> command_worker invokes SyncEngine -> engine talks to IMAP/SMTP -> emits SyncEvent on completion -> snapshot updater task writes back to snapshot -> next App::tick() picks it up -> UI re-renders.
IMAP IDLE delivers EXISTS -> account_task ends IDLE, re-syncs the folder, emits MessageAdded -> snapshot updater inserts message rows -> next tick the TUI sees a new message in the snapshot and re-renders. No user interaction needed.
- User fills the onboarding form with Client ID, tabs to Auth Code
- TUI generates PKCE verifier + challenge, builds auth URL, calls
xdg-open - User approves in browser, copies
?code=value from redirect URL - User pastes code, presses
Ctrl-S OnboardingState::to_form()produces aNewAccountFormwith OAuth2 fields populatedSyncDataSource::add_account()postsCommand::AddAccount { account, password: "", oauth_exchange: Some(...) }command_workercallsengine.add_account(account, "", Some(exchange))- Engine calls
OAuthFlow::exchange_code()over HTTPS, storesoauth_access_token,oauth_access_expiry,oauth_refresh_token,oauth_client_secretin secrets - Account worker starts; before each connection
ensure_fresh_tokens()auto-refreshes if needed
~/.config/indicium-mail-tui/config.toml (loaded if present, ignored otherwise):
[settings]
auto_refresh_secs = 60 # 0 disables polling; IDLE remains active regardless
mark_read_on_open = true
show_preview_snippet = false
browser = "" # override browser for external linksAccount credentials live in ~/.local/share/indicium-mail-tui/secrets/ (0600 files) or the OS keyring when IMT_USE_KEYRING=1 is set.
cargo build --workspace --releaseRelease binary at target/release/imt. The release profile uses lto = "fat", codegen-units = 1, panic = "abort", and strip = "symbols" for ~6 MB stripped. Tokio is configured with an explicit feature subset (no "full") to keep dependency graph and binary size minimal.
The TUI can be exercised against the in-memory mock with imt run --mock. The mock has 2 sample accounts with realistic inbox / sent / drafts / trash / archive folders and 12 sample messages including one HTML.
All logs go through tracing. Default level is info. Override with RUST_LOG:
RUST_LOG=imt_sync=debug,imt_net=trace imt runLogs are written to ~/.local/share/indicium-mail-tui/imt.log (rotating is the responsibility of the user / journald / logrotate).
APPENDUIDis not extracted from IMAP responses (async-imap 0.10 limitation). After asendwe wait for the next folder sync to discover the canonical UID rather than inserting a stub.thread_idis not populated by the sync engine; the message list is flat. Conversations are reconstructed on demand for the thread view (t) from RFC 822 references only.- Per-folder index in the sidebar updates on tick; very large mailboxes (>10000 messages) may want pagination beyond the current 500-row cap
xdg-openis used to launch the OAuth2 authorization URL; on macOS useopen(set via$BROWSERor a shell alias)- IMAP connections are opened per-operation for
move,set_flag,fetch_body,sync_folder. A connection-reuse refactor would reduce per-op TLS handshake overhead but is not yet wired in.