diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..4354066 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,49 @@ +name: Deploy neomd + +on: + push: + branches: + - main + paths: + - '*.go' + - 'go.mod' + - 'go.sum' + - 'Makefile' + - 'internal/**' + - 'cmd/**' + - '.github/workflows/deploy.yml' + workflow_dispatch: + +concurrency: + group: deploy-gpu + cancel-in-progress: false + +jobs: + deploy: + runs-on: [self-hosted, gpu] + timeout-minutes: 10 + steps: + - name: Check out source + uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Capture build version + run: echo "EXPECTED_VERSION=$(git describe --tags --always --dirty)" >> "$GITHUB_ENV" + + - name: Build + run: make build + + - name: Install + run: | + if [ -e /home/jon/.local/bin/neomd ]; then cp /home/jon/.local/bin/neomd /home/jon/.local/bin/neomd.prev; fi + install -Dm755 neomd /home/jon/.local/bin/neomd + + - name: Verify deployed binary + run: | + actual="$(/home/jon/.local/bin/neomd --version)" + test "$actual" = "neomd $EXPECTED_VERSION" || { + echo "version mismatch: expected 'neomd $EXPECTED_VERSION', got '$actual'" >&2 + exit 1 + } diff --git a/AGENTS.md b/AGENTS.md index ab17e6e..b84ce54 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,6 +93,20 @@ When you add a new field or path to outgoing messages, extend the round-trip sui the same commit — a field that isn't parse-back-asserted is a field that can silently break. +## Authentication & Safety Modes + +- **External OAuth2 helper** — `[[accounts]].oauth2_token_command` is a fixed argv + token source (`internal/oauth2`, wired in `cmd/neomd/main.go`): only a leading + `~/` in argv[0] expands, stderr is discarded, empty stdout fails, and the token + remains in memory; it bypasses native OAuth client/URL/keyring/token-file setup. + Test: `TestConfigureIMAPAuthUsesCommandTokenWithoutNativeOAuth`. +- **Read-only profile** — root `read_only` makes IMAP selection use EXAMINE, blocks + every mutating IMAP method and UI outbound/action path before network access, + suppresses first-run folder creation, blocks the mutating `screen` CLI, and + refuses `--headless` (`internal/imap/client.go`, `internal/ui/model.go`, + `cmd/neomd/main.go`). Tests: `TestReadOnlyBlocksMutationsBeforeDial`, + `TestReadOnlyBlocksPR5ActionsBeforeNetwork`. + **Hardening assertions may only be extended, never weakened.** If a hardening test fails after a code change, the default assumption is that the CODE broke a user-visible contract — investigate the code first. Relaxing, deleting, or rewriting @@ -101,8 +115,42 @@ that conversation; "the test was too strict" is not a decision an agent makes al --- +## Per-email Reminders + +- **Superhuman-style reminders** — `H` in the reader prompts for a future time, + stores the selected message in Waiting with `X-Neomd-Reminder-*` metadata, + and moves the original to recoverable Trash without SMTP. The headless daemon + and TUI background sync return due reminders to Inbox; metadata remains visible + as the `R` indicator and reader status. Tests: `TestReminderKeyStartsPerEmailPrompt`, + `TestParseReminderSection`, `TestParseHeaderAndStatus`. + ## Reply & Threading +- **Agent-readable Message-ID links** — `neomd read ` + resolves the copied reader link across configured accounts/folders with + read-only IMAP `SEARCH`/`BODY.PEEK`; `--json`, `--raw`, `--folder`, `--account`, + and stdin batches are supported. Keep `cmd/neomd/read_agent.go`, + `internal/imap/read.go`, and `internal/link/message_id.go` aligned with the + read-only/no-flag-mutation contract. Tests: `TestSearchMessageIDsAndReadRawMessage`, + `TestRunAgentReadUsesURIHostFolderAndStdinBatch`. + +- **Message-ID share links** — in the reader, `y` opens the copy menu and `m` copies + `neomd://mid/`; the URI encodes the RFC Message-ID, never + an IMAP UID or folder path. Tests: `internal/link/message_id_test.go`, + `internal/ui/copy_menu_test.go`. + +- **Every copy goes out as OSC 52 first** — `copyToClipboard` + (`internal/ui/clipboard.go`) writes the escape sequence to the terminal, and only + then tries a local tool (`wl-copy`/`xclip`/`xsel`/`pbcopy`); either succeeding is a + success. neomd is routinely run over ssh, where a local tool sets the wrong + machine's clipboard or fails outright, so a local-tool-only copy is a silent no-op + for the user. A tool whose display variable is unset is skipped, never run and + failed. Inside tmux the sequence is emitted twice — plain and DCS-passthrough + wrapped with every ESC doubled — so it lands under either `set-clipboard on` or + `allow-passthrough on`. Tests: `TestYankMenuMessageIDIsOSC52Encoded`, + `TestOSC52TmuxPassthroughDoublesEscapes`, + `TestLocalClipboardToolSkippedWithoutDisplay`. + - **`·` reply indicator** — after sending a reply, the original email gets the IMAP `\Answered` flag (`MarkAnswered` in `internal/imap/client.go`, called from `sendEmailCmd` in `internal/ui/model.go`) and the inbox shows `·` (or `·╰` inside a thread, @@ -129,12 +177,12 @@ that conversation; "the test was too strict" is not a decision an agent makes al - **Threaded inbox rendering** — threads grouped via `In-Reply-To`/`Message-ID` with subject+participant fallback, `│`/`╰` connectors, newest on top; the Sent folder is intentionally **not** threaded. Tests: `TestNormalizeSubject`, `TestParticipantMatch`. -- **Sender view (`V`)** — from the inbox list, searches `from:` (bare address +- **Sender view (`@`)** — from the inbox list, searches `from:` (bare address from the selected email) across every configured folder via the same `SearchAllFolders` IMAP infra as `/`-search, opening results in a `Sender` off-tab (`internal/ui/search.go`: `senderAddr`, `fetchSenderCmd`, `handleSenderResult`). - `V` was chosen over `E`/`F` — both already bound (`E` = continue draft in the reader, - `F` = mark as Feed). Tests: `TestSenderAddr`, `TestHandleSenderResultSetsOffTabAndEmails`, + `@` preserves sender search while `V` is now vim visual-select mode; `E`/`F` remain + occupied by mark-not-done and Feed. Tests: `TestSenderAddr`, `TestHandleSenderResultSetsOffTabAndEmails`, `TestHandleSenderResultNoMatches`. ## Compose → Pre-send → Send Pipeline @@ -292,9 +340,21 @@ that conversation; "the test was too strict" is not a decision an agent makes al the original subject is never mutated (reply/forward/thread logic uses the real RFC subject). Tests: `TestRowFitsTerminalWidth`, `TestDisplaySafe`. - **Indicator columns** — unread, `·` replied, `°` spy pixel, `│`/`╰` thread connectors. -- **Undo (`u`)** — reverses the last move/delete using UIDPLUS destination UIDs captured - on move; batch operations preserve partial-undo info on failure. Integration test: +- **Undo (`u`) is universal** — the journal (`undoStack []undoAction`, `pushUndo` in + `internal/ui/model.go`) records every reversible effect of the most recent action: + IMAP moves (archive, screener I/O/F/P/$, dd/# trash, M-chords, move picker) AND + `\Seen` toggles (`n`, ctrl+n mark-all-read). `u` pops LIFO and reverses the exact + action type via `undoActionCmd`; server-side failure surfaces in the status line. + Screener classification lists are intentionally kept on undo (mail moves back, sender + lists don't). Permanent `X` delete is never journaled. Tests: `TestUndoJournal*`, + `TestUndoNothingToDo`, `TestUndoRefusesInReadOnly`, integration `TestIntegration_IMAPMoveAndUndo`. +- **Quick peek (``)** — leader+space toggles a preview pane (From/To/ + Subject/body) of the highlighted email without leaving the inbox list; `esc` or the + chord again closes it, the cursor never moves, j/k re-targets the pane, and the body + is fetched with BODY.PEEK (never sets `\Seen`). The pane is exactly `peekPaneHeight` + lines so the status bar never overflows. Tests: `TestPeek*` in + `internal/ui/undo_peek_test.go`. - **Search matches contact names** — `internal/contacts` harvests `Name ` pairs from loaded headers into `~/.cache/neomd/contacts`; the local `/` filter appends @@ -307,8 +367,8 @@ that conversation; "the test was too strict" is not a decision an agent makes al - **The user's `[contacts]` file is read-only** — `contacts.MergeFile` only reads; neomd persists exclusively to its own cache (`config.ContactsCachePath()`), so the cache can be deleted anytime and rebuilds from harvesting + the file. The picker - (`space c`, `internal/ui/contacts_picker.go`) copies via external clipboard tools - and never mutates the store. Tests: `TestMergeFileGoogleCSVRealExport`, + (`space c`, `internal/ui/contacts_picker.go`) copies via the shared + `copyToClipboard` helper and never mutates the store. Tests: `TestMergeFileGoogleCSVRealExport`, `TestContactsPickerFilterAndSelect`. ## Reading & Security @@ -367,11 +427,84 @@ that conversation; "the test was too strict" is not a decision an agent makes al messages never include tokens/passwords. Tests: `TestTokenErrors_NoTokenLeak`, `TestSaveToken_FilePermissions`. +## Core Keyboard Contract (e / h / s / ;) + +The client is driven from the home row. These four keys are the product, not a convenience +layer — do not rebind them, and do not let a new binding shadow one. + +- **`e` archive · `h` remind · `s` start an email · `;` snippets** — bound in both the + inbox (`updateInbox`) and the reader (`updateReader`) in `internal/ui/model.go`. The + pre-5.0 keys still work as aliases (`A` archive, `H` remind, `c` compose). + Test: `TestEmailBindingMap`, `TestReaderArchiveAndRemindKeys`. +- **Mailbox ACTION keys are UPPERCASE.** `I` approve, `O` screen-out, `P` papertrail, + `B` work, `F` feed (uppercase because `f` is forward), `$` spam; the former lowercase + `i`/`p`/`b` no longer fire (pinned by `TestLowercaseMailboxActionsAreDead`). The four + core keys stay lowercase by contract, as do `o` open, `t` thread, `n` read toggle. + Vim-shaped selection uses `x/m`, `V`, and `ctrl+a`; `dd/#` trashes; `u` undoes; `U/z` + filters unread; `ctrl+d/u` are half-page movement. The final map and dropped shortcuts + live in `docs/keys.md`; `internal/ui/keys.go` drives the overlay and generated docs. +- **`h` no longer exits the reader** — `q`/`esc` do. Reverting that would shadow remind. +- **Reader `e` moved the old $EDITOR view to `e`.** +- **Superhuman/vim shortcut map is user-visible contract** — `internal/ui/keys.go`, + `docs/keys.md`, and the context footers must stay aligned: `x/m` select, + `V` extends selection with j/k, `dd/#` trash, `u` undo, `U/z` unread-only, + `ctrl+d/u` half-page, and the final `g` folder routes. Tests: + `TestDocumentedKeyBindings`, `TestReadOnlyBlocksExpandedMutatingBindingsBeforeNetwork`. +- **Bindings must not fire inside a text field** — the inbox handler's early returns for + `cmdMode`, `imapSearchActive`, `filterActive`, `reminderActive` and `pendingKey` are what + guarantee this; new bindings go in the main `switch` *after* those guards, never before. + Test: `TestEmailBindingsGuardedInsideInputFields`. + +## Instant Actions (optimistic UI) + +- **Every list action applies before the IMAP round-trip.** Archive/delete/screen/move/ + remind route through `Model.optimisticAct` (`internal/ui/optimistic.go`): the rows leave + `m.emails` and the list immediately, the IMAP command runs behind it, and `batchDoneMsg` + / `reminderDoneMsg` *confirm in place* — **no folder re-fetch**. Regressing to + `fetchFolderCmd` on ack is the failure mode this exists to prevent. + Test: `TestOptimisticArchiveIsInstant`. +- **Failures roll back visibly** — the rows return to the list and `isError` is set; a + refused action is never silently dropped. Test: + `TestOptimisticArchiveRollsBackVisiblyOnFailure`. +- **Overlapping actions are independent** — each batch gets an `optID`; one batch's ack must + never consume another's rollback snapshot. + Test: `TestOverlappingOptimisticBatchesRollBackIndependently`. + +## Infinite Scroll + +- **Reaching the bottom appends the next page** — `maybeLoadMoreCmd` fires within + `loadMoreThreshold` rows of the end and pages on UID via + `imap.Client.FetchHeadersBefore(folder, beforeUID, n)`. + Test: `TestScrollToBottomTriggersNextPage`. +- **The cursor must not move when a page lands** — appends go through `sortEmails` and then + re-`Select` the previous index. Test: `TestNextPageAppendsAndKeepsScrollPosition`. +- **Ad-hoc views are never paged** — IMAP search results, `Everything`, conversation and + sender views span folders, so UID paging is meaningless there. + Test: `TestScrollDoesNotPageAdHocViews`. +- **An empty page latches `moreExhausted`** so the client stops asking; a full folder load + (`emailsLoadedMsg`) resets paging and clears pending optimistic snapshots. + Tests: `TestEmptyNextPageMarksFolderExhausted`, `TestFullFolderLoadResetsPagingState`. + +## Snippets + +- **`;` reads `/snippets/*.md` at open time** (`internal/snippets`), so a new + template needs no restart. An optional leading `Subject:` line sets the subject; the rest + is the body, staged through `mailtoBody` so `launchEditorCmd` drops it into the editor + buffer. Tests: `TestSnippetParseSplitsSubjectAndBody`, `TestSnippetPickerComposesPrefilled`. + ## Keybindings & Docs - **`internal/ui/keys.go` is the single source of truth** — drives the `?` overlay and the generated `docs/keybindings.md` (`make docs`, runs in `make build`). Never hand-edit the markdown tables. -- **Avoid modifier keys for new bindings** — user's tmux prefix is `C-t`; `ctrl+a`/`ctrl+e` - collide with textinput line-start/end. Prefer plain letters, especially on pre-send. +- **Avoid modifier keys for new bindings** — user's tmux prefix is `C-t`; compose text + fields still own their editing chords, while inbox `ctrl+a`/`ctrl+d`/`ctrl+u` follow + the final vim map. Prefer plain letters, especially on pre-send. - **README.md syncs to the docs site** (`scripts/sync-readme-to-docs.sh` via `make docs`). + +## Maintaining this file + +Keep this file for knowledge useful to almost every future agent session in this project. +Do not repeat what the codebase already shows; point to the authoritative file or command instead. +Prefer rewriting or pruning existing entries over appending new ones. +When updating this file, preserve this bar for all agents and keep entries concise. diff --git a/CHANGELOG.md b/CHANGELOG.md index 685a156..e23ee97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,175 @@ # Changelog +# 2026-09-08 + +- **Uppercase mailbox actions, universal undo, and a quick peek** — the inbox mailbox + action keys are now uppercase (`I` approve, `O` screen-out, `P` papertrail, `B` work, + `F` feed, `$` spam); the lowercase `i`/`p`/`b` forms no longer fire and the core + `e`/`h`/`s`/`;` keys are untouched. `u` is now a universal undo backed by an action + journal: it reverses the most recent action — screener moves (previously the undo + data was silently dropped by `batchScreenerCmd`), archive/delete/M-chord moves, and + read/unread changes from `n` or ctrl+n — and server-side failures show in the status + line instead of silently doing nothing. Leader+space (``) toggles a + quick-peek pane showing From/To/Subject and the body of the highlighted email without + leaving the list: `esc` or the chord closes it, the cursor stays put, j/k re-targets + the preview, and fetching uses BODY.PEEK so peeking never marks mail read. + Where: `internal/ui/model.go`, `internal/ui/keys.go`, `internal/ui/reader.go`, docs. + Tests: `TestLowercaseMailboxActionsAreDead`, `TestUndoJournal*`, + `TestUndoNothingToDo`, `TestUndoRefusesInReadOnly`, `TestPeek*`. + +# 2026-09-07 + +- **Reminders accept natural language and filtered rows remain actionable** — `h` now + opens a centered Remind Me picker with quick picks, live local-time resolution, + clear validation, and phrases such as `in 3 days`, `friday 2pm`, `tonight`, + `this weekend`, and `next week`; send-later (`l`) uses the same pure parser. + The local `/` filter now restores/clamps the list cursor after narrowing, so + archive, remind, selection, screening, open, compose, reply, forward, move, + visual-select, and `dd` act on the highlighted filtered email and `esc` clears + the committed filter. Where: `internal/when`, `internal/ui/reminder.go`, + `internal/ui/model.go`, docs. Tests: `TestParseNaturalLanguageTimes`, + `TestCommittedFilterActionsUseVisibleTarget`, `TestReminderPopupQuickPicksAndNaturalLanguage`, +- **Read Message-ID links from scripts and agents** — `neomd read` resolves a + copied `neomd://mid/...` link, bracketed ID, or bare ID across configured + accounts and folders using read-only IMAP search/fetch. It renders decoded + headers, plain text (HTML fallback), and attachment metadata, with `--json`, + `--raw`, folder/account hints, and stdin batches; no mailbox flags or folders + are mutated. Where: `cmd/neomd`, `internal/link`, `internal/imap`. Tests: + `TestSearchMessageIDsAndReadRawMessage`, `TestRunAgentReadUsesURIHostFolderAndStdinBatch`. + +- **Yank menu actually reaches the clipboard (OSC 52)** — the reader's `y` → `m` + copy landed nowhere whenever neomd ran over ssh: the shared helper only tried + local tools, and on a headless host `xclip` is on `PATH` but `DISPLAY` is empty, + so it was invoked and failed with a bare `exit status 1` (verified on the gpu + box). Even a *successful* local tool would have set the remote machine's + clipboard, not the terminal the user pastes from. `copyToClipboard` now writes an + OSC 52 sequence to the terminal first — doubled with a DCS passthrough form + inside tmux — and only then falls back to a local tool, skipping any tool whose + display variable is unset and surfacing its stderr when it does fail. The helper + moved out of `internal/ui/contacts_picker.go` into `internal/ui/clipboard.go`, + so the contacts picker (`y`/`Y`) is fixed by the same change. Where: + `internal/ui/clipboard.go`, `internal/ui/contacts_picker.go`. Tests: + `TestYankMenuMessageIDIsOSC52Encoded`, `TestOSC52TmuxPassthroughDoublesEscapes`, + `TestLocalClipboardToolSkippedWithoutDisplay`. + +- **Message-ID share links** — the reader's `y` copy menu now offers a stable + `neomd://mid/` URI, plus an available web-version URL; + the URI is based on the RFC Message-ID rather than IMAP location data. Tests: + `TestMessageIDURIRoundTrip`, `TestReaderYOpensCopyMenu`, + `TestCopyMenuMessageIDBinding`. + +- **Superhuman-inspired vim keyboard map** — rebuilt the `?` overlay, context-aware + footer hints, generated keybinding docs, and a complete decision table from the + Superhuman v7 sheet. Captain-selected `e` done, `h` remind, `s` compose, and `;` + snippets remain primary; vim navigation now owns `gg/G`, `ctrl+d/u`, `u` undo, + `x/m` selection, `V` visual selection, `dd/#` trash, and `ctrl+a` select-all. + Superhuman folder gotos map to neomd's mailboxes, command-key-only features are + documented as dropped, and the former sender view remains available at `@`. + New folder/label picker and compose leader sequences are read-only safe before + any network mutation. Where: `internal/ui/keys.go`, `internal/ui/model.go`, + `internal/ui/reader.go`, `docs/keys.md`, `docs/content/docs/keybindings.md`. + Tests: `TestDocumentedKeyBindings`, `TestReadOnlyBlocksExpandedMutatingBindingsBeforeNetwork`, + `go test ./...`, `go vet ./...`. + +- **External XOAUTH2 token helpers and read-only mode** — accounts may use + `oauth2_token_command` to obtain an in-memory access token from a fixed argv + helper without native OAuth client/issuer, keyring, or token-file settings. + Root-level `read_only = true` selects IMAP `EXAMINE`, blocks all IMAP mutations + and outbound paths before network access, suppresses first-run folder creation, + and refuses `--headless`. Where: `internal/config`, `internal/oauth2`, + `internal/imap`, `internal/ui`, `cmd/neomd`. Tests: `TestCommandTokenSource`, + `TestLoadOAuth2TokenCommandWithoutNativeOAuthSettings`, + `TestReadOnlyBlocksMutationsBeforeDial`, `TestReadOnlyBlocksPR5ActionsBeforeNetwork`. + +- **GPU deployment follows GitHub Actions** — merges to `main` now build and install + neomd on the self-hosted GPU runner, preserving the previous binary as `neomd.prev`. + Manual rebuilds on GPU are no longer part of the deployment path. Where: + `.github/workflows/deploy.yml`, `README.md`. Tests: `go build ./...`, `go vet ./...`. + +# 2026-09-05 + +- **Keyboard-driven email handling: `e` archive, `h` remind, `s` start, `;` snippets** — + the four Superhuman-style actions are now bound in both the inbox and the reader. + `e` archives (mark done), `h` opens the remind-me prompt for the marked/cursor + emails (or the open one), `s` starts a new email, and `;` opens a snippet picker + reading `/snippets/*.md` (an optional leading `Subject:` line sets the + subject, the rest becomes the body, staged into the `$EDITOR` buffer). All four are + guarded behind the existing input-mode early returns, so they never fire while the + filter, `:` command line, IMAP search, or reminder prompt owns the keyboard. + Where: `internal/ui/model.go`, `internal/ui/snippets.go`, `internal/snippets/`. + Tests: `TestEmailBindingMap`, `TestEmailBindingsGuardedInsideInputFields`, + `TestReaderArchiveAndRemindKeys`, `TestSnippetPickerComposesPrefilled`. + +- **Email bindings standardised on lowercase** — `i`/`o`/`p`/`b`/`t`/`v` are now the + documented keys for screen-in, screen-out, PaperTrail, Work, thread and sender views; + the historical uppercase keys keep working as aliases, as do `A` (archive) and `c` + (compose). Six keys stay uppercase because their lowercase letter is already taken: + `F` (f=forward), `S` (s=compose), `U` (u=page up), `X` (x=trash), `N` (n=toggle read), + `R` (r=reply). Two visible moves: `h` no longer exits the reader (`q`/`esc` do, so `h` + can mean remind everywhere), and the reader's read-only `$EDITOR` view moved from `e` + to `e`. Where: `internal/ui/keys.go` (mapping table + case convention), + `internal/ui/model.go`. Test: `TestEmailBindingMap`. + +- **Every list action is instantaneous** — archive, delete, screener moves, Work moves and + reminders now apply to the list on the keystroke and reconcile with the server in place. + Previously each one waited for IMAP and then re-fetched the whole folder, so a single + archive felt like a page load. Rows leave the list immediately, the IMAP work runs + behind them, and a server failure puts the rows back visibly with an error status rather + than dropping the action. Overlapping actions are tracked by batch id so one + acknowledgement can never consume another's rollback snapshot. + Where: `internal/ui/optimistic.go`, `batchDoneMsg`/`reminderDoneMsg` handlers in + `internal/ui/model.go`. Tests: `TestOptimisticArchiveIsInstant`, + `TestOptimisticArchiveRollsBackVisiblyOnFailure`, + `TestOverlappingOptimisticBatchesRollBackIndependently`, + `TestOptimisticRemindMovesRowsAtOnce`. + +- **Infinite scroll in the email list** — reaching the bottom of a folder automatically + fetches and appends the next page instead of stopping at `ui.inbox_count`. Paging is by + UID (`imap.Client.FetchHeadersBefore`), the cursor stays exactly where it was when a page + lands, an empty page latches the folder as exhausted, and cross-folder views (IMAP search, + `Everything`, conversation, sender) are deliberately not paged. + Where: `internal/imap/client.go`, `internal/ui/optimistic.go`, `internal/ui/model.go`. + Tests: `TestScrollToBottomTriggersNextPage`, `TestNextPageAppendsAndKeepsScrollPosition`, + `TestScrollDoesNotPageAdHocViews`, `TestEmptyNextPageMarksFolderExhausted`, + `TestNextPageForAnotherFolderIsDropped`, `TestFullFolderLoadResetsPagingState`. + +# 2026-09-02 + +- **Reminder safety fixes** — reminder headers without a durable identity no + longer bypass sender screening; parking refuses ambiguous same-message + copies instead of moving legitimate Archive/Sent/Drafts mail to Trash; and + `Waiting = Inbox` is rejected before any IMAP operation. Tests: + `TestParseReminderRejectsUnidentifiedHeader`, + `TestClassifyForScreen`, `TestParkReminderRejectsWaitingSource`. + +# 2026-09-01 + +- **Superhuman-style per-email reminders** — press `H` while reading an email, + choose a future time (`+2h`, `tomorrow 09:00`, or an absolute local time), and + neomd stores that one message in Waiting with durable `X-Neomd-Reminder-*` + headers while moving the original to recoverable Trash. No SMTP path is used. + The daemon and TUI background sync return due reminders to Inbox, where the + `R` marker and reader status show the reminder state. Added the reminder + metadata parser, safe body-preserving header rewrite, IMAP peek fetches, and + behavioral tests in `internal/reminder`, `internal/imap`, and `internal/ui`. + Tests: `TestReminderKeyStartsPerEmailPrompt`, `TestParseReminderSection`, + `TestParseHeaderAndStatus`. + +- **Reminder delivery hardening** — header fetches now match reminder and + send-later sections by their IMAP descriptors, mixed line-ending messages + keep their complete body bytes during header rewriting, and due reminders + bypass automatic screener moves after returning to Inbox. Tests: + `TestParseHeaderSectionsByDescriptor`, + `TestHeadersPreserveBodyWithMixedLineEndings`, + `TestClassifyForScreen`. + +- **Reminder parking is race-safe and idempotent** — reminder parking claims + the source in recoverable Trash before creating the Waiting copy, reconciles + a source moved by screening, and deduplicates retries by reminder ID; + screen-all now uses the same due-reminder exclusion as regular screening. + Tests: `TestParkReminderReconcilesMovedSourceAndIsIdempotent`, + `TestDeepScreenSkipsDueReminder`. + # 2026-08-31 - **Out-of-office auto-replies — screened-in senders only** — new `[ooo]` config block (`enabled`, `from`, `until`, `subject`, `body`/`body_file`; `from`/`until` take `"YYYY-MM-DD"` or `"YYYY-MM-DD HH:MM"`, interpreted in the optional `timezone` (IANA name, e.g. `"Europe/Zurich"`; default: the daemon machine's local time — servers often run UTC, so set it), so you can arm OOO in advance — e.g. `from = "2026-08-31 16:00"` the afternoon before leaving; date-only `until` is inclusive through end of day, with a time it's exact; `accounts = ["Work", "WorkInfo"]` watches those accounts' inboxes instead of the daemon's own, each replying from its own From address/signature with the Sent copy in its own Sent folder — unknown/`imap_disabled` names are hard errors, and the reply-once cache stays shared across accounts so a sender mailing several of your addresses still gets one reply) processed exclusively by the headless daemon: each sync cycle it answers new Inbox mail from **screened-in senders only** — spam, sales pitches, and unscreened senders never learn you're away, which no server-side autoresponder can do. Replies go solely to the sender (`Reply-To` pref, `From` fallback), never to Cc; exactly once per sender per OOO period (persisted `~/.cache/neomd/ooo_replied` cache, marked BEFORE the SMTP send so a crash can never duplicate; changing `from`/`until` starts a fresh period); mail that arrived before activation is never answered; RFC 3834 loop protection both ways (skips incoming `Auto-Submitted`/`Precedence: bulk|junk|list`/`List-Id`/`List-Unsubscribe` mail, stamps outgoing `Auto-Submitted: auto-replied` + `X-Auto-Response-Suppress: All`); auto-expires after the inclusive `until` day. Every reply ends with a transparency footer (`*automatically sent from [neomd](https://neomd.ssp.sh)*`, `ooo.Footer`, test `TestBody_FooterMarksAutomatedReply`) — skipped when the text or HTML signature already links neomd.ssp.sh so the line never duplicates (tests `TestBody_SkipsFooterWhen*`). The reply body is markdown built with the same `BuildMessageWithThreading` pipeline as composed mail — identical MIME shape, text/HTML signatures, and proper `In-Reply-To`/`References` threading — plus a Sent copy. New `internal/ooo/` package, daemon glue `processOOO` in `internal/daemon/daemon.go`, `config.OOOConfig` + `OOOCachePath()`. Docs: headless page → "Out-of-Office Auto-Replies". Tests: `TestActive_*`, `TestIsAutoGenerated`, `TestReplyAddress_*`, `TestBody_*`, `TestBuildReply_LooksLikeNormalNeomdMailPlusAutoHeaders`, `TestCache_*`, `TestShouldConsider` diff --git a/README.md b/README.md index d346c55..51cb260 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # neomd +## Deployment + +Merging to `main` deploys neomd to the GPU host through [`.github/workflows/deploy.yml`](.github/workflows/deploy.yml). Never rebuild the binary by hand on GPU; merge to `main` instead. The workflow keeps the previous binary at `~/.local/bin/neomd.prev` for rollback. + A keyboard-first TUI email: write in Neovim, render as Markdown, screen senders first, organize emails once. _Neomd is my way of implementing an email TUI based on my experience with Neomutt, focusing on [Neovim](https://www.ssp.sh/brain/neovim) (input) and reading/writing in [Markdown](https://www.ssp.sh/brain/markdown) and navigating with [Vim Motions](https://www.ssp.sh/brain/vim-language-and-motions) with the GTD workflow and [HEY-Screener](https://www.hey.com/features/the-screener/)._ @@ -161,7 +165,7 @@ Keep your inbox clean without effort. - **Attachments** — attach files from the pre-send screen via yazi (`a`); images appear inline in the email body, other files as attachments; also attach from within Neovim via `a`; the reader lists all attachments and `1`–`9` downloads and opens them [→](https://neomd.ssp.sh/docs/sending/#attachments) - **Emoji reactions** — press `ctrl+e` from inbox or reader to react with emoji (👍 ❤️ 😂 🎉 🙏 💯 👀 ✅); instant send with proper threading and quoted message history, no editor needed [→](https://neomd.ssp.sh/docs/sending/#emoji-reactions) - **Multi-select** — `m` marks emails, then batch-delete, move, or screen them all at once [→](https://neomd.ssp.sh/docs/keybindings/#multi-select--undo) -- **Undo** — `u` reverses the last move or delete (`x`, `A`, `M*`) using the UIDPLUS destination UID [→](https://neomd.ssp.sh/docs/keybindings/#multi-select--undo) +- **Undo** — `U` reverses the last move or delete (`x`, `e`, `M*`) using the UIDPLUS destination UID [→](https://neomd.ssp.sh/docs/keybindings/#multi-select--undo) ### Reading @@ -179,7 +183,11 @@ Keep your inbox clean without effort. - **CC, BCC, Reply-all** — optional Cc/Bcc fields (toggle with `ctrl+b`); `R` in the reader replies to sender + all CC recipients [→](https://neomd.ssp.sh/docs/sending/#cc-bcc-reply-all-and-forward) - **Drafts** — `d` in pre-send saves to Drafts (IMAP APPEND); `E` in the reader re-opens a draft as an editable compose; compose sessions are auto-backed up to `~/.cache/neomd/drafts/` so you never lose an unsent email (`:recover` to reopen) [→](https://neomd.ssp.sh/docs/sending/#drafts) -- **Send later** — `l` in pre-send schedules delivery (`+2h`, `17:30`, `tomorrow 09:00`); the message queues in your Scheduled folder and the headless daemon delivers it on time — delete from Scheduled to cancel [→](https://neomd.ssp.sh/docs/sending/#send-later) +- **Send later** — `l` in pre-send schedules delivery using natural language (`in 3 days`, `friday 2pm`, `tomorrow morning`, or `17:30`); the message queues in your Scheduled folder and the headless daemon delivers it on time — delete from Scheduled to cancel [→](https://neomd.ssp.sh/docs/sending/#send-later) +- **Keyboard-driven, instantly** — `e` archive, `h` remind, `s` start an email, `;` snippets. Every action applies to the list the moment you press the key; the IMAP round-trip happens behind it and only a server failure puts the row back [→](https://neomd.ssp.sh/docs/keybindings/) +- **Infinite scroll** — reaching the bottom of the list appends the next page automatically; no pager, and the cursor stays where you left it +- **Snippets** — `;` opens a template picker reading `~/.config/neomd/snippets/*.md`; an optional first `Subject: ...` line sets the subject, the rest becomes the body +- **Per-email reminders** — press `h` on a message (from the inbox, a filtered `/` result, or the reader) to open quick picks plus a natural-language field (`in 3 days`, `friday 2pm`, `this weekend`, `next week`, or a date/time); it moves to Waiting and returns to Inbox when due. No mail is sent; the original stays recoverable in Trash [→](https://neomd.ssp.sh/docs/sending/#reminders) - **Recipient names & contacts** — names are harvested from email headers (plus an optional contacts file / Google Contacts CSV export), used to find people by name in search and to send `Louise Nachname ` instead of a bare address [→](https://neomd.ssp.sh/docs/sending/#recipient-names) - **Multiple From addresses** — define SMTP-only `[[senders]]` aliases (e.g. `s@ssp.sh` through an existing account); cycle with `ctrl+f` in compose and pre-send; sent copies always land in the Sent folder [→](https://neomd.ssp.sh/docs/sending/#multiple-from-addresses) - **OS keyring credentials** — set `password = "keyring"` to fetch the IMAP/SMTP password from your OS keyring (macOS Keychain, Linux Secret Service, Windows Credential Manager); OAuth2 tokens also stored in keyring with file fallback for headless/SSH; resolution happens at config load so `[[senders]]` aliases inherit the resolved password automatically [→](https://neomd.ssp.sh/docs/configuration/#storing-passwords-in-the-os-keyring) @@ -267,8 +275,34 @@ spam = "~/.config/neomd/lists/spam.txt" Use an app-specific password (Gmail, Fastmail, Hostpoint, etc.) rather than your main account password. The `password` and `user` fields support environment variable expansion (`$VAR` or `${VAR}`) so you can avoid storing secrets in the config file. +For an existing external XOAUTH2 helper, set `auth_type = "oauth2"` and provide +`oauth2_token_command = ["~/bin/oauth-token.sh"]` in the account. The fixed argv +command's trimmed stdout is used as the in-memory access token; only a leading +`~/` in its executable is expanded, stderr is discarded, and native OAuth client, +issuer/URL, keyring, and token-file settings are not used. Set root-level +`read_only = true` to browse with IMAP `EXAMINE` while refusing every IMAP +mutation and outbound delivery; first-run folder creation and `--headless` are +also disabled. + For the full configuration reference including multiple accounts, OAuth2 authentication, `[[senders]]` aliases, folder customization, signatures, and UI options, see [docs/content/docs/configuration](docs/content/docs/configuration/_index.md). +### Reading a neomd:// link from a script or agent + +Message links copied from the reader can be resolved without opening the TUI. +`read` searches the configured mailboxes with IMAP `EXAMINE` and `BODY.PEEK`, so +it never marks, moves, or deletes mail. It accepts a link, a bracketed Message-ID, +or a bare Message-ID: + +```sh +neomd read 'neomd://mid/%3Cabc%40example.com%3E?folder=Inbox' +neomd read '' --account Work --json +``` + +For batch use, pipe one link per line to `neomd read --json`. Use `--raw` for +the RFC822 source. Exit status is 0 when found, 1 when not found, and 2 for +configuration or authentication errors. OAuth2 `oauth2_token_command` helpers +work unattended through this path. + **Provider-specific guides:** - Gmail: [docs/content/docs/configuration/gmail.md](docs/content/docs/configuration/gmail.md) — folder name mapping and OAuth2 setup @@ -349,6 +383,8 @@ make help print this list You have more questions, check out the [docs](https://neomd.ssp.sh/) with more information, or check [Frequently Asked Questions](https://neomd.ssp.sh/docs/faq/). +For why neomd exists and the principles behind it, see [VISION.md](VISION.md). + ## Changelog See [CHANGELOG.md](CHANGELOG.md) for what's new. diff --git a/VISION.md b/VISION.md new file mode 100644 index 0000000..c1aa853 --- /dev/null +++ b/VISION.md @@ -0,0 +1,85 @@ +# Vision + +`neomd` exists so that one person can run their entire email life from the terminal, at the speed of a keystroke, with an inbox that contains only the senders they chose. +It removes the standing pains of email: an inbox flooded by strangers, writing trapped in rich-text web apps, filing busy-work that never ends, and tracking pixels that report every open. +It also removes the small waits that add up: actions that stall on a reload cycle, history that stops at the first page, and searches that answer nothing. +It began because its author wanted Neomutt's keyboard-driven speed combined with the HEY screener's sender control, GTD's process-once discipline, and Markdown as the native language of both reading and writing email. +The explicit bar is the best commercial keyboard-first client, named by its captain as the gold standard of that class: the same instant, shortcut-driven, zero-friction experience delivered from a terminal. +That intent is the whole product in one line: keyboard-first TUI email - write in Neovim, render as Markdown, screen senders first, organize emails once. + +## The inbox is a chosen place + +Ordinary clients treat the inbox as a place where everyone may arrive, then bury the user in filters, rules, and scoring algorithms that guess at what matters. +neomd inverts this: an unknown sender waits in ToScreen until the user decides, and one classification of a sender holds forever - screened-in, screened-out, Feed, or Papertrail. +The user decides who reaches the inbox, never an algorithm; this same gate doubles as phishing defense, because an impostor claiming to be a known sender lands in ToScreen where it looks immediately suspicious. +Decisions are made once and stored as plain text lists, so the screening brain is inspectable, editable, and syncable with no neomd in the loop. +After screening, GTD finishes the job: every email in the inbox is processed exactly once into next, Waiting, Someday, Scheduled, or Archive. +Filing hierarchies are rejected on purpose; emails fade into Archive, and search - over contacts and headers, on the server - finds them when needed. +Newsletters and receipts get their own quiet rooms in Feed and Papertrail, so deciding whether to read mail and whether to read news are separate acts. + +## Built for one pair of hands + +neomd serves the terminal person: someone who lives in Neovim and vim motions, writes in Markdown, and wants email to behave like the rest of their tooling instead of a browser tab. +It runs the captain's real mail: a work account and personal accounts side by side behind one OAuth setup, because one person's life spans several inboxes and switching between them is one keystroke, not a logout. +It is written by one such person for their own daily use, and shared publicly so others of the same shape can run, read, fork, and shape it; the fork exists so the workflow can be updated and optimized without asking anyone's permission, and users who need different folders fork the Go source. +On its author's machine it has taken over as the primary daily client, with the older Neomutt setup preserved unchanged purely as a fallback. +It is not for teams, shared inboxes, or collaboration; it is not for users who want a graphical client, a filing hierarchy, or an inbox that any sender can enter. +It is experimental software that moves, deletes, and flags mail directly on the IMAP server across all devices, and it says so up front: back up important email, try a secondary account first. + +## What neomd owns + +It owns the end-to-end terminal email experience: screening, reading, threading, composing in the user's own editor, sending, drafts, send-later, reminders, emoji reactions, iCalendar RSVPs, out-of-office replies, and multiple accounts behind one config with instant switching. +It owns the bytes that go on the wire: RFC-compliant MIME with Markdown as plain text plus rendered HTML, correct threading headers, deliverability across providers, and recipient-visible fidelity held to a hardening suite of byte-exact round-trip tests. +It owns a headless daemon that runs the background of the same workflow on an always-on machine: auto-screening, scheduled delivery, due reminders, and screened-in-only vacation replies that never reveal absence to strangers. +It owns a small set of hand-offs to tools the user already has: Neovim for composing, yazi for attachments, any LLM CLI for an AI draft pass, the local calendar app for invites, the browser for full-fidelity viewing, and a status-bar CLI for a panel widget. +It owns privacy defaults: spy pixels detected and blocked with attribution, credentials kept in config files, environment variables, or the OS keyring, TLS enforced with unencrypted ports refused, and secrets never printed in errors. + +## What it refuses to own + +It refuses to be a mail store: IMAP is the database, there is no local sync daemon or shadow copy to drift, and neomd stays consistent with the phone or webmail using the same account. +It refuses to grow folders: the folder set is fixed in code to the screener-plus-GTD workflow, because a small closed surface is what keeps processing predictable. +It refuses to compose in HTML: Markdown is the source of truth and HTML is derived output, never the other way around. +It refuses an in-app AI: intelligence arrives by handing the draft to whichever CLI the user configures, so neomd depends on no model, vendor, or subscription. +It refuses to score content for spamminess; screening is by sender and by the user's explicit word. +It refuses to be a service: no server of its own, no accounts, no telemetry, one Go binary and one config file. + +## The experience + +The experience is speed: every navigation, open, and move is a keystroke away and measured in milliseconds, not seconds, which is why provider latency is benchmarked, documented, and honest about which providers feel instant. +It is instant feedback: acting on a message updates the row at once instead of waiting for the next reload, and a failed action hands back the exact selection it started with. +It is continuity: the list keeps revealing older mail as the user scrolls, and a search returns everything that matches, because a feature that silently stops is a defect. +It is the confidence of processing email once: classify a sender one time, decide an email one time, and neither decision ever has to be made again. +It is the ease of looking away: drafts are backed up for recovery, sends require a review screen, discards ask for confirmation, moves can be undone, overdue scheduled mail shouts a warning instead of silently dying, and the hardening suite stands guard over every byte a recipient will see. +It is the calm of a quiet inbox: no unread strangers, no unread newsletters, no tracking, and desktop notifications only for senders the user named as VIP. + +## Principles + +Speed is the product; when a feature costs latency on every interaction, it must buy something the workflow cannot live without. +Fidelity to the wire outranks convenience; a mangled recipient, subject, attachment, or leaked Bcc reaches real people, so outgoing bytes are pinned by tests and never regressed for expediency. +Plain text wins ties; the compose buffer, screener lists, config, and drafts are all human-readable text that survives editors, sync, and time. +Nothing happens without a visible decision and a way back: review before send, confirm before discard, undo after move, recover after loss. +Outbound mail originates only in an explicit human act - a sent message, a scheduled send, a configured vacation reply - so no automated helper can ever speak in the user's voice. +Defects are fixed at the cause: a warning or a broken key is eliminated, never suppressed, hidden, or worked around. +Directness over layers: talk to the IMAP and SMTP servers directly, integrate by handing off to existing tools, and add no intermediate service, index, or daemon that can disagree with the server. +Security and privacy are defaults, not options: encrypted connections only, credentials protected, trackers blocked, and absence never disclosed to unscreened senders. +The single user's real workflow is the specification; features enter when the author's daily email demands them and are hardened as if business depended on it, because it does. + +## Non-goals + +- Custom or user-defined folders beyond the fixed screener and GTD set. +- A local mail store, index, or sync daemon beside IMAP. +- Rich-text or HTML composing; Markdown in, HTML out. +- Team, shared-inbox, or collaboration features. +- Making slow IMAP providers fast; latency is the provider's, and neomd documents it instead. +- A mobile-first client; running on Android is a working extra, not a target. +- Content-based spam scoring or any inbox ordering the user did not choose. + +## A year from now + +A folder switch on a good provider still feels instant, and the benchmark documentation still tells the truth about providers that cannot keep up. +Business email still arrives byte-exact, and the hardening suite has grown alongside every new field and path so that what is parse-back-asserted cannot silently break. +A change is not finished when its tests pass: it counts when it is proven in the live client on the owner's real machine, merged, deployed, and re-verified against the real inbox path. +The daemon quietly runs the background on a home server: screening arrives before the inbox is opened, scheduled mail goes out on time, reminders return when due, and vacation replies answer once and only the chosen. +The distance to the keyboard-first standard keeps shrinking in order of daily pain, instant actions and the small conveniences first, so the terminal client loses nothing to its graphical model. +Installing is still cloning the repository and running make install, upgrading is a version bump away, and a new user has classified their first sender within minutes of first launch. +The inbox still contains only senders the user chose, and the terminal still is the only place the user needs to answer email. diff --git a/cmd/neomd/main.go b/cmd/neomd/main.go index de03067..9da1284 100644 --- a/cmd/neomd/main.go +++ b/cmd/neomd/main.go @@ -23,6 +23,10 @@ import ( var version = "dev" func main() { + // Go's standard flag parser stops at the first positional argument. Pull + // -config out first so the documented `neomd read --config ...` + // spelling works as well as the traditional global-flag spelling. + os.Args = append([]string{os.Args[0]}, moveConfigArgsToFront(os.Args[1:])...) cfgPath := flag.String("config", "", "path to config.toml (default: ~/.config/neomd/config.toml)") showVersion := flag.Bool("version", false, "print version and exit") headless := flag.Bool("headless", false, "run in headless daemon mode (no TUI)") @@ -39,6 +43,17 @@ func main() { fmt.Println("neomd", version) return } + readCommand := flag.NArg() > 0 && flag.Arg(0) == "read" + legacyRead := readCommand && hasArg(flag.Args()[1:], "--uid") + var readOptions agentReadOpts + var readOptionsErr error + if readCommand && !legacyRead { + readOptions, readOptionsErr = parseAgentReadArgs(flag.Args()[1:]) + if readOptionsErr != nil { + fmt.Fprintf(os.Stderr, "neomd read: %v\n", readOptionsErr) + os.Exit(2) + } + } ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) defer cancel() @@ -47,15 +62,41 @@ func main() { if err != nil { if strings.Contains(err.Error(), "please fill in") { fmt.Fprintln(os.Stderr, "neomd:", err) + if readCommand { + os.Exit(2) + } os.Exit(0) } fmt.Fprintf(os.Stderr, "neomd: config error: %v\n", err) + if readCommand { + os.Exit(2) + } + os.Exit(1) + } + if *headless && cfg.ReadOnly { + fmt.Fprintln(os.Stderr, "neomd: --headless is disabled by read_only mode") os.Exit(1) } accounts := cfg.ActiveAccounts() + if readCommand && readOptions.account != "" { + var selected []config.AccountConfig + for _, account := range accounts { + if strings.EqualFold(account.Name, readOptions.account) { + selected = append(selected, account) + } + } + if len(selected) == 0 { + fmt.Fprintf(os.Stderr, "neomd read: unknown account %q\n", readOptions.account) + os.Exit(2) + } + accounts = selected + } if len(accounts) == 0 { fmt.Fprintln(os.Stderr, "neomd: no accounts configured in config.toml") + if readCommand { + os.Exit(2) + } os.Exit(1) } @@ -79,40 +120,16 @@ func main() { STARTTLS: useSTARTTLS, TLSCertFile: acc.TLSCertFile, } - if acc.IsOAuth2() { - if acc.OAuth2ClientID == "" { - fmt.Fprintf(os.Stderr, "neomd: account %q: oauth2_client_id is required\n", acc.Name) - os.Exit(1) + if err := configureIMAPAuth(ctx, acc, &imapCfg); err != nil { + fmt.Fprintf(os.Stderr, "neomd: account %q: %v\n", acc.Name, err) + if readCommand { + os.Exit(2) } - if acc.OAuth2IssuerURL == "" && (acc.OAuth2AuthURL == "" || acc.OAuth2TokenURL == "") { - fmt.Fprintf(os.Stderr, "neomd: account %q: set oauth2_issuer_url or both oauth2_auth_url and oauth2_token_url\n", acc.Name) - os.Exit(1) - } - tokenFile, err := config.TokenFilePath(acc.Name) - if err != nil { - fmt.Fprintf(os.Stderr, "neomd: account %q: %v\n", acc.Name, err) - os.Exit(1) - } - ts, err := oauth2.TokenSource(ctx, oauth2.Config{ - ClientID: acc.OAuth2ClientID, - ClientSecret: acc.OAuth2ClientSecret, - IssuerURL: acc.OAuth2IssuerURL, - AuthURL: acc.OAuth2AuthURL, - TokenURL: acc.OAuth2TokenURL, - Scopes: acc.OAuth2Scopes, - RedirectPort: acc.OAuth2RedirectPort, - TokenFile: tokenFile, - AccountName: acc.Name, // enables keyring storage; TokenFile remains as headless fallback - }) - if err != nil { - fmt.Fprintf(os.Stderr, "neomd: account %q: oauth2: %v\n", acc.Name, err) - os.Exit(1) - } - imapCfg.TokenSource = ts - } else if acc.User == "" || acc.Password == "" { - fmt.Fprintf(os.Stderr, "neomd: account %q: user/password not set\n", acc.Name) os.Exit(1) } + // The script-facing read command is always EXAMINE/BODY.PEEK, even + // when the user's normal TUI profile is writable. + imapCfg.ReadOnly = cfg.ReadOnly || readCommand imapClients = append(imapClients, goIMAP.New(imapCfg)) } defer func() { @@ -145,22 +162,36 @@ func main() { os.Exit(code) } - // `neomd read --folder