From 74bd78ff7e8d62a7cec96c7c28c3b096b564a351 Mon Sep 17 00:00:00 2001 From: Jon Roosevelt Date: Tue, 1 Sep 2026 10:16:49 -0400 Subject: [PATCH 01/16] feat: add per-email reminders --- AGENTS.md | 9 ++ CHANGELOG.md | 13 +++ README.md | 1 + docs/content/docs/_index.md | 1 + docs/content/docs/keybindings.md | 1 + docs/content/docs/sending.md | 10 +- internal/daemon/daemon.go | 30 ++++++ internal/imap/client.go | 63 ++++++++++-- internal/imap/client_test.go | 11 +++ internal/reminder/reminder.go | 117 ++++++++++++++++++++++ internal/reminder/reminder_test.go | 30 ++++++ internal/ui/inbox.go | 31 +++--- internal/ui/keys.go | 1 + internal/ui/model.go | 150 ++++++++++++++++++++++++++++- internal/ui/model_test.go | 18 ++++ internal/ui/reader.go | 2 +- 16 files changed, 463 insertions(+), 25 deletions(-) create mode 100644 internal/reminder/reminder.go create mode 100644 internal/reminder/reminder_test.go diff --git a/AGENTS.md b/AGENTS.md index ab17e6e..7d554c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,6 +101,15 @@ 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 - **`·` reply indicator** — after sending a reply, the original email gets the IMAP diff --git a/CHANGELOG.md b/CHANGELOG.md index 685a156..45658e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +# 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`. + # 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..7fa5953 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,7 @@ 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) +- **Per-email reminders** — open an email and press `H`, enter `+2h`, `tomorrow 09:00`, 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) diff --git a/docs/content/docs/_index.md b/docs/content/docs/_index.md index 2e34523..d40fd10 100644 --- a/docs/content/docs/_index.md +++ b/docs/content/docs/_index.md @@ -183,6 +183,7 @@ 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) +- **Per-email reminders** — open an email and press `H`, enter `+2h`, `tomorrow 09:00`, 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) diff --git a/docs/content/docs/keybindings.md b/docs/content/docs/keybindings.md index 76a4a20..6bc56cf 100644 --- a/docs/content/docs/keybindings.md +++ b/docs/content/docs/keybindings.md @@ -138,6 +138,7 @@ To update both the help overlay and this document at once, edit that file and ru | `ctrl+e` | react with emoji (from inbox or reader) | | `f` | forward email (from reader or inbox) | | `T` | show full conversation thread across folders (from inbox or reader) | +| `H (reader)` | remind this email — return it to Inbox at a chosen time (no mail is sent) | | `V` | show all emails from this sender, across all folders (from inbox) | | `c` | compose new email | | `ctrl+b (compose/pre-send)` | toggle Cc+Bcc fields (both hidden by default) | diff --git a/docs/content/docs/sending.md b/docs/content/docs/sending.md index 7b7c25e..c095564 100644 --- a/docs/content/docs/sending.md +++ b/docs/content/docs/sending.md @@ -303,6 +303,15 @@ Use the continue-draft flow: The original queued copy is **replaced automatically**: once the new version is safely stored (or sent), the old one is moved to Trash — recoverable, never expunged. If you instead abort the editor or discard on pre-send, the original stays queued untouched. Should the automatic removal ever fail (connection drop), neomd warns you loudly so you can delete the old copy manually — otherwise the daemon would deliver both. +## Reminders + +Open an email and press `H` to set a reminder, then enter a time such as +`+2h`, `tomorrow 09:00`, or `2026-09-02 09:00`. The message is copied to the +configured **Waiting** folder with `X-Neomd-Reminder-*` metadata and the +original is moved to Trash (recoverable). No email is sent. The headless daemon +returns due reminders to Inbox; the reminder marker stays on the message so it +is clear why it resurfaced. The TUI also checks Waiting during background sync. + ## Recipient Names When you type a bare address (`louise@domain.io`), neomd automatically upgrades the outgoing `To:`/`Cc:` headers to `Louise Nachname ` when it knows the name — so recipients see a proper name and your Sent folder becomes searchable by name. Names come from three sources, in order: @@ -522,4 +531,3 @@ Replace `zsh`/`.zshrc` with `bash`/`.bashrc` if you use bash. Replace `foot` wit ### How it looks ![mailto](/images/mailto-open.png) - diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 4e602cc..c7facc2 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -79,6 +79,9 @@ func (d *Daemon) Run(ctx context.Context) error { if err := d.processScheduled(ctx); err != nil { d.logger.Error("send-later pass failed", "error", err) } + if err := d.processReminders(ctx); err != nil { + d.logger.Error("reminder pass failed", "error", err) + } if err := d.processOOO(ctx); err != nil { d.logger.Error("out-of-office pass failed", "error", err) } @@ -100,6 +103,9 @@ func (d *Daemon) Run(ctx context.Context) error { if err := d.processScheduled(ctx); err != nil { d.logger.Error("send-later pass failed", "error", err) } + if err := d.processReminders(ctx); err != nil { + d.logger.Error("reminder pass failed", "error", err) + } if err := d.processOOO(ctx); err != nil { d.logger.Error("out-of-office pass failed", "error", err) } @@ -178,6 +184,30 @@ func (d *Daemon) reloadScreener() error { return nil } +// processReminders resurfaces due messages without SMTP: IMAP MOVE is the +// entire lifecycle. Reminder metadata remains attached so the Inbox shows +// which messages were returned and when they became due. +func (d *Daemon) processReminders(ctx context.Context) error { + waiting, inbox := d.cfg.Folders.Waiting, d.cfg.Folders.Inbox + if waiting == "" || inbox == "" || waiting == inbox { + return nil + } + emails, err := d.imapCli.FetchHeaders(ctx, waiting, 0) + if err != nil { + return fmt.Errorf("fetch reminders: %w", err) + } + for _, e := range emails { + if e.Reminder == nil || e.Reminder.Status(time.Now()) != "due" { + continue + } + if _, err := d.imapCli.MoveMessage(ctx, waiting, e.UID, inbox); err != nil { + return fmt.Errorf("resurface reminder uid=%d: %w", e.UID, err) + } + d.logger.Info("reminder resurfaced", "uid", e.UID, "subject", e.Subject, "scheduled_for", e.Reminder.At.Format(time.RFC3339)) + } + return nil +} + // processScheduled delivers due send-later messages queued in the Scheduled // folder (see internal/schedule). Claim protocol: a message is marked // \Flagged right before SMTP delivery so a crash mid-send can never deliver diff --git a/internal/imap/client.go b/internal/imap/client.go index 0c08731..05c1ee0 100644 --- a/internal/imap/client.go +++ b/internal/imap/client.go @@ -24,6 +24,7 @@ import ( "github.com/emersion/go-message/mail" "github.com/sspaeti/neomd/internal/mailtls" "github.com/sspaeti/neomd/internal/oauth2" + "github.com/sspaeti/neomd/internal/reminder" "github.com/sspaeti/neomd/internal/schedule" ) @@ -50,12 +51,13 @@ type Email struct { Answered bool // \Answered flag — set when replied to from any client Flagged bool // \Flagged — the send-later daemon uses it as a claim marker Folder string - Size uint32 // RFC822 size in bytes - HasAttachment bool // true if BODYSTRUCTURE contains an attachment part - MessageID string // Message-ID from envelope (for threading) - InReplyTo string // first In-Reply-To message ID (for threading) - References string // References header (space-separated Message-IDs for threading) - SendAt time.Time // parsed X-Neomd-Send-At — non-zero only for send-later queued messages + Size uint32 // RFC822 size in bytes + HasAttachment bool // true if BODYSTRUCTURE contains an attachment part + MessageID string // Message-ID from envelope (for threading) + InReplyTo string // first In-Reply-To message ID (for threading) + References string // References header (space-separated Message-IDs for threading) + SendAt time.Time // parsed X-Neomd-Send-At — non-zero only for send-later queued messages + Reminder *reminder.Metadata // optional per-email reminder metadata } // Config holds connection parameters. @@ -302,6 +304,25 @@ func sendAtHeaderSection() []*imap.FetchItemBodySection { }} } +func reminderHeaderSection() *imap.FetchItemBodySection { + return &imap.FetchItemBodySection{ + Specifier: imap.PartSpecifierHeader, + HeaderFields: []string{reminder.AtHeader, reminder.StateHeader, reminder.IDHeader}, + Peek: true, + } +} + +func parseReminder(sections []imapclient.FetchBodySectionBuffer) *reminder.Metadata { + if len(sections) < 2 || len(sections[1].Bytes) == 0 { + return nil + } + metadata, err := reminder.ParseHeader(sections[1].Bytes) + if err != nil || metadata.At.IsZero() { + return nil + } + return &metadata +} + // parseSendAtSection extracts the RFC 3339 time from a fetched // X-Neomd-Send-At header-fields section. Zero time when absent. func parseSendAtSection(sections []imapclient.FetchBodySectionBuffer) time.Time { @@ -364,7 +385,7 @@ func (c *Client) FetchHeaders(ctx context.Context, folder string, n int) ([]Emai Envelope: true, RFC822Size: true, BodyStructure: &imap.FetchItemBodyStructure{Extended: true}, - BodySection: sendAtHeaderSection(), + BodySection: append(sendAtHeaderSection(), reminderHeaderSection()), }).Collect() if err != nil { return fmt.Errorf("FETCH headers: %w", err) @@ -380,7 +401,7 @@ func (c *Client) FetchHeaders(ctx context.Context, folder string, n int) ([]Emai if !ok { continue } - e := Email{UID: uint32(m.UID), Folder: folder, SendAt: parseSendAtSection(m.BodySection)} + e := Email{UID: uint32(m.UID), Folder: folder, SendAt: parseSendAtSection(m.BodySection), Reminder: parseReminder(m.BodySection)} for _, f := range m.Flags { if f == imap.FlagSeen { e.Seen = true @@ -780,13 +801,13 @@ func (c *Client) FetchHeadersByUID(ctx context.Context, folder string, uids []ui Envelope: true, RFC822Size: true, BodyStructure: &imap.FetchItemBodyStructure{Extended: true}, - BodySection: sendAtHeaderSection(), + BodySection: append(sendAtHeaderSection(), reminderHeaderSection()), }).Collect() if err != nil { return fmt.Errorf("FETCH headers: %w", err) } for _, m := range msgs { - e := Email{UID: uint32(m.UID), Folder: folder, SendAt: parseSendAtSection(m.BodySection)} + e := Email{UID: uint32(m.UID), Folder: folder, SendAt: parseSendAtSection(m.BodySection), Reminder: parseReminder(m.BodySection)} for _, f := range m.Flags { if f == imap.FlagSeen { e.Seen = true @@ -1212,6 +1233,28 @@ func (c *Client) SaveDraft(ctx context.Context, folder string, raw []byte) error }) } +// SaveReminder APPENDs a parked email to the reminder folder. Reminders are +// ordinary IMAP messages; no SMTP or outbound delivery is involved. +func (c *Client) SaveReminder(ctx context.Context, folder string, raw []byte) error { + if ctx == nil { + ctx = context.Background() + } + return c.withConn(ctx, func(conn *imapclient.Client) error { + opts := &imap.AppendOptions{Flags: []imap.Flag{imap.FlagSeen}, Time: time.Now()} + cmd := conn.Append(folder, int64(len(raw)), opts) + if _, err := cmd.Write(raw); err != nil { + return fmt.Errorf("APPEND reminder write: %w", err) + } + if err := cmd.Close(); err != nil { + return fmt.Errorf("APPEND reminder close: %w", err) + } + if _, err := cmd.Wait(); err != nil { + return fmt.Errorf("APPEND reminder wait: %w", err) + } + return nil + }) +} + // parseBody extracts the best available content from a raw MIME message. // Returns (markdownBody, rawHTML, webURL). // - markdownBody: what the TUI renders (HTML→markdown or normalised plain text) diff --git a/internal/imap/client_test.go b/internal/imap/client_test.go index 5a71516..ab50a13 100644 --- a/internal/imap/client_test.go +++ b/internal/imap/client_test.go @@ -34,6 +34,17 @@ func TestParseSendAtSection(t *testing.T) { } } +func TestParseReminderSection(t *testing.T) { + secs := []imapclient.FetchBodySectionBuffer{{}, {Bytes: []byte("X-Neomd-Reminder-At: 2030-01-02T03:04:05Z\r\nX-Neomd-Reminder-State: due\r\n")}} + got := parseReminder(secs) + if got == nil || got.State != "due" || !got.At.Equal(time.Date(2030, time.January, 2, 3, 4, 5, 0, time.UTC)) { + t.Fatalf("reminder = %#v, want due metadata", got) + } + if parseReminder(nil) != nil || parseReminder([]imapclient.FetchBodySectionBuffer{{Bytes: []byte("Subject: ordinary\r\n")}}) != nil { + t.Fatal("ordinary or incomplete headers should not become reminders") + } +} + func TestBuildSearchCriteria(t *testing.T) { tests := []struct { name string diff --git a/internal/reminder/reminder.go b/internal/reminder/reminder.go new file mode 100644 index 0000000..d721dff --- /dev/null +++ b/internal/reminder/reminder.go @@ -0,0 +1,117 @@ +// Package reminder defines the metadata used to park an email until later. +package reminder + +import ( + "bytes" + "fmt" + "net/mail" + "strings" + "time" +) + +const ( + AtHeader = "X-Neomd-Reminder-At" + StateHeader = "X-Neomd-Reminder-State" + IDHeader = "X-Neomd-Reminder-ID" +) + +type Metadata struct { + At time.Time + State string + ID string +} + +// ParseHeader parses only the reminder headers supplied by IMAP BODY.PEEK. +func ParseHeader(raw []byte) (Metadata, error) { + msg, err := mail.ReadMessage(bytes.NewReader(raw)) + if err != nil { + return Metadata{}, fmt.Errorf("parse reminder headers: %w", err) + } + atValue := strings.TrimSpace(msg.Header.Get(AtHeader)) + if atValue == "" { + return Metadata{}, nil + } + at, err := time.Parse(time.RFC3339, atValue) + if err != nil { + return Metadata{}, fmt.Errorf("parse %s: %w", AtHeader, err) + } + state := strings.ToLower(strings.TrimSpace(msg.Header.Get(StateHeader))) + if state == "" { + state = "scheduled" + } + if state != "scheduled" && state != "due" { + return Metadata{}, fmt.Errorf("invalid reminder state %q", state) + } + return Metadata{At: at.UTC(), State: state, ID: strings.TrimSpace(msg.Header.Get(IDHeader))}, nil +} + +func (m Metadata) Status(now time.Time) string { + if m.State != "scheduled" || m.At.IsZero() { + return m.State + } + if !now.Before(m.At) { + return "due" + } + return "scheduled" +} + +// SetHeaders adds or replaces reminder metadata and leaves the message body +// byte-for-byte untouched. It accepts CRLF or LF input and emits CRLF headers. +func SetHeaders(raw []byte, at time.Time, id string) ([]byte, error) { + if at.IsZero() { + return nil, fmt.Errorf("reminder time is required") + } + if strings.ContainsAny(id, "\r\n") { + return nil, fmt.Errorf("reminder ID contains a newline") + } + return rewrite(raw, map[string]string{ + AtHeader: at.UTC().Format(time.RFC3339), + StateHeader: "scheduled", + IDHeader: id, + }) +} + +// ClearHeaders removes reminder metadata without changing the body. +func ClearHeaders(raw []byte) ([]byte, error) { + return rewrite(raw, nil) +} + +func rewrite(raw []byte, values map[string]string) ([]byte, error) { + sep, sepLen := []byte("\r\n\r\n"), 4 + idx := bytes.Index(raw, sep) + if idx < 0 { + sep, sepLen = []byte("\n\n"), 2 + idx = bytes.Index(raw, sep) + } + if idx < 0 { + return nil, fmt.Errorf("message has no RFC header/body separator") + } + var lines []string + removing := false + for _, line := range strings.Split(string(raw[:idx]), "\n") { + line = strings.TrimRight(line, "\r") + if removing && (strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t")) { + continue + } + removing = false + key, _, ok := strings.Cut(line, ":") + if ok && (strings.EqualFold(key, AtHeader) || strings.EqualFold(key, StateHeader) || strings.EqualFold(key, IDHeader)) { + removing = true + continue + } + lines = append(lines, line) + } + if len(values) > 0 { + // Keep metadata together at the top, making it cheap to fetch later. + injected := []string{AtHeader + ": " + values[AtHeader], StateHeader + ": " + values[StateHeader], IDHeader + ": " + values[IDHeader]} + lines = append(injected, lines...) + } + var out bytes.Buffer + for _, line := range lines { + out.WriteString(line) + out.WriteString("\r\n") + } + out.WriteString("\r\n") + out.Write(raw[idx+sepLen:]) + return out.Bytes(), nil +} diff --git a/internal/reminder/reminder_test.go b/internal/reminder/reminder_test.go new file mode 100644 index 0000000..002f15c --- /dev/null +++ b/internal/reminder/reminder_test.go @@ -0,0 +1,30 @@ +package reminder + +import ( + "bytes" + "testing" + "time" +) + +func TestParseHeaderAndStatus(t *testing.T) { + at := time.Date(2030, time.January, 2, 8, 4, 5, 0, time.UTC) + got, err := ParseHeader([]byte("X-Neomd-Reminder-At: 2030-01-02T03:04:05-05:00\r\nX-Neomd-Reminder-ID: id-1\r\n\r\n")) + if err != nil || !got.At.Equal(at) || got.State != "scheduled" || got.ID != "id-1" { + t.Fatalf("metadata=%#v err=%v", got, err) + } + if got.Status(at.Add(-time.Minute)) != "scheduled" || got.Status(at) != "due" { + t.Fatal("reminder status did not follow its due time") + } +} + +func TestHeadersPreserveBodyAndReplaceFoldedValues(t *testing.T) { + raw := []byte("Subject: test\r\nX-Neomd-Reminder-At: old\r\n folded\r\nX-Unrelated: yes\r\n\r\nbody\r\n") + updated, err := SetHeaders(raw, time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC), "id") + if err != nil || !bytes.HasSuffix(updated, []byte("body\r\n")) || bytes.Contains(updated, []byte("folded")) { + t.Fatalf("updated=%q err=%v", updated, err) + } + cleared, err := ClearHeaders(updated) + if err != nil || bytes.Contains(cleared, []byte("Reminder")) || !bytes.HasSuffix(cleared, []byte("body\r\n")) { + t.Fatalf("cleared=%q err=%v", cleared, err) + } +} diff --git a/internal/ui/inbox.go b/internal/ui/inbox.go index ca75194..4622929 100644 --- a/internal/ui/inbox.go +++ b/internal/ui/inbox.go @@ -44,14 +44,15 @@ func (d emailDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil } // Column widths const ( - colNumWidth = 4 // " 1 " - colFlagWidth = 2 // "N " or " " - colReplyWidth = 1 // "·" or " " - colThreadWidth = 2 // "│ " or "╰ " or " " - colDateWidth = 7 // "Feb 03 " - colAttachWidth = 2 // "@ " or " " - colSpyWidth = 2 // "°" or " " — spy pixel indicator - colSizeWidth = 7 // "(38.2K)" + colNumWidth = 4 // " 1 " + colFlagWidth = 2 // "N " or " " + colReplyWidth = 1 // "·" or " " + colThreadWidth = 2 // "│ " or "╰ " or " " + colDateWidth = 7 // "Feb 03 " + colAttachWidth = 2 // "@ " or " " + colSpyWidth = 2 // "°" or " " — spy pixel indicator + colReminderWidth = 2 // "R " or " " — scheduled/due reminder + colSizeWidth = 7 // "(38.2K)" ) func (d emailDelegate) Render(w io.Writer, m list.Model, index int, item list.Item) { @@ -99,9 +100,13 @@ func (d emailDelegate) Render(w io.Writer, m list.Model, index int, item list.It if e.hasSpyPixel { spyStr = "° " } + reminderStr := " " + if e.email.Reminder != nil { + reminderStr = "R " + } sizeStr := fmtSize(e.email.Size) - fixed := colNumWidth + colFlagWidth + colReplyWidth + colThreadWidth + colDateWidth + colAttachWidth + colSpyWidth + colSizeWidth + 2 // 2 spaces padding + fixed := colNumWidth + colFlagWidth + colReplyWidth + colThreadWidth + colDateWidth + colAttachWidth + colSpyWidth + colReminderWidth + colSizeWidth + 2 // 2 spaces padding fromMax := 20 subjectMax := width - fixed - fromMax - 2 if subjectMax < 8 { @@ -123,7 +128,7 @@ func (d emailDelegate) Render(w io.Writer, m list.Model, index int, item list.It subject := truncate(displaySafe(subjectText), subjectMax) if isSelected { - row := num + flag + replyStr + threadStr + dateStr + attachStr + spyStr + + row := num + flag + replyStr + threadStr + dateStr + attachStr + spyStr + reminderStr + padRight(from, fromMax) + " " + padRight(subject, subjectMax) + " " + sizeStr @@ -153,6 +158,10 @@ func (d emailDelegate) Render(w io.Writer, m list.Model, index int, item list.It if e.hasSpyPixel { spyS = lipgloss.NewStyle().Foreground(lipgloss.Color("208")).Render(spyStr) // orange warning } + reminderS := lipgloss.NewStyle().Foreground(colorMuted).Render(reminderStr) + if e.email.Reminder != nil { + reminderS = lipgloss.NewStyle().Foreground(colorPrimary).Render(reminderStr) + } fromStyle := lipgloss.NewStyle().Foreground(colorAuthorRead) subStyle := lipgloss.NewStyle().Foreground(colorSubjectRead) @@ -164,7 +173,7 @@ func (d emailDelegate) Render(w io.Writer, m list.Model, index int, item list.It subS := subStyle.Render(padRight(subject, subjectMax)) sizeS := lipgloss.NewStyle().Foreground(colorSizeCol).Render(sizeStr) - fmt.Fprint(w, numS+flagS+replyS+threadS+dateS+attachS+spyS+fromS+" "+subS+" "+sizeS) + fmt.Fprint(w, numS+flagS+replyS+threadS+dateS+attachS+spyS+reminderS+fromS+" "+subS+" "+sizeS) } // cleanFrom strips the part when a display name is present. diff --git a/internal/ui/keys.go b/internal/ui/keys.go index 5a9646e..a51101f 100644 --- a/internal/ui/keys.go +++ b/internal/ui/keys.go @@ -105,6 +105,7 @@ var HelpSections = []HelpSection{ {"ctrl+e", "react with emoji (from inbox or reader)"}, {"f", "forward email (from reader or inbox)"}, {"T", "show full conversation thread across folders (from inbox or reader)"}, + {"H (reader)", "remind this email — return it to Inbox at a chosen time (no mail is sent)"}, {"V", "show all emails from this sender, across all folders (from inbox)"}, {"c", "compose new email"}, {"ctrl+b (compose/pre-send)", "toggle Cc+Bcc fields (both hidden by default)"}, diff --git a/internal/ui/model.go b/internal/ui/model.go index f8a8652..5835b51 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -29,6 +29,7 @@ import ( "github.com/sspaeti/neomd/internal/imap" "github.com/sspaeti/neomd/internal/listmonk" "github.com/sspaeti/neomd/internal/notify" + "github.com/sspaeti/neomd/internal/reminder" "github.com/sspaeti/neomd/internal/render" "github.com/sspaeti/neomd/internal/schedule" "github.com/sspaeti/neomd/internal/screener" @@ -155,6 +156,14 @@ type ( label string } saveDraftDoneMsg struct{ err error } + reminderDoneMsg struct { + at time.Time + err error + } + dueRemindersDoneMsg struct { + moved int + err error + } // scheduleDoneMsg reports the result of queuing a send-later message. scheduleDoneMsg struct { at time.Time @@ -594,6 +603,9 @@ type Model struct { // Send-later time prompt on the pre-send screen (`l`). sendLaterActive bool sendLaterInput textinput.Model + // Reminder time prompt on the reader (`H`, Superhuman's reminder key). + reminderActive bool + reminderInput textinput.Model // Reaction reactionEmail *imap.Email // email being reacted to @@ -1958,6 +1970,74 @@ func (m Model) bgFetchInboxCmd() tea.Cmd { } } +// setReminderCmd stores one copy in Waiting only after the complete message +// has been fetched and tagged. The source is removed last, so a failed append +// cannot lose the email and no SMTP code is reachable from this path. +func (m Model) setReminderCmd(e *imap.Email, at time.Time) tea.Cmd { + return func() tea.Msg { + if e == nil || m.cfg.Folders.Waiting == "" { + return reminderDoneMsg{err: fmt.Errorf("reminders require a configured waiting folder")} + } + if e.Folder == m.cfg.Folders.Trash || m.cfg.Folders.Trash == "" { + return reminderDoneMsg{err: fmt.Errorf("reminders require a recoverable Trash folder")} + } + cli := m.imapCli() + if cli == nil { + return reminderDoneMsg{err: fmt.Errorf("reminders require an IMAP-enabled account")} + } + raw, err := cli.FetchRaw(nil, e.Folder, e.UID) + if err != nil { + return reminderDoneMsg{err: fmt.Errorf("fetch email for reminder: %w", err)} + } + id := e.MessageID + if id == "" { + id = fmt.Sprintf("neomd:%s:%d", e.Folder, e.UID) + } + updated, err := reminder.SetHeaders(raw, at, id) + if err != nil { + return reminderDoneMsg{err: fmt.Errorf("prepare reminder: %w", err)} + } + if err := cli.SaveReminder(nil, m.cfg.Folders.Waiting, updated); err != nil { + return reminderDoneMsg{err: fmt.Errorf("save reminder: %w", err)} + } + if _, err := cli.MoveMessage(nil, e.Folder, e.UID, m.cfg.Folders.Trash); err != nil { + return reminderDoneMsg{at: at, err: fmt.Errorf("reminder saved, but original could not be moved to Trash: %w", err)} + } + return reminderDoneMsg{at: at} + } +} + +// resurfaceDueRemindersCmd returns due messages from Waiting to Inbox. The +// reminder headers stay on the message, giving the inbox its purple-dot +// equivalent and making the due state visible without another store. +func (m Model) resurfaceDueRemindersCmd() tea.Cmd { + return func() tea.Msg { + waiting, inbox := m.cfg.Folders.Waiting, m.cfg.Folders.Inbox + if waiting == "" || inbox == "" || waiting == inbox { + return dueRemindersDoneMsg{} + } + cli := m.imapCli() + if cli == nil { + return dueRemindersDoneMsg{} + } + emails, err := cli.FetchHeaders(nil, waiting, 0) + if err != nil { + return dueRemindersDoneMsg{err: err} + } + moved := 0 + for i := range emails { + if emails[i].Reminder == nil || emails[i].Reminder.Status(time.Now()) != "due" { + continue + } + if _, err := cli.MoveMessage(nil, waiting, emails[i].UID, inbox); err != nil { + return dueRemindersDoneMsg{moved: moved, err: err} + } + moved++ + } + return dueRemindersDoneMsg{moved: moved} + } +} + // bgFetchVipFolderCmd silently fetches headers from one configured // notification folder (other than Inbox, which is handled by the regular bg // sync). The result feeds straight into the notifier — no auto-screening, @@ -2445,6 +2525,35 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil + case reminderDoneMsg: + m.loading = false + if msg.err != nil { + m.status = "Reminder error: " + msg.err.Error() + m.isError = true + return m, nil + } + m.reminderActive = false + m.state = stateInbox + m.status = fmt.Sprintf("Reminder set for %s — moved to %s.", msg.at.Local().Format("Mon 2006-01-02 15:04"), m.cfg.Folders.Waiting) + m.isError = false + return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.activeFolder())) + + case dueRemindersDoneMsg: + if msg.err != nil { + // Background reminder checks are best-effort; the next tick retries. + return m, nil + } + if msg.moved == 0 { + return m, nil + } + m.status = fmt.Sprintf("%d reminder(s) returned to Inbox.", msg.moved) + m.isError = false + if m.state == stateInbox { + m.loading = true + return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.activeFolder())) + } + return m, nil + case clipboardDoneMsg: if msg.err != nil { m.status = "Copy failed: " + msg.err.Error() @@ -2742,7 +2851,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Also poll any extra notification folders (e.g. PaperTrail, Feed) // so VIP mail screened out of Inbox by the daemon still notifies. m.bgSyncInProgress = true - cmds := []tea.Cmd{m.bgFetchInboxCmd(), m.scheduleBgSync(), m.checkOverdueScheduledCmd()} + cmds := []tea.Cmd{m.bgFetchInboxCmd(), m.resurfaceDueRemindersCmd(), m.scheduleBgSync(), m.checkOverdueScheduledCmd()} for _, f := range m.vipNotifyFolders() { cmds = append(cmds, m.bgFetchVipFolderCmd(f)) } @@ -3867,6 +3976,26 @@ func (m Model) handleChord(prefix, key string) (tea.Model, tea.Cmd) { func (m Model) updateReader(msg tea.KeyMsg) (tea.Model, tea.Cmd) { key := msg.String() + if m.reminderActive { + switch key { + case "esc": + m.reminderActive = false + return m, nil + case "enter": + at, err := schedule.ParseSendAt(m.reminderInput.Value(), time.Now()) + if err != nil { + m.reminderInput.SetValue("") + m.reminderInput.Placeholder = err.Error() + return m, nil + } + m.reminderActive = false + m.loading = true + return m, tea.Batch(m.spinner.Tick, m.setReminderCmd(m.openEmail, at)) + } + var cmd tea.Cmd + m.reminderInput, cmd = m.reminderInput.Update(msg) + return m, cmd + } // Handle reader chords if m.readerPending != "" { @@ -4022,6 +4151,7 @@ func (m Model) updateReader(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch key { case "q", "esc", "h": m.state = stateInbox + m.reminderActive = false m.readerPending = "" // Clear mark-as-read timer state when exiting reader m.markAsReadUID = 0 @@ -4049,6 +4179,16 @@ func (m Model) updateReader(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.imapCli().ResetMailboxSelection() // force fresh SELECT to see new messages m.loading = true return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.activeFolder())) + case "H": + if m.openEmail != nil { + ti := textinput.New() + ti.Placeholder = "+2h · tomorrow 09:00 · 2026-09-02 09:00" + ti.CharLimit = 40 + ti.Focus() + m.reminderInput = ti + m.reminderActive = true + } + return m, nil case "ctrl+r": if m.openEmail != nil { return m.launchReplyAllCmd() @@ -6062,8 +6202,14 @@ func (m Model) viewReader() string { } else { b.WriteString(m.reader.View()) } + if m.openEmail != nil && m.openEmail.Reminder != nil { + rem := m.openEmail.Reminder + b.WriteString("\n" + styleDate.Render(fmt.Sprintf(" Reminder: %s at %s", rem.Status(time.Now()), rem.At.Local().Format("2006-01-02 15:04")))) + } isDraft := m.openEmail != nil && m.openEmail.Folder == m.cfg.Folders.Drafts - if m.status != "" { + if m.reminderActive { + b.WriteString("\n" + styleInputLabel.Render("Remind me at:") + " " + m.reminderInput.View() + styleHelp.Render(" · enter set · esc cancel")) + } else if m.status != "" { b.WriteString("\n" + statusBar(m.status, m.isError)) } else { b.WriteString("\n" + readerHelp(isDraft, len(m.openLinks) > 0)) diff --git a/internal/ui/model_test.go b/internal/ui/model_test.go index eac2e78..f67f316 100644 --- a/internal/ui/model_test.go +++ b/internal/ui/model_test.go @@ -13,6 +13,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/sspaeti/neomd/internal/config" "github.com/sspaeti/neomd/internal/imap" + "github.com/sspaeti/neomd/internal/reminder" ) func TestMaskEmail(t *testing.T) { @@ -36,6 +37,23 @@ func TestMaskEmail(t *testing.T) { } } +func TestReminderKeyStartsPerEmailPrompt(t *testing.T) { + m := Model{openEmail: &imap.Email{UID: 7}, state: stateReading} + updated, cmd := m.updateReader(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'H'}}) + got := updated.(Model) + if !got.reminderActive || got.reminderInput.Placeholder == "" || cmd != nil { + t.Fatalf("H prompt state = active %v placeholder %q cmd %v", got.reminderActive, got.reminderInput.Placeholder, cmd) + } +} + +func TestReminderMetadataIsVisibleState(t *testing.T) { + at := time.Date(2030, time.January, 2, 3, 4, 5, 0, time.UTC) + e := imap.Email{Reminder: &reminder.Metadata{At: at, State: "scheduled"}} + if e.Reminder.Status(at.Add(time.Minute)) != "due" { + t.Fatal("scheduled reminder should become due at its timestamp") + } +} + // isURLSchemeAllowed replicates the inline URL scheme check from model.go Update(). func isURLSchemeAllowed(url string) bool { lower := strings.ToLower(url) diff --git a/internal/ui/reader.go b/internal/ui/reader.go index e7d8949..be1dec6 100644 --- a/internal/ui/reader.go +++ b/internal/ui/reader.go @@ -190,7 +190,7 @@ func calendarInviteCard(attachments []imap.Attachment) string { // readerHelp returns the one-line help string for the reader view. // When isDraft is true, "E draft" is shown so the user knows they can re-open in compose. func readerHelp(isDraft bool, hasLinks bool) string { - keys := []string{"j/k scroll", "h/q back", "r reply", "ctrl+r reply-all", "ctrl+e react", "f fwd", "e nvim"} + keys := []string{"j/k scroll", "h/q back", "H remind", "r reply", "ctrl+r reply-all", "ctrl+e react", "f fwd", "e nvim"} if isDraft { keys = append(keys, "E draft") } From cbb571b27b5291495f53fd3e767bb9c333457c73 Mon Sep 17 00:00:00 2001 From: Jon Roosevelt Date: Tue, 1 Sep 2026 10:53:47 -0400 Subject: [PATCH 02/16] no-mistakes(review): Harden reminder metadata parsing, body preservation, and screening --- CHANGELOG.md | 8 +++++++ docs/content/docs/sending.md | 5 ++-- internal/imap/client.go | 31 ++++++++++++++---------- internal/imap/client_test.go | 38 +++++++++++++++++++++++------- internal/reminder/reminder.go | 10 ++++---- internal/reminder/reminder_test.go | 15 ++++++++++++ internal/screener/classify.go | 5 ++++ internal/screener/classify_test.go | 10 ++++++++ 8 files changed, 93 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45658e4..00e10e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,14 @@ 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`. + # 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/docs/content/docs/sending.md b/docs/content/docs/sending.md index c095564..211f967 100644 --- a/docs/content/docs/sending.md +++ b/docs/content/docs/sending.md @@ -309,8 +309,9 @@ Open an email and press `H` to set a reminder, then enter a time such as `+2h`, `tomorrow 09:00`, or `2026-09-02 09:00`. The message is copied to the configured **Waiting** folder with `X-Neomd-Reminder-*` metadata and the original is moved to Trash (recoverable). No email is sent. The headless daemon -returns due reminders to Inbox; the reminder marker stays on the message so it -is clear why it resurfaced. The TUI also checks Waiting during background sync. +returns due reminders to Inbox and leaves them there even when automatic +screening is enabled; the reminder marker stays on the message so it is clear +why it resurfaced. The TUI also checks Waiting during background sync. ## Recipient Names diff --git a/internal/imap/client.go b/internal/imap/client.go index 05c1ee0..48a5972 100644 --- a/internal/imap/client.go +++ b/internal/imap/client.go @@ -296,12 +296,12 @@ func (c *Client) Ping(ctx context.Context) error { // sendAtHeaderSection requests only the X-Neomd-Send-At header field (peek, // so \Seen is untouched) — it identifies send-later queued messages so the UI // can mark them, without ever mutating the stored message. -func sendAtHeaderSection() []*imap.FetchItemBodySection { - return []*imap.FetchItemBodySection{{ +func sendAtHeaderSection() *imap.FetchItemBodySection { + return &imap.FetchItemBodySection{ Specifier: imap.PartSpecifierHeader, HeaderFields: []string{schedule.HeaderSendAt}, Peek: true, - }} + } } func reminderHeaderSection() *imap.FetchItemBodySection { @@ -312,11 +312,11 @@ func reminderHeaderSection() *imap.FetchItemBodySection { } } -func parseReminder(sections []imapclient.FetchBodySectionBuffer) *reminder.Metadata { - if len(sections) < 2 || len(sections[1].Bytes) == 0 { +func parseReminder(raw []byte) *reminder.Metadata { + if len(raw) == 0 { return nil } - metadata, err := reminder.ParseHeader(sections[1].Bytes) + metadata, err := reminder.ParseHeader(raw) if err != nil || metadata.At.IsZero() { return nil } @@ -325,11 +325,12 @@ func parseReminder(sections []imapclient.FetchBodySectionBuffer) *reminder.Metad // parseSendAtSection extracts the RFC 3339 time from a fetched // X-Neomd-Send-At header-fields section. Zero time when absent. -func parseSendAtSection(sections []imapclient.FetchBodySectionBuffer) time.Time { - if len(sections) == 0 { +func parseSendAtSection(raw []byte) time.Time { + if len(raw) == 0 { return time.Time{} } - for _, line := range strings.Split(string(sections[0].Bytes), "\r\n") { + for _, line := range strings.Split(string(raw), "\n") { + line = strings.TrimRight(line, "\r") if i := strings.IndexByte(line, ':'); i > 0 && strings.EqualFold(strings.TrimSpace(line[:i]), schedule.HeaderSendAt) { if at, err := time.Parse(time.RFC3339, strings.TrimSpace(line[i+1:])); err == nil { return at @@ -379,13 +380,15 @@ func (c *Client) FetchHeaders(ctx context.Context, folder string, n int) ([]Emai fetchSet.AddNum(uid) } + sendAtSection := sendAtHeaderSection() + reminderSection := reminderHeaderSection() msgs, err := conn.Fetch(fetchSet, &imap.FetchOptions{ UID: true, Flags: true, Envelope: true, RFC822Size: true, BodyStructure: &imap.FetchItemBodyStructure{Extended: true}, - BodySection: append(sendAtHeaderSection(), reminderHeaderSection()), + BodySection: []*imap.FetchItemBodySection{sendAtSection, reminderSection}, }).Collect() if err != nil { return fmt.Errorf("FETCH headers: %w", err) @@ -401,7 +404,7 @@ func (c *Client) FetchHeaders(ctx context.Context, folder string, n int) ([]Emai if !ok { continue } - e := Email{UID: uint32(m.UID), Folder: folder, SendAt: parseSendAtSection(m.BodySection), Reminder: parseReminder(m.BodySection)} + e := Email{UID: uint32(m.UID), Folder: folder, SendAt: parseSendAtSection(m.FindBodySection(sendAtSection)), Reminder: parseReminder(m.FindBodySection(reminderSection))} for _, f := range m.Flags { if f == imap.FlagSeen { e.Seen = true @@ -795,19 +798,21 @@ func (c *Client) FetchHeadersByUID(ctx context.Context, folder string, uids []ui for _, uid := range uids { fetchSet.AddNum(imap.UID(uid)) } + sendAtSection := sendAtHeaderSection() + reminderSection := reminderHeaderSection() msgs, err := conn.Fetch(fetchSet, &imap.FetchOptions{ UID: true, Flags: true, Envelope: true, RFC822Size: true, BodyStructure: &imap.FetchItemBodyStructure{Extended: true}, - BodySection: append(sendAtHeaderSection(), reminderHeaderSection()), + BodySection: []*imap.FetchItemBodySection{sendAtSection, reminderSection}, }).Collect() if err != nil { return fmt.Errorf("FETCH headers: %w", err) } for _, m := range msgs { - e := Email{UID: uint32(m.UID), Folder: folder, SendAt: parseSendAtSection(m.BodySection), Reminder: parseReminder(m.BodySection)} + e := Email{UID: uint32(m.UID), Folder: folder, SendAt: parseSendAtSection(m.FindBodySection(sendAtSection)), Reminder: parseReminder(m.FindBodySection(reminderSection))} for _, f := range m.Flags { if f == imap.FlagSeen { e.Seen = true diff --git a/internal/imap/client_test.go b/internal/imap/client_test.go index ab50a13..c39d880 100644 --- a/internal/imap/client_test.go +++ b/internal/imap/client_test.go @@ -16,35 +16,55 @@ import ( // UI can mark them without mutating the stored message. func TestParseSendAtSection(t *testing.T) { at := time.Date(2026, 8, 25, 9, 0, 0, 0, time.UTC) - sec := []imapclient.FetchBodySectionBuffer{{ - Bytes: []byte("X-Neomd-Send-At: " + at.Format(time.RFC3339) + "\r\n\r\n"), - }} - if got := parseSendAtSection(sec); !got.Equal(at) { + if got := parseSendAtSection([]byte("X-Neomd-Send-At: " + at.Format(time.RFC3339) + "\r\n\r\n")); !got.Equal(at) { t.Errorf("SendAt = %v, want %v", got, at) } // Regular mail (no section content) and garbage must yield zero time. if got := parseSendAtSection(nil); !got.IsZero() { t.Errorf("nil section: %v", got) } - if got := parseSendAtSection([]imapclient.FetchBodySectionBuffer{{Bytes: []byte("\r\n")}}); !got.IsZero() { + if got := parseSendAtSection([]byte("\r\n")); !got.IsZero() { t.Errorf("empty header: %v", got) } - if got := parseSendAtSection([]imapclient.FetchBodySectionBuffer{{Bytes: []byte("X-Neomd-Send-At: not-a-time\r\n")}}); !got.IsZero() { + if got := parseSendAtSection([]byte("X-Neomd-Send-At: not-a-time\r\n")); !got.IsZero() { t.Errorf("garbage time: %v", got) } } func TestParseReminderSection(t *testing.T) { - secs := []imapclient.FetchBodySectionBuffer{{}, {Bytes: []byte("X-Neomd-Reminder-At: 2030-01-02T03:04:05Z\r\nX-Neomd-Reminder-State: due\r\n")}} - got := parseReminder(secs) + got := parseReminder([]byte("X-Neomd-Reminder-At: 2030-01-02T03:04:05Z\r\nX-Neomd-Reminder-State: due\r\n")) if got == nil || got.State != "due" || !got.At.Equal(time.Date(2030, time.January, 2, 3, 4, 5, 0, time.UTC)) { t.Fatalf("reminder = %#v, want due metadata", got) } - if parseReminder(nil) != nil || parseReminder([]imapclient.FetchBodySectionBuffer{{Bytes: []byte("Subject: ordinary\r\n")}}) != nil { + if parseReminder(nil) != nil || parseReminder([]byte("Subject: ordinary\r\n")) != nil { t.Fatal("ordinary or incomplete headers should not become reminders") } } +func TestParseHeaderSectionsByDescriptor(t *testing.T) { + sendAtSection := sendAtHeaderSection() + reminderSection := reminderHeaderSection() + sendAtResponse := *sendAtSection + reminderResponse := *reminderSection + msg := &imapclient.FetchMessageBuffer{BodySection: []imapclient.FetchBodySectionBuffer{ + { + Section: &reminderResponse, + Bytes: []byte("X-Neomd-Reminder-At: 2030-01-02T03:04:05Z\r\nX-Neomd-Reminder-State: due\r\n"), + }, + { + Section: &sendAtResponse, + Bytes: []byte("X-Neomd-Send-At: 2030-01-03T03:04:05Z\r\n"), + }, + }} + + if got := parseSendAtSection(msg.FindBodySection(sendAtSection)); !got.Equal(time.Date(2030, time.January, 3, 3, 4, 5, 0, time.UTC)) { + t.Errorf("SendAt = %v, want descriptor-matched value", got) + } + if got := parseReminder(msg.FindBodySection(reminderSection)); got == nil || got.State != "due" { + t.Errorf("Reminder = %#v, want descriptor-matched value", got) + } +} + func TestBuildSearchCriteria(t *testing.T) { tests := []struct { name string diff --git a/internal/reminder/reminder.go b/internal/reminder/reminder.go index d721dff..6f5c5f9 100644 --- a/internal/reminder/reminder.go +++ b/internal/reminder/reminder.go @@ -77,11 +77,11 @@ func ClearHeaders(raw []byte) ([]byte, error) { } func rewrite(raw []byte, values map[string]string) ([]byte, error) { - sep, sepLen := []byte("\r\n\r\n"), 4 - idx := bytes.Index(raw, sep) - if idx < 0 { - sep, sepLen = []byte("\n\n"), 2 - idx = bytes.Index(raw, sep) + crlfIdx := bytes.Index(raw, []byte("\r\n\r\n")) + lfIdx := bytes.Index(raw, []byte("\n\n")) + idx, sepLen := crlfIdx, 4 + if idx < 0 || (lfIdx >= 0 && lfIdx < idx) { + idx, sepLen = lfIdx, 2 } if idx < 0 { return nil, fmt.Errorf("message has no RFC header/body separator") diff --git a/internal/reminder/reminder_test.go b/internal/reminder/reminder_test.go index 002f15c..305a6a8 100644 --- a/internal/reminder/reminder_test.go +++ b/internal/reminder/reminder_test.go @@ -28,3 +28,18 @@ func TestHeadersPreserveBodyAndReplaceFoldedValues(t *testing.T) { t.Fatalf("cleared=%q err=%v", cleared, err) } } + +func TestHeadersPreserveBodyWithMixedLineEndings(t *testing.T) { + raw := []byte("Subject: x\n\nbody-one\nbody-two\r\n\r\nbody-tail") + wantBody := []byte("body-one\nbody-two\r\n\r\nbody-tail") + at := time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC) + + updated, err := SetHeaders(raw, at, "id") + if err != nil || !bytes.HasSuffix(updated, wantBody) { + t.Fatalf("updated body=%q err=%v", updated, err) + } + cleared, err := ClearHeaders(updated) + if err != nil || !bytes.HasSuffix(cleared, wantBody) { + t.Fatalf("cleared body=%q err=%v", cleared, err) + } +} diff --git a/internal/screener/classify.go b/internal/screener/classify.go index 962f91a..821e5b2 100644 --- a/internal/screener/classify.go +++ b/internal/screener/classify.go @@ -2,6 +2,7 @@ package screener import ( "fmt" + "time" "github.com/sspaeti/neomd/internal/config" "github.com/sspaeti/neomd/internal/imap" @@ -23,9 +24,13 @@ func ClassifyForScreen(screener *Screener, emails []imap.Email, folderCfg config } inboxFolder := folderCfg.Inbox + now := time.Now() var moves []ScreenMove for i := range emails { e := &emails[i] + if e.Reminder != nil && e.Reminder.Status(now) == "due" { + continue + } cat := screener.Classify(e.From) var dst string switch cat { diff --git a/internal/screener/classify_test.go b/internal/screener/classify_test.go index a80ff6d..3162652 100644 --- a/internal/screener/classify_test.go +++ b/internal/screener/classify_test.go @@ -4,9 +4,11 @@ import ( "os" "path/filepath" "testing" + "time" "github.com/sspaeti/neomd/internal/config" "github.com/sspaeti/neomd/internal/imap" + "github.com/sspaeti/neomd/internal/reminder" ) func TestClassifyForScreen(t *testing.T) { @@ -102,6 +104,14 @@ func TestClassifyForScreen(t *testing.T) { expectedMoves: 0, expectedDsts: []string{}, }, + { + name: "due reminder stays in inbox", + emails: []imap.Email{ + {UID: 12, From: "spam@example.com", Subject: "Reminder", Reminder: &reminder.Metadata{At: time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC), State: "scheduled"}}, + }, + expectedMoves: 0, + expectedDsts: []string{}, + }, { name: "unknown sender (moves to ToScreen)", emails: []imap.Email{ From 969d6c5b1e15668fe0b47dcd7b97c16d521e2eca Mon Sep 17 00:00:00 2001 From: Jon Roosevelt Date: Tue, 1 Sep 2026 11:59:41 -0400 Subject: [PATCH 03/16] no-mistakes(review): Harden reminder parking and screen-all filtering --- CHANGELOG.md | 7 + internal/imap/client.go | 242 ++++++++++++++++++++++++++++++++++- internal/imap/client_test.go | 145 +++++++++++++++++++++ internal/ui/model.go | 67 +++------- internal/ui/model_test.go | 20 +++ 5 files changed, 430 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00e10e5..0d8495a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,13 @@ `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/internal/imap/client.go b/internal/imap/client.go index 48a5972..48003ab 100644 --- a/internal/imap/client.go +++ b/internal/imap/client.go @@ -1049,6 +1049,222 @@ func (c *Client) MoveMessage(ctx context.Context, src string, uid uint32, dst st return destUID, err } +func reminderID(source Email) string { + if source.Reminder != nil && strings.TrimSpace(source.Reminder.ID) != "" { + return strings.TrimSpace(source.Reminder.ID) + } + if strings.TrimSpace(source.MessageID) != "" { + return strings.TrimSpace(source.MessageID) + } + return fmt.Sprintf("neomd:%s:%d", source.Folder, source.UID) +} + +func reminderIDEqual(a, b string) bool { + return strings.TrimSpace(a) != "" && strings.EqualFold(strings.TrimSpace(a), strings.TrimSpace(b)) +} + +func reminderAddress(field string) string { + addrs := SplitAddrs(field) + if len(addrs) == 0 { + return "" + } + return addrs[0] +} + +func sameReminderEmail(source, candidate Email, id string) bool { + if candidate.Reminder != nil && reminderIDEqual(candidate.Reminder.ID, id) { + return true + } + if source.MessageID != "" { + return reminderIDEqual(source.MessageID, candidate.MessageID) + } + if candidate.MessageID != "" || reminderAddress(source.From) == "" || reminderAddress(candidate.From) == "" { + return false + } + if !strings.EqualFold(reminderAddress(source.From), reminderAddress(candidate.From)) || source.Subject != candidate.Subject { + return false + } + if !source.Date.IsZero() && !candidate.Date.IsZero() && !source.Date.Equal(candidate.Date) { + return false + } + if source.Size != 0 && candidate.Size != 0 && source.Size != candidate.Size { + return false + } + return true +} + +func reminderSearchFolders(folders []string, source, waiting, trash string) []string { + all := make([]string, 0, len(folders)+3) + all = append(all, folders...) + all = append(all, source, waiting, trash) + seen := make(map[string]struct{}, len(all)) + unique := make([]string, 0, len(all)) + for _, folder := range all { + if folder == "" { + continue + } + if _, ok := seen[folder]; ok { + continue + } + seen[folder] = struct{}{} + unique = append(unique, folder) + } + return unique +} + +func (c *Client) reminderCopies(ctx context.Context, source Email, id string, folders []string) ([]Email, error) { + var matches []Email + var scanErrs []error + for _, folder := range folders { + emails, err := c.FetchHeaders(ctx, folder, 0) + if err != nil { + var imapErr *imap.Error + if errors.As(err, &imapErr) && imapErr.Code == imap.ResponseCodeNonExistent { + continue + } + scanErrs = append(scanErrs, fmt.Errorf("%s: %w", folder, err)) + continue + } + for _, candidate := range emails { + if sameReminderEmail(source, candidate, id) { + matches = append(matches, candidate) + } + } + } + if len(scanErrs) > 0 { + return matches, fmt.Errorf("scan reminder folders: %w", errors.Join(scanErrs...)) + } + return matches, nil +} + +func (c *Client) reminderCopyInFolder(ctx context.Context, source Email, id, folder string, hint uint32) (Email, bool, error) { + if hint != 0 { + if emails, err := c.FetchHeadersByUID(ctx, folder, []uint32{hint}); err == nil { + for _, candidate := range emails { + if sameReminderEmail(source, candidate, id) { + return candidate, true, nil + } + } + } + } + emails, err := c.FetchHeaders(ctx, folder, 0) + if err != nil { + return Email{}, false, err + } + for _, candidate := range emails { + if sameReminderEmail(source, candidate, id) { + return candidate, true, nil + } + } + return Email{}, false, nil +} + +func (c *Client) reminderSourceExists(ctx context.Context, source Email) (bool, error) { + emails, err := c.FetchHeadersByUID(ctx, source.Folder, []uint32{source.UID}) + if err != nil { + return false, err + } + return len(emails) > 0, nil +} + +func (c *Client) claimReminderSource(ctx context.Context, source Email, folders []string, waiting, trash, id string) (Email, bool, error) { + searchFolders := reminderSearchFolders(folders, source.Folder, waiting, trash) + var lastErr error + for attempt := 0; attempt < 3; attempt++ { + sourceExists, checkErr := c.reminderSourceExists(ctx, source) + if checkErr != nil { + lastErr = fmt.Errorf("check source message uid=%d: %w", source.UID, checkErr) + } else if sourceExists { + destUID, moveErr := c.MoveMessage(ctx, source.Folder, source.UID, trash) + if moveErr == nil { + copy, found, findErr := c.reminderCopyInFolder(ctx, source, id, trash, destUID) + if findErr == nil && found { + return copy, false, nil + } + if findErr != nil { + lastErr = findErr + } else { + lastErr = fmt.Errorf("moved message uid=%d to %s but could not find it there", source.UID, trash) + } + continue + } + lastErr = fmt.Errorf("move message uid=%d to %s: %w", source.UID, trash, moveErr) + } else { + lastErr = fmt.Errorf("source message uid=%d is no longer in %s", source.UID, source.Folder) + } + + copies, scanErr := c.reminderCopies(ctx, source, id, searchFolders) + if scanErr != nil { + lastErr = scanErr + continue + } + waitingFound := false + changed := false + moveFailed := false + for _, copy := range copies { + switch copy.Folder { + case waiting: + waitingFound = true + case trash: + return copy, false, nil + default: + if _, moveErr := c.MoveMessage(ctx, copy.Folder, copy.UID, trash); moveErr != nil { + lastErr = fmt.Errorf("reconcile message uid=%d from %s: %w", copy.UID, copy.Folder, moveErr) + moveFailed = true + continue + } + changed = true + } + } + if waitingFound && !changed && !moveFailed { + return Email{}, true, nil + } + } + if lastErr == nil { + lastErr = fmt.Errorf("source message could not be located") + } + return Email{}, false, fmt.Errorf("claim reminder source: %w", lastErr) +} + +func (c *Client) ParkReminder(ctx context.Context, source Email, folders []string, waiting, trash string, at time.Time) error { + if ctx == nil { + ctx = context.Background() + } + if source.Folder == "" || source.UID == 0 { + return fmt.Errorf("reminder source is missing its folder or UID") + } + if waiting == "" || trash == "" || waiting == trash { + return fmt.Errorf("reminders require distinct Waiting and Trash folders") + } + if source.Folder == trash { + return fmt.Errorf("cannot remind an email already in Trash") + } + if at.IsZero() { + return fmt.Errorf("reminder time is required") + } + + id := reminderID(source) + claimed, alreadyParked, err := c.claimReminderSource(ctx, source, folders, waiting, trash, id) + if err != nil { + return err + } + if alreadyParked { + return nil + } + raw, err := c.FetchRaw(ctx, claimed.Folder, claimed.UID) + if err != nil { + return fmt.Errorf("fetch claimed reminder: %w", err) + } + updated, err := reminder.SetHeaders(raw, at, id) + if err != nil { + return fmt.Errorf("prepare reminder: %w", err) + } + if err := c.SaveReminder(ctx, waiting, updated); err != nil { + return fmt.Errorf("save reminder: %w", err) + } + return nil +} + // EnsureFolders creates and subscribes any folders in the list that do not // yet exist on the server. Already-existing folders are silently skipped. // Returns the names of folders that were actually created. @@ -1238,13 +1454,35 @@ func (c *Client) SaveDraft(ctx context.Context, folder string, raw []byte) error }) } -// SaveReminder APPENDs a parked email to the reminder folder. Reminders are -// ordinary IMAP messages; no SMTP or outbound delivery is involved. +// SaveReminder idempotently APPENDs a parked email to the reminder folder. func (c *Client) SaveReminder(ctx context.Context, folder string, raw []byte) error { if ctx == nil { ctx = context.Background() } + metadata, err := reminder.ParseHeader(raw) + if err != nil { + return fmt.Errorf("parse reminder before save: %w", err) + } return c.withConn(ctx, func(conn *imapclient.Client) error { + if err := c.selectMailbox(folder); err != nil { + return err + } + if metadata.ID != "" { + searchData, err := conn.UIDSearch(&imap.SearchCriteria{ + Header: []imap.SearchCriteriaHeaderField{{Key: reminder.IDHeader, Value: metadata.ID}}, + }, nil).Wait() + if err != nil { + return fmt.Errorf("SEARCH existing reminder: %w", err) + } + if searchData != nil { + if uidSet, ok := searchData.All.(imap.UIDSet); ok { + uids, _ := uidSet.Nums() + if len(uids) > 0 { + return nil + } + } + } + } opts := &imap.AppendOptions{Flags: []imap.Flag{imap.FlagSeen}, Time: time.Now()} cmd := conn.Append(folder, int64(len(raw)), opts) if _, err := cmd.Write(raw); err != nil { diff --git a/internal/imap/client_test.go b/internal/imap/client_test.go index c39d880..d1824a0 100644 --- a/internal/imap/client_test.go +++ b/internal/imap/client_test.go @@ -1,16 +1,98 @@ package imap import ( + "bytes" "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" "encoding/base64" + "math/big" + "net" "strings" "testing" "time" imap "github.com/emersion/go-imap/v2" "github.com/emersion/go-imap/v2/imapclient" + "github.com/emersion/go-imap/v2/imapserver" + "github.com/emersion/go-imap/v2/imapserver/imapmemserver" + "github.com/sspaeti/neomd/internal/reminder" ) +type testLiteralReader struct { + *bytes.Reader + size int64 +} + +func (r *testLiteralReader) Size() int64 { return r.size } + +func startMemoryIMAP(t *testing.T, folders ...string) (*Client, *imapmemserver.User) { + t.Helper() + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + template := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "test-imap"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + } + der, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey) + if err != nil { + t.Fatal(err) + } + listener, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{ + Certificates: []tls.Certificate{{Certificate: [][]byte{der}, PrivateKey: privateKey}}, + }) + if err != nil { + t.Fatal(err) + } + + memServer := imapmemserver.New() + user := imapmemserver.NewUser("user", "password") + for _, folder := range folders { + if err := user.Create(folder, nil); err != nil { + t.Fatal(err) + } + } + memServer.AddUser(user) + server := imapserver.New(&imapserver.Options{ + NewSession: func(_ *imapserver.Conn) (imapserver.Session, *imapserver.GreetingData, error) { + return memServer.NewSession(), nil, nil + }, + Caps: imap.CapSet{ + imap.CapIMAP4rev1: {}, + imap.CapIMAP4rev2: {}, + imap.CapMove: {}, + }, + }) + go func() { _ = server.Serve(listener) }() + + _, port, err := net.SplitHostPort(listener.Addr().String()) + if err != nil { + _ = server.Close() + t.Fatal(err) + } + client := New(Config{ + Host: "127.0.0.1", + Port: port, + User: "user", + Password: "password", + TLS: true, + }) + t.Cleanup(func() { + client.Close() + _ = server.Close() + }) + return client, user +} + // Send-later queued messages are identified in header fetches by their // X-Neomd-Send-At header (fetched as a peek'd header-fields section) so the // UI can mark them without mutating the stored message. @@ -65,6 +147,69 @@ func TestParseHeaderSectionsByDescriptor(t *testing.T) { } } +func TestParkReminderReconcilesMovedSourceAndIsIdempotent(t *testing.T) { + client, user := startMemoryIMAP(t, "INBOX", "Feed", "Waiting", "Trash") + raw := []byte("From: sender@example.com\r\nTo: me@example.com\r\nSubject: meeting\r\nMessage-ID: \r\n\r\nbody\r\n") + data, err := user.Append("INBOX", &testLiteralReader{Reader: bytes.NewReader(raw), size: int64(len(raw))}, &imap.AppendOptions{}) + if err != nil { + t.Fatal(err) + } + source := Email{ + UID: uint32(data.UID), + Folder: "INBOX", + From: "sender@example.com", + Subject: "meeting", + MessageID: "reminder@example.com", + Size: uint32(len(raw)), + } + if _, err := client.MoveMessage(context.Background(), "INBOX", source.UID, "Feed"); err != nil { + t.Fatal(err) + } + + at := time.Date(2030, time.January, 2, 3, 4, 5, 0, time.UTC) + folders := []string{"INBOX", "Feed", "Waiting", "Trash"} + if err := client.ParkReminder(context.Background(), source, folders, "Waiting", "Trash", at); err != nil { + t.Fatalf("ParkReminder after source move: %v", err) + } + waiting, err := client.FetchHeaders(context.Background(), "Waiting", 0) + if err != nil { + t.Fatal(err) + } + trash, err := client.FetchHeaders(context.Background(), "Trash", 0) + if err != nil { + t.Fatal(err) + } + if len(waiting) != 1 || len(trash) != 1 { + t.Fatalf("after first park: Waiting=%d Trash=%d, want one in each", len(waiting), len(trash)) + } + waitingRaw, err := client.FetchRaw(context.Background(), "Waiting", waiting[0].UID) + if err != nil { + t.Fatal(err) + } + metadata, err := reminder.ParseHeader(waitingRaw) + if err != nil || !metadata.At.Equal(at) || metadata.ID != source.MessageID { + t.Fatalf("waiting metadata=%#v err=%v", metadata, err) + } + if !bytes.HasSuffix(waitingRaw, []byte("body\r\n")) { + t.Fatalf("waiting body changed: %q", waitingRaw) + } + + if err := client.ParkReminder(context.Background(), source, folders, "Waiting", "Trash", at.Add(time.Hour)); err != nil { + t.Fatalf("repeating ParkReminder: %v", err) + } + waiting, err = client.FetchHeaders(context.Background(), "Waiting", 0) + if err != nil { + t.Fatal(err) + } + trash, err = client.FetchHeaders(context.Background(), "Trash", 0) + if err != nil { + t.Fatal(err) + } + if len(waiting) != 1 || len(trash) != 1 { + t.Fatalf("after repeated park: Waiting=%d Trash=%d, want one in each", len(waiting), len(trash)) + } +} + func TestBuildSearchCriteria(t *testing.T) { tests := []struct { name string diff --git a/internal/ui/model.go b/internal/ui/model.go index 5835b51..ea488e9 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -29,7 +29,6 @@ import ( "github.com/sspaeti/neomd/internal/imap" "github.com/sspaeti/neomd/internal/listmonk" "github.com/sspaeti/neomd/internal/notify" - "github.com/sspaeti/neomd/internal/reminder" "github.com/sspaeti/neomd/internal/render" "github.com/sspaeti/neomd/internal/schedule" "github.com/sspaeti/neomd/internal/screener" @@ -1970,43 +1969,34 @@ func (m Model) bgFetchInboxCmd() tea.Cmd { } } -// setReminderCmd stores one copy in Waiting only after the complete message -// has been fetched and tagged. The source is removed last, so a failed append -// cannot lose the email and no SMTP code is reachable from this path. +// setReminderCmd parks the selected message without reaching the SMTP path. func (m Model) setReminderCmd(e *imap.Email, at time.Time) tea.Cmd { + var source imap.Email + if e != nil { + source = *e + } + waiting, trash := m.cfg.Folders.Waiting, m.cfg.Folders.Trash + folders := reminderFolders(m.cfg.Folders) return func() tea.Msg { - if e == nil || m.cfg.Folders.Waiting == "" { - return reminderDoneMsg{err: fmt.Errorf("reminders require a configured waiting folder")} - } - if e.Folder == m.cfg.Folders.Trash || m.cfg.Folders.Trash == "" { - return reminderDoneMsg{err: fmt.Errorf("reminders require a recoverable Trash folder")} - } cli := m.imapCli() if cli == nil { return reminderDoneMsg{err: fmt.Errorf("reminders require an IMAP-enabled account")} } - raw, err := cli.FetchRaw(nil, e.Folder, e.UID) - if err != nil { - return reminderDoneMsg{err: fmt.Errorf("fetch email for reminder: %w", err)} - } - id := e.MessageID - if id == "" { - id = fmt.Sprintf("neomd:%s:%d", e.Folder, e.UID) - } - updated, err := reminder.SetHeaders(raw, at, id) - if err != nil { - return reminderDoneMsg{err: fmt.Errorf("prepare reminder: %w", err)} - } - if err := cli.SaveReminder(nil, m.cfg.Folders.Waiting, updated); err != nil { - return reminderDoneMsg{err: fmt.Errorf("save reminder: %w", err)} - } - if _, err := cli.MoveMessage(nil, e.Folder, e.UID, m.cfg.Folders.Trash); err != nil { - return reminderDoneMsg{at: at, err: fmt.Errorf("reminder saved, but original could not be moved to Trash: %w", err)} + if err := cli.ParkReminder(nil, source, folders, waiting, trash, at); err != nil { + return reminderDoneMsg{err: err} } return reminderDoneMsg{at: at} } } +func reminderFolders(fc config.FoldersConfig) []string { + return []string{ + fc.Inbox, fc.ToScreen, fc.Feed, fc.PaperTrail, fc.ScreenedOut, fc.Spam, + fc.Archive, fc.Work, fc.Someday, fc.Scheduled, fc.Sent, fc.Drafts, + fc.Waiting, fc.Trash, + } +} + // resurfaceDueRemindersCmd returns due messages from Waiting to Inbox. The // reminder headers stay on the message, giving the inbox its purple-dot // equivalent and making the due state visible without another store. @@ -2759,28 +2749,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(m.spinner.Tick, m.deepScreenClassifyCmd(msg.emails, msg.remaining, msg.total)) } // All batches done — classify in-memory (O(1) map lookups). - inboxFolder := m.cfg.Folders.Inbox - var moves []autoScreenMove - for i := range msg.emails { - e := &msg.emails[i] - cat := m.screener.Classify(e.From) - var dst string - switch cat { - case screener.CategorySpam: - dst = m.cfg.Folders.Spam - case screener.CategoryScreenedOut: - dst = m.cfg.Folders.ScreenedOut - case screener.CategoryFeed: - dst = m.cfg.Folders.Feed - case screener.CategoryPaperTrail: - dst = m.cfg.Folders.PaperTrail - case screener.CategoryToScreen: - dst = m.cfg.Folders.ToScreen - } - if dst != "" && dst != inboxFolder { - moves = append(moves, autoScreenMove{email: e, dst: dst}) - } - } + moves := m.classifyForScreen(msg.emails) m.loading = false if len(moves) == 0 { m.status = fmt.Sprintf("Screen-all: all %d inbox emails already classified.", msg.total) diff --git a/internal/ui/model_test.go b/internal/ui/model_test.go index f67f316..93f3d43 100644 --- a/internal/ui/model_test.go +++ b/internal/ui/model_test.go @@ -14,6 +14,7 @@ import ( "github.com/sspaeti/neomd/internal/config" "github.com/sspaeti/neomd/internal/imap" "github.com/sspaeti/neomd/internal/reminder" + "github.com/sspaeti/neomd/internal/screener" ) func TestMaskEmail(t *testing.T) { @@ -54,6 +55,25 @@ func TestReminderMetadataIsVisibleState(t *testing.T) { } } +func TestDeepScreenSkipsDueReminder(t *testing.T) { + m := Model{ + cfg: &config.Config{Folders: config.FoldersConfig{Inbox: "INBOX", ToScreen: "ToScreen"}}, + screener: &screener.Screener{}, + } + updated, _ := m.Update(deepScreenBatchMsg{ + emails: []imap.Email{{ + UID: 9, + From: "unknown@example.com", + Reminder: &reminder.Metadata{At: time.Now().Add(-time.Minute), State: "scheduled"}, + }}, + total: 1, + }) + got := updated.(Model) + if len(got.pendingMoves) != 0 { + t.Fatalf("screen-all planned %d move(s) for a due reminder", len(got.pendingMoves)) + } +} + // isURLSchemeAllowed replicates the inline URL scheme check from model.go Update(). func isURLSchemeAllowed(url string) bool { lower := strings.ToLower(url) From 29b646a16d81da8af2e837d24fc4640f8bd5c6e8 Mon Sep 17 00:00:00 2001 From: Jon Roosevelt Date: Tue, 1 Sep 2026 23:23:04 -0400 Subject: [PATCH 04/16] fix: harden per-email reminders --- CHANGELOG.md | 9 +++++ internal/imap/client.go | 53 +++++++++++++++++++---------- internal/imap/client_test.go | 54 ++++++++++++++++++++++++++++-- internal/reminder/reminder.go | 6 +++- internal/reminder/reminder_test.go | 6 ++++ internal/screener/classify.go | 4 ++- internal/screener/classify_test.go | 12 +++++-- internal/ui/model.go | 5 +++ internal/ui/model_test.go | 2 +- 9 files changed, 126 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d8495a..5a0a71b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +# 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, diff --git a/internal/imap/client.go b/internal/imap/client.go index 48003ab..4e1af54 100644 --- a/internal/imap/client.go +++ b/internal/imap/client.go @@ -1198,27 +1198,41 @@ func (c *Client) claimReminderSource(ctx context.Context, source Email, folders lastErr = scanErr continue } - waitingFound := false - changed := false - moveFailed := false - for _, copy := range copies { - switch copy.Folder { - case waiting: - waitingFound = true - case trash: - return copy, false, nil - default: - if _, moveErr := c.MoveMessage(ctx, copy.Folder, copy.UID, trash); moveErr != nil { - lastErr = fmt.Errorf("reconcile message uid=%d from %s: %w", copy.UID, copy.Folder, moveErr) - moveFailed = true - continue + if len(copies) == 0 { + continue + } + if len(copies) > 1 { + waitingReminders, trashCopies := 0, 0 + owned := true + for _, copy := range copies { + switch { + case copy.Folder == waiting && copy.Reminder != nil && reminderIDEqual(copy.Reminder.ID, id): + waitingReminders++ + case copy.Folder == trash: + trashCopies++ + default: + owned = false } - changed = true } + if owned && waitingReminders == 1 && trashCopies <= 1 { + return Email{}, true, nil + } + // Same-Message-ID copies are not enough to identify the source. Do + // not move any of them: Archive/Sent/Drafts may contain legitimate + // copies of the message. + return Email{}, false, fmt.Errorf("claim reminder source: ambiguous %d matching copies", len(copies)) } - if waitingFound && !changed && !moveFailed { + copy := copies[0] + if copy.Folder == waiting && copy.Reminder != nil && reminderIDEqual(copy.Reminder.ID, id) { return Email{}, true, nil } + if copy.Folder == trash { + return copy, false, nil + } + if _, moveErr := c.MoveMessage(ctx, copy.Folder, copy.UID, trash); moveErr != nil { + lastErr = fmt.Errorf("reconcile message uid=%d from %s: %w", copy.UID, copy.Folder, moveErr) + continue + } } if lastErr == nil { lastErr = fmt.Errorf("source message could not be located") @@ -1233,12 +1247,15 @@ func (c *Client) ParkReminder(ctx context.Context, source Email, folders []strin if source.Folder == "" || source.UID == 0 { return fmt.Errorf("reminder source is missing its folder or UID") } - if waiting == "" || trash == "" || waiting == trash { + if waiting == "" || trash == "" || strings.EqualFold(waiting, trash) { return fmt.Errorf("reminders require distinct Waiting and Trash folders") } - if source.Folder == trash { + if strings.EqualFold(source.Folder, trash) { return fmt.Errorf("cannot remind an email already in Trash") } + if strings.EqualFold(source.Folder, waiting) { + return fmt.Errorf("reminder source must be outside Waiting") + } if at.IsZero() { return fmt.Errorf("reminder time is required") } diff --git a/internal/imap/client_test.go b/internal/imap/client_test.go index d1824a0..d7eb925 100644 --- a/internal/imap/client_test.go +++ b/internal/imap/client_test.go @@ -114,7 +114,7 @@ func TestParseSendAtSection(t *testing.T) { } func TestParseReminderSection(t *testing.T) { - got := parseReminder([]byte("X-Neomd-Reminder-At: 2030-01-02T03:04:05Z\r\nX-Neomd-Reminder-State: due\r\n")) + got := parseReminder([]byte("X-Neomd-Reminder-At: 2030-01-02T03:04:05Z\r\nX-Neomd-Reminder-State: due\r\nX-Neomd-Reminder-ID: id-1\r\n")) if got == nil || got.State != "due" || !got.At.Equal(time.Date(2030, time.January, 2, 3, 4, 5, 0, time.UTC)) { t.Fatalf("reminder = %#v, want due metadata", got) } @@ -123,6 +123,20 @@ func TestParseReminderSection(t *testing.T) { } } +func TestParseReminderRejectsUnidentifiedHeader(t *testing.T) { + if got := parseReminder([]byte("X-Neomd-Reminder-At: 2030-01-02T03:04:05Z\r\n")); got != nil { + t.Fatalf("unidentified reminder = %#v, want nil", got) + } +} + +func TestParkReminderRejectsWaitingSource(t *testing.T) { + client := &Client{} + err := client.ParkReminder(context.Background(), Email{Folder: "INBOX", UID: 1}, []string{"INBOX", "Waiting", "Trash"}, "INBOX", "Trash", time.Now().Add(time.Hour)) + if err == nil { + t.Fatal("expected Waiting=Inbox to be rejected before IMAP access") + } +} + func TestParseHeaderSectionsByDescriptor(t *testing.T) { sendAtSection := sendAtHeaderSection() reminderSection := reminderHeaderSection() @@ -131,7 +145,7 @@ func TestParseHeaderSectionsByDescriptor(t *testing.T) { msg := &imapclient.FetchMessageBuffer{BodySection: []imapclient.FetchBodySectionBuffer{ { Section: &reminderResponse, - Bytes: []byte("X-Neomd-Reminder-At: 2030-01-02T03:04:05Z\r\nX-Neomd-Reminder-State: due\r\n"), + Bytes: []byte("X-Neomd-Reminder-At: 2030-01-02T03:04:05Z\r\nX-Neomd-Reminder-State: due\r\nX-Neomd-Reminder-ID: id-1\r\n"), }, { Section: &sendAtResponse, @@ -210,6 +224,42 @@ func TestParkReminderReconcilesMovedSourceAndIsIdempotent(t *testing.T) { } } +func TestParkReminderRejectsAmbiguousCopiesWithoutMovingThem(t *testing.T) { + client, user := startMemoryIMAP(t, "INBOX", "Archive", "Sent", "Waiting", "Trash") + raw := []byte("From: sender@example.com\r\nTo: me@example.com\r\nSubject: meeting\r\nMessage-ID: \r\n\r\nbody\r\n") + for _, folder := range []string{"Archive", "Sent"} { + if _, err := user.Append(folder, &testLiteralReader{Reader: bytes.NewReader(raw), size: int64(len(raw))}, &imap.AppendOptions{}); err != nil { + t.Fatal(err) + } + } + + source := Email{ + UID: 999, + Folder: "INBOX", + From: "sender@example.com", + Subject: "meeting", + MessageID: "ambiguous@example.com", + Size: uint32(len(raw)), + } + err := client.ParkReminder(context.Background(), source, []string{"INBOX", "Archive", "Sent", "Waiting", "Trash"}, "Waiting", "Trash", time.Now().Add(time.Hour)) + if err == nil || !strings.Contains(err.Error(), "ambiguous") { + t.Fatalf("ParkReminder error = %v, want ambiguity error", err) + } + for _, folder := range []string{"Archive", "Sent", "Waiting", "Trash"} { + emails, fetchErr := client.FetchHeaders(context.Background(), folder, 0) + if fetchErr != nil { + t.Fatal(fetchErr) + } + want := 1 + if folder == "Waiting" || folder == "Trash" { + want = 0 + } + if len(emails) != want { + t.Errorf("%s has %d messages, want %d", folder, len(emails), want) + } + } +} + func TestBuildSearchCriteria(t *testing.T) { tests := []struct { name string diff --git a/internal/reminder/reminder.go b/internal/reminder/reminder.go index 6f5c5f9..964849c 100644 --- a/internal/reminder/reminder.go +++ b/internal/reminder/reminder.go @@ -35,6 +35,10 @@ func ParseHeader(raw []byte) (Metadata, error) { if err != nil { return Metadata{}, fmt.Errorf("parse %s: %w", AtHeader, err) } + id := strings.TrimSpace(msg.Header.Get(IDHeader)) + if id == "" { + return Metadata{}, fmt.Errorf("%s is required", IDHeader) + } state := strings.ToLower(strings.TrimSpace(msg.Header.Get(StateHeader))) if state == "" { state = "scheduled" @@ -42,7 +46,7 @@ func ParseHeader(raw []byte) (Metadata, error) { if state != "scheduled" && state != "due" { return Metadata{}, fmt.Errorf("invalid reminder state %q", state) } - return Metadata{At: at.UTC(), State: state, ID: strings.TrimSpace(msg.Header.Get(IDHeader))}, nil + return Metadata{At: at.UTC(), State: state, ID: id}, nil } func (m Metadata) Status(now time.Time) string { diff --git a/internal/reminder/reminder_test.go b/internal/reminder/reminder_test.go index 305a6a8..42ee944 100644 --- a/internal/reminder/reminder_test.go +++ b/internal/reminder/reminder_test.go @@ -17,6 +17,12 @@ func TestParseHeaderAndStatus(t *testing.T) { } } +func TestParseHeaderRequiresIdentity(t *testing.T) { + if _, err := ParseHeader([]byte("X-Neomd-Reminder-At: 2030-01-02T03:04:05Z\r\n\r\n")); err == nil { + t.Fatal("reminder without durable identity should be rejected") + } +} + func TestHeadersPreserveBodyAndReplaceFoldedValues(t *testing.T) { raw := []byte("Subject: test\r\nX-Neomd-Reminder-At: old\r\n folded\r\nX-Unrelated: yes\r\n\r\nbody\r\n") updated, err := SetHeaders(raw, time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC), "id") diff --git a/internal/screener/classify.go b/internal/screener/classify.go index 821e5b2..f1da8bd 100644 --- a/internal/screener/classify.go +++ b/internal/screener/classify.go @@ -28,7 +28,9 @@ func ClassifyForScreen(screener *Screener, emails []imap.Email, folderCfg config var moves []ScreenMove for i := range emails { e := &emails[i] - if e.Reminder != nil && e.Reminder.Status(now) == "due" { + // Only reminders with our durable identity may bypass re-screening. + // Sender-controlled timestamps without an ID must be treated as mail. + if e.Reminder != nil && e.Reminder.ID != "" && e.Reminder.Status(now) == "due" { continue } cat := screener.Classify(e.From) diff --git a/internal/screener/classify_test.go b/internal/screener/classify_test.go index 3162652..40e51a4 100644 --- a/internal/screener/classify_test.go +++ b/internal/screener/classify_test.go @@ -105,13 +105,21 @@ func TestClassifyForScreen(t *testing.T) { expectedDsts: []string{}, }, { - name: "due reminder stays in inbox", + name: "trusted due reminder stays in inbox", emails: []imap.Email{ - {UID: 12, From: "spam@example.com", Subject: "Reminder", Reminder: &reminder.Metadata{At: time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC), State: "scheduled"}}, + {UID: 12, From: "spam@example.com", Subject: "Reminder", Reminder: &reminder.Metadata{At: time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC), State: "scheduled", ID: "id-12"}}, }, expectedMoves: 0, expectedDsts: []string{}, }, + { + name: "unidentified due reminder is screened", + emails: []imap.Email{ + {UID: 13, From: "spam@example.com", Subject: "Spoofed reminder", Reminder: &reminder.Metadata{At: time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC), State: "scheduled"}}, + }, + expectedMoves: 1, + expectedDsts: []string{"Spam"}, + }, { name: "unknown sender (moves to ToScreen)", emails: []imap.Email{ diff --git a/internal/ui/model.go b/internal/ui/model.go index ea488e9..601dfa5 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -1976,6 +1976,11 @@ func (m Model) setReminderCmd(e *imap.Email, at time.Time) tea.Cmd { source = *e } waiting, trash := m.cfg.Folders.Waiting, m.cfg.Folders.Trash + if strings.EqualFold(waiting, m.cfg.Folders.Inbox) { + return func() tea.Msg { + return reminderDoneMsg{err: fmt.Errorf("reminders require Waiting and Inbox to be distinct folders")} + } + } folders := reminderFolders(m.cfg.Folders) return func() tea.Msg { cli := m.imapCli() diff --git a/internal/ui/model_test.go b/internal/ui/model_test.go index 93f3d43..850cdd0 100644 --- a/internal/ui/model_test.go +++ b/internal/ui/model_test.go @@ -64,7 +64,7 @@ func TestDeepScreenSkipsDueReminder(t *testing.T) { emails: []imap.Email{{ UID: 9, From: "unknown@example.com", - Reminder: &reminder.Metadata{At: time.Now().Add(-time.Minute), State: "scheduled"}, + Reminder: &reminder.Metadata{At: time.Now().Add(-time.Minute), State: "scheduled", ID: "id-9"}, }}, total: 1, }) From 138858b49542dda9f3e8e8d7e362d145f4d413e5 Mon Sep 17 00:00:00 2001 From: Jon Roosevelt Date: Fri, 4 Sep 2026 07:09:03 -0400 Subject: [PATCH 05/16] docs: add project vision (#3) --- README.md | 2 ++ VISION.md | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 VISION.md diff --git a/README.md b/README.md index 7fa5953..2608c98 100644 --- a/README.md +++ b/README.md @@ -350,6 +350,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..529c1f7 --- /dev/null +++ b/VISION.md @@ -0,0 +1,75 @@ +# 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 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. +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 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 fixed folder set is the author's workflow, and users who need different folders fork the Go source. +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, and out-of-office replies. +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 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. +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. +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. +Installing is still cloning the repository and running make install, 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. From 6e483361d3e6582bf5348ba2244f36dd1b4e9eda Mon Sep 17 00:00:00 2001 From: Jon Roosevelt Date: Fri, 4 Sep 2026 11:43:05 -0400 Subject: [PATCH 06/16] docs: revise project vision from session history (#4) --- VISION.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/VISION.md b/VISION.md index 529c1f7..c1aa853 100644 --- a/VISION.md +++ b/VISION.md @@ -2,7 +2,9 @@ `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 @@ -18,13 +20,15 @@ Newsletters and receipts get their own quiet rooms in Feed and Papertrail, so de ## 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 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 fixed folder set is the author's workflow, and users who need different folders fork the Go source. +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, and out-of-office replies. +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. @@ -42,6 +46,8 @@ It refuses to be a service: no server of its own, no accounts, no telemetry, one ## 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. @@ -52,6 +58,8 @@ Speed is the product; when a feature costs latency on every interaction, it must 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. @@ -70,6 +78,8 @@ The single user's real workflow is the specification; features enter when the au 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. -Installing is still cloning the repository and running make install, and a new user has classified their first sender within minutes of first launch. +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. From db0bbb8eede9ded17a7c044bf04ae1695e44dae0 Mon Sep 17 00:00:00 2001 From: Jon Roosevelt Date: Sat, 5 Sep 2026 01:11:53 -0400 Subject: [PATCH 07/16] =?UTF-8?q?feat(ui):=20keyboard-driven=20email=20han?= =?UTF-8?q?dling=20=E2=80=94=20e/h/s/;,=20infinite=20scroll,=20instant=20a?= =?UTF-8?q?ctions=20(#5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drive the client from the home row, and make every action land immediately. Bindings (inbox + reader, guarded behind the existing input-mode returns so they never fire inside the filter, `:` command line, IMAP search, or the reminder prompt): e archive / mark done h remind me s start an email ; snippets / templates picker Case reconciliation — email actions are now lowercase (i/o/p/b/t/v replace I/O/P/B/T/V, uppercase kept as aliases, as are A and c). Six keys stay uppercase because their lowercase letter is load-bearing: 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), and the reader's read-only $EDITOR view moved from `e` to `e`. Instant actions — archive/delete/screen/move/remind apply to the list on the keystroke via Model.optimisticAct, reconcile in place on the server ack (no folder re-fetch, which is what made every keypress feel like a page load), and roll back visibly on failure. Batches carry an optID so overlapping actions never consume each other's rollback snapshot. Infinite scroll — reaching the bottom appends the next page via the new imap.Client.FetchHeadersBefore UID cursor; the list cursor stays put, an empty page latches the folder exhausted, and cross-folder views are not paged. Snippets — `;` reads /snippets/*.md at open time; an optional leading `Subject:` line sets the subject and the rest is staged into the $EDITOR buffer through the existing mailto-body path. Tests: TestEmailBindingMap, TestEmailBindingsGuardedInsideInputFields, TestReaderArchiveAndRemindKeys, TestOptimisticArchiveIsInstant, TestOptimisticArchiveRollsBackVisiblyOnFailure, TestOverlappingOptimisticBatchesRollBackIndependently, TestOptimisticRemindMovesRowsAtOnce, TestScrollToBottomTriggersNextPage, TestNextPageAppendsAndKeepsScrollPosition, TestScrollLatchSurvivesTheKeyHandler, TestScrollDoesNotPageAdHocViews, TestEmptyNextPageMarksFolderExhausted, TestNextPageForAnotherFolderIsDropped, TestFullFolderLoadResetsPagingState, TestSnippetParseSplitsSubjectAndBody, TestSnippetPickerComposesPrefilled. Claude-Session: https://claude.ai/code/session_014hpboaND66f8HSC1LG43Rn Co-authored-by: Claude Opus 5 (1M context) --- AGENTS.md | 65 ++++ CHANGELOG.md | 47 +++ README.md | 15 +- docs/content/docs/_index.md | 17 +- docs/content/docs/keybindings.md | 58 ++-- internal/config/config.go | 25 +- internal/imap/client.go | 15 + internal/snippets/snippets.go | 88 ++++++ internal/ui/email_keys_test.go | 527 +++++++++++++++++++++++++++++++ internal/ui/keys.go | 55 ++-- internal/ui/model.go | 301 +++++++++++++++--- internal/ui/optimistic.go | 181 +++++++++++ internal/ui/snippets.go | 103 ++++++ 13 files changed, 1390 insertions(+), 107 deletions(-) create mode 100644 internal/snippets/snippets.go create mode 100644 internal/ui/email_keys_test.go create mode 100644 internal/ui/optimistic.go create mode 100644 internal/ui/snippets.go diff --git a/AGENTS.md b/AGENTS.md index 7d554c9..ba87f51 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -376,6 +376,64 @@ 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`. +- **Case convention is LOWERCASE for email actions.** `i`/`o`/`p`/`b`/`t`/`v` replaced + `I`/`O`/`P`/`B`/`T`/`V` (uppercase kept as aliases). Six keys had to stay uppercase + because their lowercase letter is already load-bearing — the exception list is + `F` (f=forward), `S` (s=compose), `U` (u=page up), `X` (x=trash), `N` (n=toggle read), + `R` (r=reply). Documented in the `keys.go` header comment; keep both in sync. +- **`h` no longer exits the reader** — `q`/`esc` do. Reverting that would shadow remind. +- **Reader `e` moved the old $EDITOR view to `e`.** +- **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 @@ -384,3 +442,10 @@ that conversation; "the test was too strict" is not a decision an agent makes al - **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. - **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 5a0a71b..5f2ab99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,52 @@ # Changelog +# 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 diff --git a/README.md b/README.md index 2608c98..9d6fda1 100644 --- a/README.md +++ b/README.md @@ -161,7 +161,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 @@ -180,7 +180,10 @@ 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) -- **Per-email reminders** — open an email and press `H`, enter `+2h`, `tomorrow 09:00`, 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) +- **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 or the reader), enter `+2h`, `tomorrow 09:00`, 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) @@ -283,14 +286,14 @@ By default, neomd loads and auto-screens only the newest `200` Inbox emails (`[u **Getting started with the screener:** -1. From your Inbox, pick an email and press `I` (screen **in**) to approve the sender, or `O` (screen **out**) to block them. This creates your first screener list entry. +1. From your Inbox, pick an email and press `i` (screen **in**) to approve the sender, or `o` (screen **out**) to block them. This creates your first screener list entry. 2. Once you've classified at least one sender, auto-screening activates on every Inbox load — new emails from known senders are sorted automatically. 3. Unknown senders land in the `ToScreen` tab. Jump there with `gk` (or `Tab`, use `L` or click the tab) and classify them: - - `I` screen **in** — sender stays in Inbox forever - - `O` screen **out** — sender never reaches Inbox again + - `i` screen **in** — sender stays in Inbox forever + - `o` screen **out** — sender never reaches Inbox again - `F` **feed** — newsletters go to the Feed tab - `P` **papertrail** — receipts go to the PaperTrail tab -4. Use `m` to mark multiple emails, then `I` to batch-approve them all at once. From the `ToScreen` folder, approving/blocking a single unmarked message now applies to all currently queued mail from that sender. +4. Use `m` to mark multiple emails, then `i` to batch-approve them all at once. From the `ToScreen` folder, approving/blocking a single unmarked message now applies to all currently queued mail from that sender. **The best part:** all classifications are saved permanently in your screener lists (`screened_in.txt`, `screened_out.txt`, etc.). An email address screened in will automatically go to your Inbox, and any email screened out will never be in your Inbox again. diff --git a/docs/content/docs/_index.md b/docs/content/docs/_index.md index d40fd10..d15bcde 100644 --- a/docs/content/docs/_index.md +++ b/docs/content/docs/_index.md @@ -164,7 +164,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 @@ -183,7 +183,10 @@ 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) -- **Per-email reminders** — open an email and press `H`, enter `+2h`, `tomorrow 09:00`, 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) +- **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 or the reader), enter `+2h`, `tomorrow 09:00`, 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) @@ -292,14 +295,14 @@ By default, neomd loads and auto-screens only the newest `200` Inbox emails (`[u **Getting started with the screener:** -1. From your Inbox, pick an email and press `I` (screen **in**) to approve the sender, or `O` (screen **out**) to block them. This creates your first screener list entry. +1. From your Inbox, pick an email and press `i` (screen **in**) to approve the sender, or `o` (screen **out**) to block them. This creates your first screener list entry. 2. Once you've classified at least one sender, auto-screening activates on every Inbox load — new emails from known senders are sorted automatically. 3. Unknown senders land in the `ToScreen` tab. Jump there with `gk` (or `Tab`, use `L` or click the tab) and classify them: - - `I` screen **in** — sender stays in Inbox forever - - `O` screen **out** — sender never reaches Inbox again + - `i` screen **in** — sender stays in Inbox forever + - `o` screen **out** — sender never reaches Inbox again - `F` **feed** — newsletters go to the Feed tab - `P` **papertrail** — receipts go to the PaperTrail tab -4. Use `m` to mark multiple emails, then `I` to batch-approve them all at once. From the `ToScreen` folder, approving/blocking a single unmarked message now applies to all currently queued mail from that sender. +4. Use `m` to mark multiple emails, then `i` to batch-approve them all at once. From the `ToScreen` folder, approving/blocking a single unmarked message now applies to all currently queued mail from that sender. **The best part:** all classifications are saved permanently in your screener lists (`screened_in.txt`, `screened_out.txt`, etc.). An email address screened in will automatically go to your Inbox, and any email screened out will never be in your Inbox again. @@ -363,6 +366,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/docs/content/docs/keybindings.md b/docs/content/docs/keybindings.md index 6bc56cf..bc3943f 100644 --- a/docs/content/docs/keybindings.md +++ b/docs/content/docs/keybindings.md @@ -21,7 +21,7 @@ To update both the help overlay and this document at once, edit that file and ru | `gg` | jump to top | | `G` | jump to bottom | | `enter / l` | open email | -| `h / q / esc` | back to inbox (from reader) | +| `q / esc` | back to inbox (from reader) — h is now remind | | `?` | toggle help overlay (type to filter) | @@ -48,20 +48,29 @@ To update both the help overlay and this document at once, edit that file and ru | `gS` | go to Spam (not in tab rotation) | +### Core actions (marked or cursor — instant, no refresh) + +| Key | Action | +|-----|--------| +| `e` | archive / mark done — move to Archive, no screener update (alias A) | +| `h` | remind me — park in Waiting until a chosen time, then back to Inbox | +| `s` | start a new email (compose) (alias c) | +| `;` | snippets — pick a template from /snippets/*.md | + + ### Screener (marked or cursor, any folder) | Key | Action | |-----|--------| -| `I` | approve sender → screened_in.txt + move to Inbox (removes from blocked lists) | -| `O` | block sender → screened_out.txt + move to ScreenedOut (removes from screened_in) | +| `i` | approve sender → screened_in.txt + move to Inbox (removes from blocked lists) (alias I) | +| `o` | block sender → screened_out.txt + move to ScreenedOut (removes from screened_in) (alias O) | | `$` | mark as Spam → spam.txt + move to Spam (removes from screened_in/out) | -| `F` | mark as Feed → feed.txt + move to Feed | -| `P` | mark as PaperTrail → papertrail.txt + move to PaperTrail | +| `F` | mark as Feed → feed.txt + move to Feed (uppercase: f is forward) | +| `p` | mark as PaperTrail → papertrail.txt + move to PaperTrail (alias P) | | `Di` | approve whole DOMAIN → @domain.tld appended to screened_in.txt (cursor or open email; y/n) | | `Do` | block whole DOMAIN → @domain.tld appended to screened_out.txt (cursor or open email; y/n) | -| `A` | archive (move to Archive, no screener update) | -| `B` | move to Work/business (no screener update, if configured) | -| `S` | dry-run screen inbox (loaded emails), then y/n | +| `b` | move to Work/business (no screener update, if configured) (alias B) | +| `S` | dry-run screen inbox (loaded emails), then y/n (uppercase: s is compose) | ### Move (marked or cursor, no screener update) @@ -89,8 +98,8 @@ To update both the help overlay and this document at once, edit that file and ru |-----|--------| | `m` | mark / unmark email + advance cursor | | `ctrl+u` | clear all marks | -| `U` | undo last move or delete (reverses x, A, M* — not screener actions) | -| `X (Trash only)` | permanently delete marked or cursor email(s) — no undo | +| `U` | undo last move or delete (reverses x, e, M* — not screener actions) (uppercase: u is page up) | +| `X (Trash only)` | permanently delete marked or cursor email(s) — no undo (uppercase: x is move to Trash) | ### Leader Key Mappings (space prefix) @@ -101,6 +110,7 @@ To update both the help overlay and this document at once, edit that file and ru | `/` | IMAP search ALL emails on server (From + Subject) | | `c` | contacts picker — browse harvested + [contacts] file names; / filter, y copy address, Y copy "Name ", enter compose to contact | | `S` | scan current folder for spy pixels (skips already scanned) | +| `e (reader)` | open the email in $EDITOR read-only | | `d (reader)` | download raw email source (.eml) to ~/Downloads | | `n (reader)` | append open email's sender to notify.txt (desktop notifications opt-in) | | `N (reader)` | append @domain of open email's sender to notify.txt | @@ -130,17 +140,18 @@ To update both the help overlay and this document at once, edit that file and ru | Key | Action | |-----|--------| | `n` | toggle read/unread (marked or cursor) | -| `N` | jump to next unread email | +| `N` | jump to next unread email (uppercase: n toggles read/unread) | | `ctrl+n` | mark all in current folder as read | -| `R` | reload / refresh folder | +| `R` | reload / refresh folder (uppercase: r is reply) | | `r` | reply (from inbox or reader) | | `ctrl+r` | reply-all — reply to sender + all CC recipients (from inbox or reader) | | `ctrl+e` | react with emoji (from inbox or reader) | | `f` | forward email (from reader or inbox) | -| `T` | show full conversation thread across folders (from inbox or reader) | -| `H (reader)` | remind this email — return it to Inbox at a chosen time (no mail is sent) | -| `V` | show all emails from this sender, across all folders (from inbox) | -| `c` | compose new email | +| `t` | show full conversation thread across folders (from inbox or reader) (alias T) | +| `h` | remind — park marked/cursor (or the open email) in Waiting until a chosen time (alias H) | +| `v` | show all emails from this sender, across all folders (from inbox) (alias V) | +| `s` | compose new email (alias c) | +| `;` | open the snippets / templates picker | | `ctrl+b (compose/pre-send)` | toggle Cc+Bcc fields (both hidden by default) | | `ctrl+f (compose/pre-send)` | cycle From address through all accounts + [[senders]] aliases | | `a (pre-send)` | attach file via yazi file picker (or $NEOMD_FILE_PICKER) | @@ -155,8 +166,8 @@ To update both the help overlay and this document at once, edit that file and ru | `1-9 (reader)` | download attachment N to ~/Downloads and open with xdg-open | | `space+1-0 (reader)` | open link 1-10 in $BROWSER (0 = 10th link) | | `space+l11-99 (reader)` | open link 11-99 in $BROWSER (e.g. space+l26 for [26]) | -| `e (reader)` | open in $EDITOR read-only — search, copy, vim motions | -| `E (reader)` | continue draft — re-open as editable compose (Drafts folder) | +| `e (reader)` | archive / mark done, back to the list (the old e — $EDITOR view — is now e) | +| `E (reader)` | continue draft — re-open as editable compose (Drafts folder) (uppercase: e is archive) | | `o (reader)` | open in w3m (terminal browser) | | `O (reader)` | open in $BROWSER (GUI browser, images shown) | | `ctrl+o (reader)` | open web version / newsletter URL in $BROWSER | @@ -194,12 +205,23 @@ To update both the help overlay and this document at once, edit that file and ru | `esc` | cancel | +### Snippets (; to open) + +| Key | Action | +|-----|--------| +| `j / k` | move through templates | +| `1-9` | pick template by number | +| `enter` | start a compose pre-filled with the template's subject + body | +| `esc / q / ;` | close the picker | + + ### General | Key | Action | |-----|--------| | `/` | filter loaded emails (From + Subject, in-memory) | | `z` | toggle unread-only view (zoomed out/zero inbox) | +| `scroll to bottom` | the next page of emails loads and appends automatically | | `/ or :search` | IMAP search ALL emails on server (From + Subject) | | `?` | toggle this help | | `q` | quit (from inbox) | diff --git a/internal/config/config.go b/internal/config/config.go index 7674a5c..a22cf06 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -388,13 +388,13 @@ type Config struct { // Only the headless daemon acts on it; the TUI ignores this block. type OOOConfig struct { Enabled bool `toml:"enabled"` - Accounts []string `toml:"accounts"` // [[accounts]] names whose inboxes get auto-replies, each from its own address (e.g. ["Work", "WorkInfo"]); empty = the daemon's own account - Timezone string `toml:"timezone"` // IANA name (e.g. "Europe/Zurich") that from/until are interpreted in; empty = the daemon machine's local time - From string `toml:"from"` // "YYYY-MM-DD" — active starting at 00:00 of this day (local time); empty = active immediately - Until string `toml:"until"` // "YYYY-MM-DD" — active through the END of this day (local time); empty = active until enabled=false - Subject string `toml:"subject"` // reply subject; default "Out of Office" - Body string `toml:"body"` // reply body in markdown (same rendering as composed emails) - BodyFile string `toml:"body_file"` // optional path to a markdown file; overrides body when set + Accounts []string `toml:"accounts"` // [[accounts]] names whose inboxes get auto-replies, each from its own address (e.g. ["Work", "WorkInfo"]); empty = the daemon's own account + Timezone string `toml:"timezone"` // IANA name (e.g. "Europe/Zurich") that from/until are interpreted in; empty = the daemon machine's local time + From string `toml:"from"` // "YYYY-MM-DD" — active starting at 00:00 of this day (local time); empty = active immediately + Until string `toml:"until"` // "YYYY-MM-DD" — active through the END of this day (local time); empty = active until enabled=false + Subject string `toml:"subject"` // reply subject; default "Out of Office" + Body string `toml:"body"` // reply body in markdown (same rendering as composed emails) + BodyFile string `toml:"body_file"` // optional path to a markdown file; overrides body when set } // ListmonkTrigger maps a virtual email address to Listmonk list IDs. @@ -455,6 +455,16 @@ func DefaultPath() string { // Set during Load() so that different configs use separate cache directories. var cacheDirName = "neomd" +// configDirPath is the directory holding the loaded config.toml. Set during +// Load() so sibling assets (snippets/, lists/) follow the -config flag. +var configDirPath = filepath.Join(filepath.Dir(DefaultPath())) + +// SnippetsDir returns the directory holding email templates, a sibling of the +// active config file (~/.config/neomd/snippets/ by default). +func SnippetsDir() string { + return filepath.Join(configDirPath, "snippets") +} + // HistoryPath returns the path for the command history file. // Uses the OS cache directory (~/.cache/neomd/ on Linux) so it is never // picked up by dotfile version control but still persists across reboots. @@ -583,6 +593,7 @@ func Load(path string) (*Config, error) { // Derive cache dir name from config directory (e.g. "neomd-demo" from // ~/.config/neomd-demo/config.toml) so demo and production don't share cache. cacheDirName = filepath.Base(filepath.Dir(path)) + configDirPath = filepath.Dir(path) cfg := defaults() diff --git a/internal/imap/client.go b/internal/imap/client.go index 4e1af54..2816ea1 100644 --- a/internal/imap/client.go +++ b/internal/imap/client.go @@ -341,6 +341,13 @@ func parseSendAtSection(raw []byte) time.Time { } func (c *Client) FetchHeaders(ctx context.Context, folder string, n int) ([]Email, error) { + return c.FetchHeadersBefore(ctx, folder, 0, n) +} + +// FetchHeadersBefore fetches the n most recent headers with UID strictly below +// beforeUID — the paging primitive behind the inbox's infinite scroll. A +// beforeUID of 0 means "no cursor", making it identical to FetchHeaders. +func (c *Client) FetchHeadersBefore(ctx context.Context, folder string, beforeUID uint32, n int) ([]Email, error) { if ctx == nil { ctx = context.Background() } @@ -368,6 +375,14 @@ func (c *Client) FetchHeaders(ctx context.Context, folder string, n int) ([]Emai // Take the last n UIDs (most recent) and reverse to newest-first. // n=0 means no limit — fetch all. sort.Slice(allUIDs, func(i, j int) bool { return allUIDs[i] < allUIDs[j] }) + // Paging cursor: keep only UIDs older than the oldest one already held. + if beforeUID > 0 { + cut := sort.Search(len(allUIDs), func(i int) bool { return uint32(allUIDs[i]) >= beforeUID }) + allUIDs = allUIDs[:cut] + if len(allUIDs) == 0 { + return nil + } + } if n > 0 && len(allUIDs) > n { allUIDs = allUIDs[len(allUIDs)-n:] } diff --git a/internal/snippets/snippets.go b/internal/snippets/snippets.go new file mode 100644 index 0000000..480c191 --- /dev/null +++ b/internal/snippets/snippets.go @@ -0,0 +1,88 @@ +// Package snippets loads reusable email templates from a directory of +// markdown files. One file = one snippet; the filename (without extension) is +// the snippet name shown in the picker. +// +// An optional `Subject: ...` line at the top of the file sets the subject; the +// remainder (after the first blank line) is the body. Files without that line +// are pure bodies. +package snippets + +import ( + "os" + "path/filepath" + "sort" + "strings" +) + +// Snippet is one loaded template. +type Snippet struct { + Name string + Subject string + Body string +} + +// Load reads every *.md / *.txt file in dir, sorted by name. A missing or +// unreadable directory yields no snippets and no error — the picker just shows +// its empty-state hint. Unreadable individual files are skipped. +func Load(dir string) []Snippet { + if dir == "" { + return nil + } + entries, err := os.ReadDir(dir) + if err != nil { + return nil + } + var out []Snippet + for _, ent := range entries { + if ent.IsDir() { + continue + } + ext := strings.ToLower(filepath.Ext(ent.Name())) + if ext != ".md" && ext != ".txt" { + continue + } + raw, err := os.ReadFile(filepath.Join(dir, ent.Name())) + if err != nil { + continue + } + s := Parse(strings.TrimSuffix(ent.Name(), filepath.Ext(ent.Name())), string(raw)) + out = append(out, s) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +// Parse splits raw template text into subject and body. Leading `Subject:` +// lines are consumed as the subject; everything after the header block (and +// the blank line that terminates it) is the body. +func Parse(name, raw string) Snippet { + s := Snippet{Name: name} + lines := strings.Split(strings.ReplaceAll(raw, "\r\n", "\n"), "\n") + i := 0 + for ; i < len(lines); i++ { + line := lines[i] + if strings.TrimSpace(line) == "" { + // Blank line ends the header block only once a header was seen; + // otherwise it is body whitespace we skip past anyway. + if s.Subject != "" { + i++ + } + break + } + if rest, ok := cutHeader(line, "subject:"); ok && s.Subject == "" { + s.Subject = rest + continue + } + break + } + s.Body = strings.TrimLeft(strings.Join(lines[i:], "\n"), "\n") + return s +} + +// cutHeader matches a case-insensitive `name: value` prefix. +func cutHeader(line, name string) (string, bool) { + if len(line) < len(name) || !strings.EqualFold(line[:len(name)], name) { + return "", false + } + return strings.TrimSpace(line[len(name):]), true +} diff --git a/internal/ui/email_keys_test.go b/internal/ui/email_keys_test.go new file mode 100644 index 0000000..9356e58 --- /dev/null +++ b/internal/ui/email_keys_test.go @@ -0,0 +1,527 @@ +package ui + +import ( + "errors" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/sspaeti/neomd/internal/config" + "github.com/sspaeti/neomd/internal/imap" + "github.com/sspaeti/neomd/internal/screener" + "github.com/sspaeti/neomd/internal/snippets" +) + +// ── Fixtures ───────────────────────────────────────────────────────────── + +func keysTestConfig() *config.Config { + return &config.Config{ + Folders: config.FoldersConfig{ + Inbox: "INBOX", Archive: "Archive", Trash: "Trash", Waiting: "Waiting", + ScreenedOut: "ScreenedOut", Feed: "Feed", PaperTrail: "PaperTrail", + ToScreen: "ToScreen", Work: "Work", Sent: "Sent", Drafts: "Drafts", + Someday: "Someday", Scheduled: "Scheduled", Spam: "Spam", + }, + UI: config.UIConfig{InboxCount: 3}, + } +} + +func keysTestEmails(n int) []imap.Email { + out := make([]imap.Email, n) + base := time.Date(2026, time.March, 1, 9, 0, 0, 0, time.UTC) + for i := range out { + out[i] = imap.Email{ + UID: uint32(100 - i), // newest first: descending UIDs + Folder: "INBOX", + From: "sender@example.com", + Subject: "message", + Date: base.Add(-time.Duration(i) * time.Hour), + } + } + return out +} + +// keysTestModel returns an inbox-state model holding n emails in INBOX. +func keysTestModel(t *testing.T, n int) Model { + t.Helper() + m := Model{ + state: stateInbox, + inbox: newInboxList(80, 20, "Sent", "Drafts"), + folders: []string{"Inbox"}, + cfg: keysTestConfig(), + emails: keysTestEmails(n), + markedUIDs: map[uint32]bool{}, + compose: newComposeModel(), + screener: &screener.Screener{}, + } + m.sortField, m.sortReverse = "date", true + m.applyFilter() + if len(m.inbox.Items()) != n { + t.Fatalf("fixture: list has %d items, want %d", len(m.inbox.Items()), n) + } + return m +} + +func press(m Model, key string) Model { + var msg tea.KeyMsg + switch key { + case "esc": + msg = tea.KeyMsg{Type: tea.KeyEsc} + case "enter": + msg = tea.KeyMsg{Type: tea.KeyEnter} + default: + msg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)} + } + next, _ := m.updateInbox(msg) + return next.(Model) +} + +// ── 1. Binding map ─────────────────────────────────────────────────────── + +// TestEmailBindingMap pins the keyboard contract: the four keys the client is +// driven by, plus the lowercase migrations and the uppercase keys that had to +// stay because their lowercase letter is taken. +func TestEmailBindingMap(t *testing.T) { + tests := []struct { + key string + check func(t *testing.T, before, after Model) + }{ + {"e", func(t *testing.T, before, after Model) { // archive + if len(after.emails) != len(before.emails)-1 { + t.Fatalf("e: %d emails left, want %d", len(after.emails), len(before.emails)-1) + } + }}, + {"A", func(t *testing.T, before, after Model) { // archive, legacy alias + if len(after.emails) != len(before.emails)-1 { + t.Fatalf("A alias: %d emails left, want %d", len(after.emails), len(before.emails)-1) + } + }}, + {"h", func(t *testing.T, _, after Model) { // remind + if !after.reminderActive || len(after.reminderTargets) != 1 { + t.Fatalf("h: reminderActive=%v targets=%d", after.reminderActive, len(after.reminderTargets)) + } + }}, + {"s", func(t *testing.T, _, after Model) { // start an email + if after.state != stateCompose { + t.Fatalf("s: state = %v, want stateCompose", after.state) + } + }}, + {"c", func(t *testing.T, _, after Model) { // compose, legacy alias + if after.state != stateCompose { + t.Fatalf("c alias: state = %v, want stateCompose", after.state) + } + }}, + {";", func(t *testing.T, _, after Model) { // snippets + if after.state != stateSnippets { + t.Fatalf("; : state = %v, want stateSnippets", after.state) + } + }}, + {"x", func(t *testing.T, before, after Model) { // delete + if len(after.emails) != len(before.emails)-1 { + t.Fatalf("x: %d emails left, want %d", len(after.emails), len(before.emails)-1) + } + }}, + {"i", func(t *testing.T, before, after Model) { // screen in (was I) + if len(after.emails) != len(before.emails)-1 { + t.Fatalf("i: %d emails left, want %d", len(after.emails), len(before.emails)-1) + } + }}, + {"o", func(t *testing.T, before, after Model) { // screen out (was O) + if len(after.emails) != len(before.emails)-1 { + t.Fatalf("o: %d emails left, want %d", len(after.emails), len(before.emails)-1) + } + }}, + {"p", func(t *testing.T, before, after Model) { // papertrail (was P) + if len(after.emails) != len(before.emails)-1 { + t.Fatalf("p: %d emails left, want %d", len(after.emails), len(before.emails)-1) + } + }}, + {"b", func(t *testing.T, before, after Model) { // work (was B) + if len(after.emails) != len(before.emails)-1 { + t.Fatalf("b: %d emails left, want %d", len(after.emails), len(before.emails)-1) + } + }}, + {"F", func(t *testing.T, before, after Model) { // feed stays uppercase: f is forward + if len(after.emails) != len(before.emails)-1 { + t.Fatalf("F: %d emails left, want %d", len(after.emails), len(before.emails)-1) + } + }}, + } + for _, tt := range tests { + t.Run(tt.key, func(t *testing.T) { + before := keysTestModel(t, 4) + tt.check(t, before, press(before, tt.key)) + }) + } +} + +// TestEmailBindingsGuardedInsideInputFields pins spec: none of the new keys +// fire while a text field owns the keyboard — they must land as characters. +func TestEmailBindingsGuardedInsideInputFields(t *testing.T) { + for _, key := range []string{"e", "h", "s", ";"} { + t.Run("filter/"+key, func(t *testing.T) { + m := keysTestModel(t, 4) + m.filterActive = true + after := press(m, key) + if len(after.emails) != 4 || after.state != stateInbox || after.reminderActive { + t.Fatalf("%q fired inside the filter field", key) + } + if after.filterText != key { + t.Fatalf("filterText = %q, want %q — key was swallowed", after.filterText, key) + } + }) + t.Run("cmdline/"+key, func(t *testing.T) { + m := keysTestModel(t, 4) + m.cmdMode = true + after := press(m, key) + if len(after.emails) != 4 || after.state != stateInbox || after.reminderActive { + t.Fatalf("%q fired inside the : command line", key) + } + }) + t.Run("reminder-prompt/"+key, func(t *testing.T) { + m := keysTestModel(t, 4) + m.reminderActive = true + m.reminderInput = newReminderInput() + after := press(m, key) + if len(after.emails) != 4 || after.state != stateInbox { + t.Fatalf("%q fired inside the reminder prompt", key) + } + }) + } +} + +// TestReaderArchiveAndRemindKeys pins the same keys in the reader, including +// the h → remind move (back-to-inbox stays on q/esc). +func TestReaderArchiveAndRemindKeys(t *testing.T) { + open := imap.Email{UID: 100, Folder: "INBOX", From: "a@b.com"} + + m := keysTestModel(t, 4) + m.state = stateReading + m.openEmail = &open + after, _ := m.updateReader(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}}) + got := after.(Model) + if got.state != stateInbox || len(got.emails) != 3 { + t.Fatalf("reader e: state=%v emails=%d, want inbox/3", got.state, len(got.emails)) + } + + m2 := keysTestModel(t, 4) + m2.state = stateReading + m2.openEmail = &open + after2, _ := m2.updateReader(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'h'}}) + got2 := after2.(Model) + if !got2.reminderActive || got2.state != stateReading { + t.Fatalf("reader h: reminderActive=%v state=%v, want prompt in reader", got2.reminderActive, got2.state) + } + + // q still leaves the reader — h no longer does. + m3 := keysTestModel(t, 4) + m3.state = stateReading + m3.openEmail = &open + after3, _ := m3.updateReader(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}) + if after3.(Model).state != stateInbox { + t.Fatal("reader q should return to the inbox") + } +} + +// ── 2. Optimistic update path ──────────────────────────────────────────── + +// TestOptimisticArchiveIsInstant pins that the row leaves the list on the +// keystroke — before any IMAP round-trip — and that no folder re-fetch is +// queued when the server later confirms. +func TestOptimisticArchiveIsInstant(t *testing.T) { + m := keysTestModel(t, 4) + target := m.emails[0] + + after := press(m, "e") + if len(after.emails) != 3 || len(after.inbox.Items()) != 3 { + t.Fatalf("archive not applied optimistically: emails=%d items=%d", len(after.emails), len(after.inbox.Items())) + } + for _, e := range after.emails { + if e.UID == target.UID { + t.Fatal("archived email is still in the list") + } + } + if len(after.optimistic) != 1 { + t.Fatalf("optimistic batches = %d, want 1 pending rollback snapshot", len(after.optimistic)) + } + + // Server confirms: the snapshot retires and the list is left alone. + var id int + for k := range after.optimistic { + id = k + } + done, _ := after.Update(batchDoneMsg{optID: id}) + confirmed := done.(Model) + if len(confirmed.emails) != 3 { + t.Fatalf("confirm changed the list: %d emails, want 3", len(confirmed.emails)) + } + if len(confirmed.optimistic) != 0 { + t.Fatal("confirmed batch should retire its rollback snapshot") + } + if confirmed.loading { + t.Fatal("confirming an optimistic batch must not put the UI back into loading") + } +} + +// TestOptimisticArchiveRollsBackVisiblyOnFailure pins that a server refusal +// puts the row back and says so, rather than silently dropping the action. +func TestOptimisticArchiveRollsBackVisiblyOnFailure(t *testing.T) { + m := keysTestModel(t, 4) + target := m.emails[0] + after := press(m, "e") + + var id int + for k := range after.optimistic { + id = k + } + done, _ := after.Update(batchDoneMsg{optID: id, err: errors.New("IMAP said no")}) + rolled := done.(Model) + + if len(rolled.emails) != 4 || len(rolled.inbox.Items()) != 4 { + t.Fatalf("rollback: emails=%d items=%d, want 4/4", len(rolled.emails), len(rolled.inbox.Items())) + } + var found bool + for _, e := range rolled.emails { + if e.UID == target.UID { + found = true + } + } + if !found { + t.Fatal("rolled-back email did not return to the list") + } + if !rolled.isError || rolled.status == "" { + t.Fatalf("rollback must be visible: isError=%v status=%q", rolled.isError, rolled.status) + } + if len(rolled.optimistic) != 0 { + t.Fatal("rolled-back batch should release its snapshot") + } +} + +// TestOverlappingOptimisticBatchesRollBackIndependently pins that firing two +// actions before either acks does not let one ack clobber the other's undo. +func TestOverlappingOptimisticBatchesRollBackIndependently(t *testing.T) { + m := keysTestModel(t, 4) + first := press(m, "e") // archive newest + second := press(first, "x") // delete the next one + if len(second.emails) != 2 || len(second.optimistic) != 2 { + t.Fatalf("two pending batches: emails=%d snapshots=%d", len(second.emails), len(second.optimistic)) + } + + var ids []int + for k := range second.optimistic { + ids = append(ids, k) + } + // Confirm one, fail the other: exactly one row must come back. + ok, _ := second.Update(batchDoneMsg{optID: ids[0]}) + next := ok.(Model) + failed, _ := next.Update(batchDoneMsg{optID: ids[1], err: errors.New("nope")}) + end := failed.(Model) + if len(end.emails) != 3 { + t.Fatalf("after one ack + one failure: %d emails, want 3", len(end.emails)) + } + if len(end.optimistic) != 0 { + t.Fatalf("both batches should be released, %d left", len(end.optimistic)) + } +} + +// TestOptimisticRemindMovesRowsAtOnce pins the reminder path through the same +// optimistic machinery as archive. +func TestOptimisticRemindMovesRowsAtOnce(t *testing.T) { + m := keysTestModel(t, 4) + m = press(m, "h") + if !m.reminderActive { + t.Fatal("h did not open the reminder prompt") + } + m.reminderInput.SetValue("+2h") + next, _ := m.updateInbox(tea.KeyMsg{Type: tea.KeyEnter}) + after := next.(Model) + if after.reminderActive { + t.Fatal("prompt should close on enter") + } + if len(after.emails) != 3 || len(after.optimistic) != 1 { + t.Fatalf("remind: emails=%d snapshots=%d, want 3/1", len(after.emails), len(after.optimistic)) + } +} + +// ── 3. Infinite scroll ─────────────────────────────────────────────────── + +func TestOldestUIDIsThePagingCursor(t *testing.T) { + emails := []imap.Email{ + {UID: 40, Folder: "INBOX"}, {UID: 12, Folder: "INBOX"}, + {UID: 7, Folder: "Archive"}, // other folder must not lower the cursor + } + if got := oldestUID(emails, "INBOX"); got != 12 { + t.Fatalf("oldestUID = %d, want 12", got) + } + if got := oldestUID(emails, "Feed"); got != 0 { + t.Fatalf("oldestUID for an unheld folder = %d, want 0", got) + } +} + +func TestAppendNewEmailsDeduplicates(t *testing.T) { + have := []imap.Email{{UID: 9, Folder: "INBOX"}} + got, added := appendNewEmails(have, []imap.Email{ + {UID: 9, Folder: "INBOX"}, // already held + {UID: 9, Folder: "Archive"}, // same UID, different folder → new + {UID: 8, Folder: "INBOX"}, + }) + if added != 2 || len(got) != 3 { + t.Fatalf("added=%d len=%d, want 2/3", added, len(got)) + } + if _, again := appendNewEmails(got, got); again != 0 { + t.Fatalf("re-appending the same page added %d", again) + } +} + +// TestScrollToBottomTriggersNextPage pins that reaching the end of the list +// fetches more — and that the middle of the list does not. +func TestScrollToBottomTriggersNextPage(t *testing.T) { + m := keysTestModel(t, 12) + + m.inbox.Select(0) + if cmd := m.maybeLoadMoreCmd(); cmd != nil { + t.Fatal("top of the list should not page") + } + + m.inbox.Select(len(m.inbox.Items()) - 1) + if cmd := m.maybeLoadMoreCmd(); cmd == nil { + t.Fatal("bottom of the list should page") + } + if !m.loadingMore { + t.Fatal("paging should latch loadingMore so the fetch is not duplicated") + } + if cmd := m.maybeLoadMoreCmd(); cmd != nil { + t.Fatal("a second fetch fired while one was in flight") + } + + m.loadingMore = false + m.moreExhausted = true + if cmd := m.maybeLoadMoreCmd(); cmd != nil { + t.Fatal("exhausted folder should not keep fetching") + } +} + +func TestScrollDoesNotPageAdHocViews(t *testing.T) { + m := keysTestModel(t, 12) + m.inbox.Select(len(m.inbox.Items()) - 1) + m.imapSearchResults = true + if cmd := m.maybeLoadMoreCmd(); cmd != nil { + t.Fatal("IMAP search results are not a paged folder view") + } + m.imapSearchResults = false + m.offTabFolder = "Everything" + if cmd := m.maybeLoadMoreCmd(); cmd != nil { + t.Fatal("cross-folder views are not paged") + } +} + +// TestNextPageAppendsAndKeepsScrollPosition pins the append: rows are added, +// nothing is replaced, and the cursor stays on the row the user was reading. +func TestNextPageAppendsAndKeepsScrollPosition(t *testing.T) { + m := keysTestModel(t, 6) + m.inbox.Select(4) + at := m.inbox.Index() + + older := []imap.Email{ + {UID: 90, Folder: "INBOX", From: "old@example.com", Subject: "older", Date: time.Date(2026, time.February, 1, 9, 0, 0, 0, time.UTC)}, + {UID: 89, Folder: "INBOX", From: "old@example.com", Subject: "older", Date: time.Date(2026, time.February, 1, 8, 0, 0, 0, time.UTC)}, + } + next, _ := m.Update(moreEmailsLoadedMsg{emails: older, folder: "INBOX"}) + after := next.(Model) + + if len(after.emails) != 8 || len(after.inbox.Items()) != 8 { + t.Fatalf("append: emails=%d items=%d, want 8/8", len(after.emails), len(after.inbox.Items())) + } + if after.inbox.Index() != at { + t.Fatalf("cursor moved on append: %d, want %d", after.inbox.Index(), at) + } + if after.loadingMore { + t.Fatal("loadingMore should clear once the page lands") + } + if after.moreExhausted { + t.Fatal("a page that added rows must not mark the folder exhausted") + } +} + +func TestEmptyNextPageMarksFolderExhausted(t *testing.T) { + m := keysTestModel(t, 6) + next, _ := m.Update(moreEmailsLoadedMsg{emails: nil, folder: "INBOX"}) + after := next.(Model) + if !after.moreExhausted || len(after.emails) != 6 { + t.Fatalf("exhausted=%v emails=%d, want true/6", after.moreExhausted, len(after.emails)) + } +} + +func TestNextPageForAnotherFolderIsDropped(t *testing.T) { + m := keysTestModel(t, 6) + next, _ := m.Update(moreEmailsLoadedMsg{ + emails: []imap.Email{{UID: 5, Folder: "Archive"}}, + folder: "Archive", // user switched tabs while the page was in flight + }) + if got := next.(Model); len(got.emails) != 6 { + t.Fatalf("stale page merged into the wrong folder: %d emails", len(got.emails)) + } +} + +func TestFullFolderLoadResetsPagingState(t *testing.T) { + m := keysTestModel(t, 6) + m.moreExhausted = true + m.loadingMore = true + m.optimistic = map[int][]imap.Email{1: {{UID: 1}}} + next, _ := m.Update(emailsLoadedMsg{emails: keysTestEmails(2), folder: "INBOX"}) + after := next.(Model) + if after.moreExhausted || after.loadingMore || after.optimistic != nil { + t.Fatalf("reload should reset paging/optimistic state: exhausted=%v more=%v opt=%v", + after.moreExhausted, after.loadingMore, after.optimistic) + } +} + +// ── Snippets ───────────────────────────────────────────────────────────── + +func TestSnippetParseSplitsSubjectAndBody(t *testing.T) { + got := snippets.Parse("intro", "Subject: Quick intro\n\nHi there,\n\nBody line.\n") + if got.Subject != "Quick intro" { + t.Fatalf("subject = %q", got.Subject) + } + if got.Body != "Hi there,\n\nBody line.\n" { + t.Fatalf("body = %q", got.Body) + } + + plain := snippets.Parse("plain", "No header here.\nSecond line.\n") + if plain.Subject != "" || plain.Body != "No header here.\nSecond line.\n" { + t.Fatalf("headerless snippet mis-parsed: %+v", plain) + } +} + +func TestSnippetPickerComposesPrefilled(t *testing.T) { + m := keysTestModel(t, 2) + m.snippets = []snippets.Snippet{{Name: "intro", Subject: "Quick intro", Body: "Hi there,\n"}} + m.state = stateSnippets + next, _ := m.updateSnippets(tea.KeyMsg{Type: tea.KeyEnter}) + after := next.(Model) + if after.state != stateCompose { + t.Fatalf("state = %v, want stateCompose", after.state) + } + if after.compose.subject.Value() != "Quick intro" { + t.Fatalf("subject = %q", after.compose.subject.Value()) + } + if after.mailtoBody != "Hi there,\n" { + t.Fatalf("body not staged for the editor: %q", after.mailtoBody) + } +} + +// TestScrollLatchSurvivesTheKeyHandler pins that the loadingMore latch set by +// maybeLoadMoreCmd is present on the model the handler RETURNS — a plain +// `return m, m.maybeLoadMoreCmd()` would drop it and re-fetch on every keypress. +func TestScrollLatchSurvivesTheKeyHandler(t *testing.T) { + for _, key := range []string{"G", "j", "d"} { + t.Run(key, func(t *testing.T) { + m := keysTestModel(t, 12) + m.inbox.Select(len(m.inbox.Items()) - 1) + after := press(m, key) + if !after.loadingMore { + t.Fatalf("%q at the bottom did not latch loadingMore on the returned model", key) + } + }) + } +} diff --git a/internal/ui/keys.go b/internal/ui/keys.go index a51101f..d174421 100644 --- a/internal/ui/keys.go +++ b/internal/ui/keys.go @@ -9,6 +9,11 @@ type HelpSection struct { // HelpSections is the single source of truth for all keybindings. // Edit here → `make docs` regenerates the README.md keybindings section. +// +// Case convention: email actions are LOWERCASE. Uppercase keys survive only +// where the lowercase letter is already taken by something a user presses more +// often; those are listed as "(alias)" or noted in the row. The pre-5.0 +// uppercase keys still work but are no longer the documented binding. var HelpSections = []HelpSection{ {"Navigation", [][2]string{ {"j / k", "move down / up"}, @@ -16,7 +21,7 @@ var HelpSections = []HelpSection{ {"gg", "jump to top"}, {"G", "jump to bottom"}, {"enter / l", "open email"}, - {"h / q / esc", "back to inbox (from reader)"}, + {"q / esc", "back to inbox (from reader) — h is now remind"}, {"?", "toggle help overlay (type to filter)"}, }}, {"Folders", [][2]string{ @@ -38,17 +43,22 @@ var HelpSections = []HelpSection{ {"ge", "go to Everything — latest 50 emails across all folders"}, {"gS", "go to Spam (not in tab rotation)"}, }}, + {"Core actions (marked or cursor — instant, no refresh)", [][2]string{ + {"e", "archive / mark done — move to Archive, no screener update (alias A)"}, + {"h", "remind me — park in Waiting until a chosen time, then back to Inbox"}, + {"s", "start a new email (compose) (alias c)"}, + {";", "snippets — pick a template from /snippets/*.md"}, + }}, {"Screener (marked or cursor, any folder)", [][2]string{ - {"I", "approve sender → screened_in.txt + move to Inbox (removes from blocked lists)"}, - {"O", "block sender → screened_out.txt + move to ScreenedOut (removes from screened_in)"}, + {"i", "approve sender → screened_in.txt + move to Inbox (removes from blocked lists) (alias I)"}, + {"o", "block sender → screened_out.txt + move to ScreenedOut (removes from screened_in) (alias O)"}, {"$", "mark as Spam → spam.txt + move to Spam (removes from screened_in/out)"}, - {"F", "mark as Feed → feed.txt + move to Feed"}, - {"P", "mark as PaperTrail → papertrail.txt + move to PaperTrail"}, + {"F", "mark as Feed → feed.txt + move to Feed (uppercase: f is forward)"}, + {"p", "mark as PaperTrail → papertrail.txt + move to PaperTrail (alias P)"}, {"Di", "approve whole DOMAIN → @domain.tld appended to screened_in.txt (cursor or open email; y/n)"}, {"Do", "block whole DOMAIN → @domain.tld appended to screened_out.txt (cursor or open email; y/n)"}, - {"A", "archive (move to Archive, no screener update)"}, - {"B", "move to Work/business (no screener update, if configured)"}, - {"S", "dry-run screen inbox (loaded emails), then y/n"}, + {"b", "move to Work/business (no screener update, if configured) (alias B)"}, + {"S", "dry-run screen inbox (loaded emails), then y/n (uppercase: s is compose)"}, }}, {"Move (marked or cursor, no screener update)", [][2]string{ {"x", "delete → Trash"}, @@ -68,14 +78,15 @@ var HelpSections = []HelpSection{ {"Multi-select & Undo", [][2]string{ {"m", "mark / unmark email + advance cursor"}, {"ctrl+u", "clear all marks"}, - {"U", "undo last move or delete (reverses x, A, M* — not screener actions)"}, - {"X (Trash only)", "permanently delete marked or cursor email(s) — no undo"}, + {"U", "undo last move or delete (reverses x, e, M* — not screener actions) (uppercase: u is page up)"}, + {"X (Trash only)", "permanently delete marked or cursor email(s) — no undo (uppercase: x is move to Trash)"}, }}, {"Leader Key Mappings (space prefix)", [][2]string{ {"1 … 9", "jump to folder tab by number (Inbox=1, ToScreen=2, …)"}, {"/", "IMAP search ALL emails on server (From + Subject)"}, {"c", "contacts picker — browse harvested + [contacts] file names; / filter, y copy address, Y copy \"Name \", enter compose to contact"}, {"S", "scan current folder for spy pixels (skips already scanned)"}, + {"e (reader)", "open the email in $EDITOR read-only"}, {"d (reader)", "download raw email source (.eml) to ~/Downloads"}, {"n (reader)", "append open email's sender to notify.txt (desktop notifications opt-in)"}, {"N (reader)", "append @domain of open email's sender to notify.txt"}, @@ -97,17 +108,18 @@ var HelpSections = []HelpSection{ }}, {"Email actions", [][2]string{ {"n", "toggle read/unread (marked or cursor)"}, - {"N", "jump to next unread email"}, + {"N", "jump to next unread email (uppercase: n toggles read/unread)"}, {"ctrl+n", "mark all in current folder as read"}, - {"R", "reload / refresh folder"}, + {"R", "reload / refresh folder (uppercase: r is reply)"}, {"r", "reply (from inbox or reader)"}, {"ctrl+r", "reply-all — reply to sender + all CC recipients (from inbox or reader)"}, {"ctrl+e", "react with emoji (from inbox or reader)"}, {"f", "forward email (from reader or inbox)"}, - {"T", "show full conversation thread across folders (from inbox or reader)"}, - {"H (reader)", "remind this email — return it to Inbox at a chosen time (no mail is sent)"}, - {"V", "show all emails from this sender, across all folders (from inbox)"}, - {"c", "compose new email"}, + {"t", "show full conversation thread across folders (from inbox or reader) (alias T)"}, + {"h", "remind — park marked/cursor (or the open email) in Waiting until a chosen time (alias H)"}, + {"v", "show all emails from this sender, across all folders (from inbox) (alias V)"}, + {"s", "compose new email (alias c)"}, + {";", "open the snippets / templates picker"}, {"ctrl+b (compose/pre-send)", "toggle Cc+Bcc fields (both hidden by default)"}, {"ctrl+f (compose/pre-send)", "cycle From address through all accounts + [[senders]] aliases"}, {"a (pre-send)", "attach file via yazi file picker (or $NEOMD_FILE_PICKER)"}, @@ -122,8 +134,8 @@ var HelpSections = []HelpSection{ {"1-9 (reader)", "download attachment N to ~/Downloads and open with xdg-open"}, {"space+1-0 (reader)", "open link 1-10 in $BROWSER (0 = 10th link)"}, {"space+l11-99 (reader)", "open link 11-99 in $BROWSER (e.g. space+l26 for [26])"}, - {"e (reader)", "open in $EDITOR read-only — search, copy, vim motions"}, - {"E (reader)", "continue draft — re-open as editable compose (Drafts folder)"}, + {"e (reader)", "archive / mark done, back to the list (the old e — $EDITOR view — is now e)"}, + {"E (reader)", "continue draft — re-open as editable compose (Drafts folder) (uppercase: e is archive)"}, {"o (reader)", "open in w3m (terminal browser)"}, {"O (reader)", "open in $BROWSER (GUI browser, images shown)"}, {"ctrl+o (reader)", "open web version / newsletter URL in $BROWSER"}, @@ -152,9 +164,16 @@ var HelpSections = []HelpSection{ {"enter (on Subject)", "open $EDITOR with a .md temp file"}, {"esc", "cancel"}, }}, + {"Snippets (; to open)", [][2]string{ + {"j / k", "move through templates"}, + {"1-9", "pick template by number"}, + {"enter", "start a compose pre-filled with the template's subject + body"}, + {"esc / q / ;", "close the picker"}, + }}, {"General", [][2]string{ {"/", "filter loaded emails (From + Subject, in-memory)"}, {"z", "toggle unread-only view (zoomed out/zero inbox)"}, + {"scroll to bottom", "the next page of emails loads and appends automatically"}, {"/ or :search", "IMAP search ALL emails on server (From + Subject)"}, {"?", "toggle this help"}, {"q", "quit (from inbox)"}, diff --git a/internal/ui/model.go b/internal/ui/model.go index 601dfa5..a3c1740 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -33,6 +33,7 @@ import ( "github.com/sspaeti/neomd/internal/schedule" "github.com/sspaeti/neomd/internal/screener" "github.com/sspaeti/neomd/internal/smtp" + "github.com/sspaeti/neomd/internal/snippets" ) // viewState is the current screen. @@ -47,6 +48,7 @@ const ( stateWelcome // first-run welcome popup stateReaction // emoji reaction picker stateContacts // contacts picker (space c) + stateSnippets // snippet/template picker (;) ) // async message types @@ -55,6 +57,12 @@ type ( emails []imap.Email folder string } + // moreEmailsLoadedMsg carries one appended page from the infinite scroll. + moreEmailsLoadedMsg struct { + emails []imap.Email + folder string + err error + } bodyLoadedMsg struct { email *imap.Email body string @@ -122,6 +130,9 @@ type ( batchDoneMsg struct { err error undo []undoMove + // optID > 0 when the list was already updated optimistically; the + // handler retires or rolls back that batch instead of re-fetching. + optID int } undoDoneMsg struct{} toggleSeenDoneMsg struct { @@ -156,8 +167,9 @@ type ( } saveDraftDoneMsg struct{ err error } reminderDoneMsg struct { - at time.Time - err error + at time.Time + err error + optID int } dueRemindersDoneMsg struct { moved int @@ -602,9 +614,24 @@ type Model struct { // Send-later time prompt on the pre-send screen (`l`). sendLaterActive bool sendLaterInput textinput.Model - // Reminder time prompt on the reader (`H`, Superhuman's reminder key). + // Reminder time prompt (`h`), available from the inbox and the reader. reminderActive bool reminderInput textinput.Model + // reminderTargets is the email set the pending prompt will act on. + reminderTargets []imap.Email + + // Optimistic UI: rows leave the list before IMAP confirms. Keyed by batch + // id so overlapping actions roll back independently. See optimistic.go. + optimistic map[int][]imap.Email + optimisticSeq int + + // Infinite scroll paging state for the current folder view. + loadingMore bool + moreExhausted bool + + // Snippet picker (`;`). + snippets []snippets.Snippet + snippetsCursor int // Reaction reactionEmail *imap.Email // email being reacted to @@ -1970,6 +1997,40 @@ func (m Model) bgFetchInboxCmd() tea.Cmd { } // setReminderCmd parks the selected message without reaching the SMTP path. +// newReminderInput builds the "remind me at" prompt shared by the inbox (`h`) +// and the reader (`h` / `H`). +func newReminderInput() textinput.Model { + ti := textinput.New() + ti.Placeholder = "+2h · tomorrow 09:00 · 2026-09-02 09:00" + ti.CharLimit = 40 + ti.Focus() + return ti +} + +// setRemindersCmd parks every target email, emitting a single reminderDoneMsg. +func (m Model) setRemindersCmd(targets []imap.Email, at time.Time) tea.Cmd { + waiting, trash, inbox := m.cfg.Folders.Waiting, m.cfg.Folders.Trash, m.cfg.Folders.Inbox + if strings.EqualFold(waiting, inbox) { + return func() tea.Msg { + return reminderDoneMsg{err: fmt.Errorf("reminders require Waiting and Inbox to be distinct folders")} + } + } + folders := reminderFolders(m.cfg.Folders) + emails := append([]imap.Email(nil), targets...) + return func() tea.Msg { + cli := m.imapCli() + if cli == nil { + return reminderDoneMsg{err: fmt.Errorf("reminders require an IMAP-enabled account")} + } + for i, e := range emails { + if err := cli.ParkReminder(nil, e, folders, waiting, trash, at); err != nil { + return reminderDoneMsg{err: fmt.Errorf("stopped after %d/%d: %w", i, len(emails), err)} + } + } + return reminderDoneMsg{at: at} + } +} + func (m Model) setReminderCmd(e *imap.Email, at time.Time) tea.Cmd { var source imap.Email if e != nil { @@ -2227,6 +2288,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case emailsLoadedMsg: m.loading = false m.emails = msg.emails + // A full folder load resets paging: the infinite scroll starts over + // from this page and nothing is pending optimistically any more. + m.loadingMore = false + m.moreExhausted = false + m.optimistic = nil m.harvestContacts(msg.emails) m.markedUIDs = make(map[uint32]bool) // clear marks on folder reload m.filterActive = false @@ -2522,15 +2588,23 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case reminderDoneMsg: m.loading = false + m.reminderActive = false if msg.err != nil { + var cmd tea.Cmd + if msg.optID > 0 { + cmd = m.rollbackOptimistic(msg.optID) + } m.status = "Reminder error: " + msg.err.Error() m.isError = true - return m, nil + return m, cmd } - m.reminderActive = false - m.state = stateInbox m.status = fmt.Sprintf("Reminder set for %s — moved to %s.", msg.at.Local().Format("Mon 2006-01-02 15:04"), m.cfg.Folders.Waiting) m.isError = false + if msg.optID > 0 { + m.retireOptimistic(msg.optID) + return m, m.fetchFolderCountsCmd() + } + m.state = stateInbox return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.activeFolder())) case dueRemindersDoneMsg: @@ -2657,10 +2731,55 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case senderResultMsg: return m.handleSenderResult(msg) + case moreEmailsLoadedMsg: + m.loadingMore = false + if msg.err != nil { + m.status = "Load more failed: " + msg.err.Error() + m.isError = true + return m, nil + } + // The user may have switched folders while the page was in flight. + if msg.folder != m.activeFolder() { + return m, nil + } + var added int + m.emails, added = appendNewEmails(m.emails, msg.emails) + if added == 0 { + m.moreExhausted = true + return m, nil + } + m.harvestContacts(msg.emails) + // Re-sort and rebuild; SetItems keeps the cursor index, so the rows + // the user is looking at do not move under them. + at := m.inbox.Index() + cmd := m.sortEmails() + m.inbox.Select(at) + return m, cmd + case batchDoneMsg: m.loading = false m.bulkProgress = nil m.markedUIDs = make(map[uint32]bool) + // Optimistic batches already removed their rows. Confirm in place — + // no folder re-fetch — or put the rows back so the failure is visible. + if msg.optID > 0 { + if len(msg.undo) > 0 { + m.undoStack = append(m.undoStack, msg.undo) + if len(m.undoStack) > maxUndoStack { + m.undoStack = m.undoStack[len(m.undoStack)-maxUndoStack:] + } + } + if msg.err != nil { + cmd := m.rollbackOptimistic(msg.optID) + m.status = msg.err.Error() + m.isError = true + return m, cmd + } + m.retireOptimistic(msg.optID) + m.status = "Done." + m.isError = false + return m, m.fetchFolderCountsCmd() + } if msg.err != nil { // Include partial undo info so user can reverse already-moved emails. if len(msg.undo) > 0 { @@ -3004,6 +3123,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateReaction(msg) case stateContacts: return m.updateContacts(msg) + case stateSnippets: + return m.updateSnippets(msg) } } @@ -3103,6 +3224,33 @@ func (m Model) updateInbox(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } } + // ── Reminder time prompt (h) ──────────────────────────────────── + // When active, consume all keys for text input; no inbox commands fire. + if m.reminderActive { + switch key { + case "esc": + m.reminderActive = false + m.reminderTargets = nil + return m, nil + case "enter": + at, err := schedule.ParseSendAt(m.reminderInput.Value(), time.Now()) + if err != nil { + m.reminderInput.SetValue("") + m.reminderInput.Placeholder = err.Error() + return m, nil + } + m.reminderActive = false + targets := m.reminderTargets + m.reminderTargets = nil + return m, m.optimisticAct(targets, "Reminding", func() tea.Cmd { + return m.setRemindersCmd(targets, at) + }) + } + var cmd tea.Cmd + m.reminderInput, cmd = m.reminderInput.Update(msg) + return m, cmd + } + // ── Our own filter mode ───────────────────────────────────────── // When active, consume all keys for text input; no inbox commands fire. if m.filterActive { @@ -3206,12 +3354,10 @@ func (m Model) updateInbox(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // ── Mark for batch / delete ───────────────────────────────────── case "x": targets := m.targetEmails() - if len(targets) == 0 { - return m, nil - } - m.loading = true - m.bulkProgress = m.newBulkOp("Deleting", len(targets)) - return m, tea.Batch(m.spinner.Tick, m.batchMoveCmd(targets, m.cfg.Folders.Trash)) + trash := m.cfg.Folders.Trash + return m, m.optimisticAct(targets, "Deleting", func() tea.Cmd { + return m.batchMoveCmd(targets, trash) + }) case "X": // permanent delete (marked or cursor) — only in Trash if m.activeFolder() != m.cfg.Folders.Trash { @@ -3245,39 +3391,52 @@ func (m Model) updateInbox(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, tea.Batch(m.spinner.Tick, m.undoMovesCmd(last)) // ── Screener actions — operate on marked emails or cursor email ── - case "I", "O", "F", "P", "$": + // Screener actions. Lowercase is the documented binding; the historical + // uppercase keys stay as aliases. `F` has no lowercase form — `f` is + // forward — so feed keeps its uppercase key (see keys.go mapping table). + case "i", "I", "o", "O", "F", "p", "P", "$": targets := m.targetEmails() - if len(targets) == 0 { - return m, nil - } - m.loading = true - m.bulkProgress = m.newBulkOp("Screening", len(targets)) - return m, tea.Batch(m.spinner.Tick, m.batchScreenerCmd(targets, key)) + action := strings.ToUpper(key) + return m, m.optimisticAct(targets, "Screening", func() tea.Cmd { + return m.batchScreenerCmd(targets, action) + }) + + // e = archive / mark done (pure move, no screener update). `A` is the + // pre-5.0 alias. + case "e", "A": + targets := m.targetEmails() + archive := m.cfg.Folders.Archive + return m, m.optimisticAct(targets, "Archiving", func() tea.Cmd { + return m.batchMoveCmd(targets, archive) + }) - // A = archive (pure move, no screener update) - case "A": + // h = remind me — park the email in Waiting until the chosen time. + case "h": targets := m.targetEmails() if len(targets) == 0 { return m, nil } - m.loading = true - m.bulkProgress = m.newBulkOp("Archiving", len(targets)) - return m, tea.Batch(m.spinner.Tick, m.batchMoveCmd(targets, m.cfg.Folders.Archive)) + m.reminderTargets = targets + m.reminderInput = newReminderInput() + m.reminderActive = true + return m, nil - // B = move to Work/Business (pure move, no screener update) - case "B": - if m.cfg.Folders.Work == "" { + // ; = snippets / templates palette + case ";": + return m.openSnippets() + + // b = move to Work/Business (pure move, no screener update) + case "b", "B": + work := m.cfg.Folders.Work + if work == "" { m.status = "Work folder not configured" m.isError = true return m, nil } targets := m.targetEmails() - if len(targets) == 0 { - return m, nil - } - m.loading = true - m.bulkProgress = m.newBulkOp("Moving to Work", len(targets)) - return m, tea.Batch(m.spinner.Tick, m.batchMoveCmd(targets, m.cfg.Folders.Work)) + return m, m.optimisticAct(targets, "Moving to Work", func() tea.Cmd { + return m.batchMoveCmd(targets, work) + }) // ── Auto-screen dry-run (Inbox only) ──────────────────────────── case ":": @@ -3413,7 +3572,8 @@ func (m Model) updateInbox(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "G": m.inbox.Select(len(m.inbox.Items()) - 1) - return m, nil + more := m.maybeLoadMoreCmd() + return m, more case "d": next := m.inbox.Index() + m.inboxPageStep() @@ -3423,7 +3583,8 @@ func (m Model) updateInbox(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if next >= 0 { m.inbox.Select(next) } - return m, nil + more := m.maybeLoadMoreCmd() + return m, more case "u": prev := m.inbox.Index() - m.inboxPageStep() @@ -3471,7 +3632,7 @@ func (m Model) updateInbox(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.activeFolder())) } - case "c": + case "s", "c": m.attachments = nil m.state = stateCompose m.status = "" @@ -3540,7 +3701,7 @@ func (m Model) updateInbox(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.loading = true return m, tea.Batch(m.spinner.Tick, m.fetchBodyCmd(e)) - case "T": + case "t", "T": e := selectedEmail(m.inbox) if e == nil { return m, nil @@ -3548,7 +3709,7 @@ func (m Model) updateInbox(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.loading = true return m, tea.Batch(m.spinner.Tick, m.fetchConversationCmd(e)) - case "V": + case "v", "V": e := selectedEmail(m.inbox) if e == nil { return m, nil @@ -3574,10 +3735,14 @@ func (m Model) updateInbox(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } - // Forward remaining keys (j/k navigation, filter /) to list + // Forward remaining keys (j/k navigation, filter /) to list, then check + // whether the cursor moved close enough to the bottom to pull the next page. var cmd tea.Cmd m.inbox, cmd = m.inbox.Update(msg) - return m, cmd + // maybeLoadMoreCmd latches m.loadingMore, so run it before m is returned: + // Go does not order a plain operand against a call in the same statement. + more := m.maybeLoadMoreCmd() + return m, tea.Batch(cmd, more) } // sortEmails sorts m.emails in place according to m.sortField / m.sortReverse, @@ -3954,6 +4119,7 @@ func (m Model) updateReader(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch key { case "esc": m.reminderActive = false + m.reminderTargets = nil return m, nil case "enter": at, err := schedule.ParseSendAt(m.reminderInput.Value(), time.Now()) @@ -3963,8 +4129,14 @@ func (m Model) updateReader(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } m.reminderActive = false - m.loading = true - return m, tea.Batch(m.spinner.Tick, m.setReminderCmd(m.openEmail, at)) + targets := m.reminderTargets + m.reminderTargets = nil + // Reminding from the reader closes it: the mail is leaving the + // folder, so the list beneath is what the user returns to. + m.state = stateInbox + return m, m.optimisticAct(targets, "Reminding", func() tea.Cmd { + return m.setRemindersCmd(targets, at) + }) } var cmd tea.Cmd m.reminderInput, cmd = m.reminderInput.Update(msg) @@ -3996,6 +4168,10 @@ func (m Model) updateReader(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.status = "link number (11-99): l__" return m, nil } + // space + e = open in $EDITOR read-only (search, copy, vim motions) + if key == "e" { + return m.openInNeovim() + } // space + d = download raw EML source if key == "d" { m.status = "Downloading EML…" @@ -4123,9 +4299,10 @@ func (m Model) updateReader(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } switch key { - case "q", "esc", "h": + case "q", "esc": m.state = stateInbox m.reminderActive = false + m.reminderTargets = nil m.readerPending = "" // Clear mark-as-read timer state when exiting reader m.markAsReadUID = 0 @@ -4135,8 +4312,29 @@ func (m Model) updateReader(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, m.applyFilter() } return m, nil + // e = archive / mark done, then drop back to the list. The old `e` + // (open in $EDITOR read-only) moved to e — see keys.go. case "e": - return m.openInNeovim() + if m.openEmail == nil { + return m, nil + } + targets := []imap.Email{*m.openEmail} + archive := m.cfg.Folders.Archive + m.state = stateInbox + return m, m.optimisticAct(targets, "Archiving", func() tea.Cmd { + return m.batchMoveCmd(targets, archive) + }) + // s = start a new email, ; = snippets — same keys as the inbox. + case "s": + m.attachments = nil + m.state = stateCompose + m.status = "" + m.isError = false + m.compose.reset() + m.presendFromI = m.defaultFromIndex() + return m, nil + case ";": + return m.openSnippets() case "E": return m.continueDraft() case "o": @@ -4153,13 +4351,10 @@ func (m Model) updateReader(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.imapCli().ResetMailboxSelection() // force fresh SELECT to see new messages m.loading = true return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.activeFolder())) - case "H": + case "h", "H": if m.openEmail != nil { - ti := textinput.New() - ti.Placeholder = "+2h · tomorrow 09:00 · 2026-09-02 09:00" - ti.CharLimit = 40 - ti.Focus() - m.reminderInput = ti + m.reminderTargets = []imap.Email{*m.openEmail} + m.reminderInput = newReminderInput() m.reminderActive = true } return m, nil @@ -4175,7 +4370,7 @@ func (m Model) updateReader(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.openEmail != nil { return m.launchForwardCmd() } - case "T": + case "t", "T": if m.openEmail != nil { m.loading = true m.state = stateInbox @@ -4197,7 +4392,7 @@ func (m Model) updateReader(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } else if len(m.openLinks) > 0 { hints = append(hints, "1-0 links") } - hints = append(hints, "d download .eml", "n add sender to notify.txt", "N add @domain to notify.txt") + hints = append(hints, "e open in $EDITOR", "d download .eml", "n add sender to notify.txt", "N add @domain to notify.txt") m.status = "space: " + strings.Join(hints, " · ") return m, nil case "g": @@ -6018,6 +6213,8 @@ func (m Model) View() string { return m.viewReaction() case stateContacts: return m.viewContacts() + case stateSnippets: + return m.viewSnippets() } return "" } diff --git a/internal/ui/optimistic.go b/internal/ui/optimistic.go new file mode 100644 index 0000000..869ed3c --- /dev/null +++ b/internal/ui/optimistic.go @@ -0,0 +1,181 @@ +package ui + +import ( + "fmt" + + tea "github.com/charmbracelet/bubbletea" + "github.com/sspaeti/neomd/internal/imap" +) + +// ── Optimistic list updates ────────────────────────────────────────────── +// +// Archive / move / screen / remind all take an IMAP round-trip. Waiting for it +// (and then re-fetching the whole folder, as the pre-optimistic code did) made +// every keystroke feel like a page load. Instead the rows leave the list the +// instant the key is pressed, the IMAP work runs in the background, and the +// rows come back — visibly, with an error status — only if the server refuses. +// +// Batches are tracked by id so two actions fired back to back can each roll +// back independently; the id rides along on the batchDoneMsg / reminderDoneMsg +// that acknowledges it. + +// emailKey identifies an email across folders (UIDs are only unique per folder). +func emailKey(folder string, uid uint32) string { + return fmt.Sprintf("%s\x00%d", folder, uid) +} + +// optimisticAct hides targets from the list immediately, then runs build() to +// get the IMAP command, tagging its completion message with this batch's id. +// The returned command performs the list refresh and the IMAP work together. +func (m *Model) optimisticAct(targets []imap.Email, label string, build func() tea.Cmd) tea.Cmd { + if len(targets) == 0 { + return nil + } + m.optimisticSeq++ + id := m.optimisticSeq + + gone := make(map[string]bool, len(targets)) + for _, e := range targets { + gone[emailKey(e.Folder, e.UID)] = true + } + kept := make([]imap.Email, 0, len(m.emails)) + var removed []imap.Email + for _, e := range m.emails { + if gone[emailKey(e.Folder, e.UID)] { + removed = append(removed, e) + continue + } + kept = append(kept, e) + } + m.emails = kept + if m.optimistic == nil { + m.optimistic = make(map[int][]imap.Email) + } + m.optimistic[id] = removed + m.markedUIDs = make(map[uint32]bool) + + if len(targets) > 1 { + m.status = fmt.Sprintf("%s %d…", label, len(targets)) + } else { + m.status = label + "…" + } + m.isError = false + return tea.Batch(m.applyFilter(), tagOptimistic(id, build())) +} + +// tagOptimistic stamps a completion message with the batch id that produced it +// so the handler can retire (or roll back) the right batch. +func tagOptimistic(id int, c tea.Cmd) tea.Cmd { + if c == nil { + return nil + } + return func() tea.Msg { + switch msg := c().(type) { + case batchDoneMsg: + msg.optID = id + return msg + case reminderDoneMsg: + msg.optID = id + return msg + default: + return msg + } + } +} + +// retireOptimistic drops a batch's rollback snapshot after the server confirms. +func (m *Model) retireOptimistic(id int) { + delete(m.optimistic, id) +} + +// rollbackOptimistic puts a failed batch's emails back in the list, so the user +// sees the rows return rather than silently losing the action. +func (m *Model) rollbackOptimistic(id int) tea.Cmd { + restored, ok := m.optimistic[id] + if !ok || len(restored) == 0 { + return nil + } + delete(m.optimistic, id) + m.emails = append(m.emails, restored...) + return m.sortEmails() +} + +// ── Infinite scroll ────────────────────────────────────────────────────── + +// loadMoreThreshold is how close to the last row the cursor must come before +// the next page is fetched, so the append lands before the user hits the end. +const loadMoreThreshold = 5 + +// oldestUID returns the lowest UID held for folder, i.e. the paging cursor. +// Zero means we hold nothing for that folder and cannot page. +func oldestUID(emails []imap.Email, folder string) uint32 { + var min uint32 + for _, e := range emails { + if e.Folder != folder { + continue + } + if min == 0 || e.UID < min { + min = e.UID + } + } + return min +} + +// appendNewEmails appends only emails not already held, returning how many +// were added. Zero means the folder is exhausted. +func appendNewEmails(dst []imap.Email, extra []imap.Email) ([]imap.Email, int) { + have := make(map[string]bool, len(dst)) + for _, e := range dst { + have[emailKey(e.Folder, e.UID)] = true + } + added := 0 + for _, e := range extra { + k := emailKey(e.Folder, e.UID) + if have[k] { + continue + } + have[k] = true + dst = append(dst, e) + added++ + } + return dst, added +} + +// maybeLoadMoreCmd fetches the next page when the cursor nears the bottom of +// the list. Ad-hoc views (search results, conversation, Everything) span +// folders and are not paged. +func (m *Model) maybeLoadMoreCmd() tea.Cmd { + if m.loadingMore || m.moreExhausted || m.loading { + return nil + } + if m.offTabFolder != "" || m.imapSearchResults || m.filterText != "" { + return nil + } + if m.cfg.UI.InboxCount <= 0 { + return nil // unlimited page size: the first fetch already has everything + } + n := len(m.inbox.Items()) + if n == 0 || m.inbox.Index() < n-loadMoreThreshold { + return nil + } + folder := m.activeFolder() + cursor := oldestUID(m.emails, folder) + if cursor == 0 { + return nil + } + m.loadingMore = true + return m.fetchMorePageCmd(folder, cursor) +} + +// fetchMorePageCmd fetches one more page of headers older than cursor. +func (m Model) fetchMorePageCmd(folder string, cursor uint32) tea.Cmd { + n := m.cfg.UI.InboxCount + return func() tea.Msg { + cli := m.imapCli() + if cli == nil { + return moreEmailsLoadedMsg{folder: folder} + } + emails, err := cli.FetchHeadersBefore(nil, folder, cursor, n) + return moreEmailsLoadedMsg{emails: emails, folder: folder, err: err} + } +} diff --git a/internal/ui/snippets.go b/internal/ui/snippets.go new file mode 100644 index 0000000..d181e08 --- /dev/null +++ b/internal/ui/snippets.go @@ -0,0 +1,103 @@ +package ui + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/sspaeti/neomd/internal/config" + "github.com/sspaeti/neomd/internal/snippets" +) + +// ── Snippets picker (`;`) ──────────────────────────────────────────────── +// +// Reusable email templates read from /snippets/*.md. j/k moves, +// enter starts a compose pre-filled with the snippet's subject and body, +// esc/q closes. Loaded on open so a newly-added file shows up without a +// restart. + +// openSnippetsCmd loads the snippet directory and enters the picker. +func (m Model) openSnippets() (tea.Model, tea.Cmd) { + m.snippets = snippets.Load(config.SnippetsDir()) + m.snippetsCursor = 0 + m.prevState = m.state + m.state = stateSnippets + m.status = "" + m.isError = false + return m, nil +} + +func (m Model) updateSnippets(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc", "q", ";": + m.state = m.prevState + return m, nil + case "j", "down": + if m.snippetsCursor < len(m.snippets)-1 { + m.snippetsCursor++ + } + return m, nil + case "k", "up": + if m.snippetsCursor > 0 { + m.snippetsCursor-- + } + return m, nil + case "enter": + if m.snippetsCursor >= len(m.snippets) { + return m, nil + } + return m.composeFromSnippet(m.snippets[m.snippetsCursor]) + } + // Number keys 1-9 pick directly. + if k := msg.String(); len(k) == 1 && k >= "1" && k <= "9" { + i := int(k[0] - '1') + if i < len(m.snippets) { + return m.composeFromSnippet(m.snippets[i]) + } + } + return m, nil +} + +// composeFromSnippet opens the compose form with the template's subject, and +// stashes the body so launchEditorCmd drops it into the editor buffer (the +// same path a mailto: body takes). +func (m Model) composeFromSnippet(s snippets.Snippet) (tea.Model, tea.Cmd) { + m.attachments = nil + m.compose.reset() + m.presendFromI = m.defaultFromIndex() + m.compose.subject.SetValue(s.Subject) + m.mailtoBody = s.Body + m.state = stateCompose + m.status = "Snippet: " + s.Name + m.isError = false + return m, nil +} + +func (m Model) viewSnippets() string { + var b strings.Builder + b.WriteString(styleHeader.Render(" Snippets ") + "\n\n") + if len(m.snippets) == 0 { + b.WriteString(styleHelp.Render(" No snippets yet.\n\n Drop markdown files in " + config.SnippetsDir() + "/\n" + + " An optional first line `Subject: ...` sets the subject.\n")) + b.WriteString("\n" + styleHelp.Render(" esc close")) + return b.String() + } + for i, s := range m.snippets { + cursor := " " + name := s.Name + if i == m.snippetsCursor { + cursor = "▸ " + name = styleSelected.Render(name) + } + num := " " + if i < 9 { + num = string(rune('1'+i)) + " " + } + line := cursor + styleHelp.Render(num) + name + if s.Subject != "" { + line += styleHelp.Render(" — " + s.Subject) + } + b.WriteString(line + "\n") + } + b.WriteString("\n" + styleHelp.Render(" j/k move · 1-9 pick · enter compose · esc close")) + return b.String() +} From 3a896066eb459c03f2b87e3947ef903c14ab20f6 Mon Sep 17 00:00:00 2001 From: Jon Roosevelt Date: Mon, 7 Sep 2026 06:42:41 -0400 Subject: [PATCH 08/16] ci: deploy neomd to gpu on main (#6) --- .github/workflows/deploy.yml | 47 ++++++++++++++++++++++++++++++++++++ CHANGELOG.md | 7 ++++++ README.md | 4 +++ 3 files changed, 58 insertions(+) create mode 100644 .github/workflows/deploy.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..4b2af6e --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,47 @@ +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: 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: | + expected="$(git describe --tags --always --dirty)" + actual="$(/home/jon/.local/bin/neomd --version)" + test "$actual" = "neomd $expected" || { + echo "version mismatch: expected 'neomd $expected', got '$actual'" >&2 + exit 1 + } diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f2ab99..10ff5d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +# 2026-09-07 + +- **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** — diff --git a/README.md b/README.md index 9d6fda1..33eee8d 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/)._ From 7688934f77a9edabc84b70e6875a10f6360fbf71 Mon Sep 17 00:00:00 2001 From: Jon Roosevelt Date: Mon, 7 Sep 2026 06:45:30 -0400 Subject: [PATCH 09/16] fix: verify clean build version (#7) --- .github/workflows/deploy.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 4b2af6e..4354066 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -29,6 +29,9 @@ jobs: 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 @@ -39,9 +42,8 @@ jobs: - name: Verify deployed binary run: | - expected="$(git describe --tags --always --dirty)" actual="$(/home/jon/.local/bin/neomd --version)" - test "$actual" = "neomd $expected" || { - echo "version mismatch: expected 'neomd $expected', got '$actual'" >&2 + test "$actual" = "neomd $EXPECTED_VERSION" || { + echo "version mismatch: expected 'neomd $EXPECTED_VERSION', got '$actual'" >&2 exit 1 } From 3ca235e30046e6ef964b3f90b8f7d52087af2827 Mon Sep 17 00:00:00 2001 From: Jon Roosevelt Date: Mon, 7 Sep 2026 07:06:42 -0400 Subject: [PATCH 10/16] feat: add external OAuth and read-only mode (#8) --- AGENTS.md | 14 +++++ CHANGELOG.md | 10 +++ README.md | 9 +++ cmd/neomd/main.go | 82 +++++++++++++++---------- cmd/neomd/main_test.go | 28 +++++++++ internal/config/config.go | 13 +++- internal/config/config_test.go | 45 ++++++++++++++ internal/imap/client.go | 26 +++++++- internal/imap/client_test.go | 47 ++++++++++++++ internal/oauth2/oauth2.go | 31 ++++++++++ internal/oauth2/oauth2_test.go | 24 ++++++++ internal/ui/model.go | 109 ++++++++++++++++++++++++++++++++- internal/ui/model_test.go | 69 +++++++++++++++++++++ internal/ui/optimistic.go | 5 ++ 14 files changed, 474 insertions(+), 38 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ba87f51..9bfc4bf 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 10ff5d6..478f11d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ # 2026-09-07 +- **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: diff --git a/README.md b/README.md index 33eee8d..00ccf46 100644 --- a/README.md +++ b/README.md @@ -275,6 +275,15 @@ 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). **Provider-specific guides:** diff --git a/cmd/neomd/main.go b/cmd/neomd/main.go index de03067..ded1bb5 100644 --- a/cmd/neomd/main.go +++ b/cmd/neomd/main.go @@ -52,6 +52,10 @@ func main() { fmt.Fprintf(os.Stderr, "neomd: config error: %v\n", err) 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 len(accounts) == 0 { @@ -79,40 +83,11 @@ 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 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) + if err := configureIMAPAuth(ctx, acc, &imapCfg); err != nil { + fmt.Fprintf(os.Stderr, "neomd: account %q: %v\n", acc.Name, err) os.Exit(1) } + imapCfg.ReadOnly = cfg.ReadOnly imapClients = append(imapClients, goIMAP.New(imapCfg)) } defer func() { @@ -182,6 +157,10 @@ func main() { // sender from an external widget: screener list update + sender-level // ToScreen move, same semantics as the TUI's I/O/F/P keys. if flag.NArg() > 0 && flag.Arg(0) == "screen" { + if cfg.ReadOnly { + writeScreenJSON(os.Stdout, screenOutput{Error: "neomd read-only mode: screener action blocked"}) + os.Exit(0) + } var screenCli *goIMAP.Client for _, c := range imapClients { if c != nil { @@ -240,6 +219,45 @@ func main() { } } +func configureIMAPAuth(ctx context.Context, acc config.AccountConfig, imapCfg *goIMAP.Config) error { + if len(acc.OAuth2TokenCommand) > 0 { + imapCfg.TokenSource = oauth2.CommandTokenSource(acc.OAuth2TokenCommand) + return nil + } + if acc.IsOAuth2() { + if acc.OAuth2ClientID == "" { + return fmt.Errorf("oauth2_client_id is required") + } + if acc.OAuth2IssuerURL == "" && (acc.OAuth2AuthURL == "" || acc.OAuth2TokenURL == "") { + return fmt.Errorf("set oauth2_issuer_url or both oauth2_auth_url and oauth2_token_url") + } + tokenFile, err := config.TokenFilePath(acc.Name) + if err != nil { + return err + } + 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, + }) + if err != nil { + return fmt.Errorf("oauth2: %w", err) + } + imapCfg.TokenSource = ts + return nil + } + if acc.User == "" || acc.Password == "" { + return fmt.Errorf("user/password not set") + } + return nil +} + func splitAddr(addr string) (host, port string) { i := strings.LastIndex(addr, ":") if i < 0 { diff --git a/cmd/neomd/main_test.go b/cmd/neomd/main_test.go index 19e1366..cfd57ac 100644 --- a/cmd/neomd/main_test.go +++ b/cmd/neomd/main_test.go @@ -1,7 +1,11 @@ package main import ( + "context" "testing" + + "github.com/sspaeti/neomd/internal/config" + goIMAP "github.com/sspaeti/neomd/internal/imap" ) func TestInferIMAPSecurity(t *testing.T) { @@ -79,6 +83,30 @@ func TestInferIMAPSecurity(t *testing.T) { } } +func TestConfigureIMAPAuthUsesCommandTokenWithoutNativeOAuth(t *testing.T) { + acc := config.AccountConfig{ + Name: "Arcs", + User: "jon@example.com", + AuthType: "oauth2", + OAuth2TokenCommand: []string{"/bin/echo", "fake-token"}, + } + imapCfg := goIMAP.Config{Host: "imap.example.com", Port: "993", User: acc.User, TLS: true} + if err := configureIMAPAuth(context.Background(), acc, &imapCfg); err != nil { + t.Fatalf("configureIMAPAuth() = %v", err) + } + client := goIMAP.New(imapCfg) + if client.TokenSource() == nil { + t.Fatal("command-sourced OAuth2 account did not select a token source") + } + token, err := client.TokenSource()() + if err != nil { + t.Fatalf("command token source: %v", err) + } + if token != "fake-token" { + t.Fatalf("token = %q, want fake-token", token) + } +} + func TestParseMailtoQuery_PlusPreserved(t *testing.T) { tests := []struct { name string diff --git a/internal/config/config.go b/internal/config/config.go index a22cf06..b0ecf34 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -54,6 +54,7 @@ type AccountConfig struct { OAuth2TokenURL string `toml:"oauth2_token_url"` // manual override; skips discovery OAuth2Scopes []string `toml:"oauth2_scopes"` OAuth2RedirectPort int `toml:"oauth2_redirect_port"` // local callback port; default 8085 + OAuth2TokenCommand []string `toml:"oauth2_token_command"` // optional argv returning an access token on stdout // Signature Signature SignatureConfig `toml:"signature_block"` // Per account Signature @@ -337,6 +338,10 @@ type Config struct { Accounts []AccountConfig `toml:"accounts"` Account AccountConfig `toml:"account"` // legacy single-account fallback + // ReadOnly blocks every remote IMAP mutation and every outbound delivery + // path while preserving authenticated fetch, search, and folder browsing. + ReadOnly bool `toml:"read_only"` + // StoreSentDraftsInSendingAccount controls where Sent/Drafts are stored when // multiple SMTP identities are configured. Default false: always use the // primary IMAP account (the first configured account). When true, Sent/Drafts @@ -645,12 +650,16 @@ func Load(path string) (*Config, error) { cfg.Accounts[i].Password = expandEnv(cfg.Accounts[i].Password) cfg.Accounts[i].User = expandEnv(cfg.Accounts[i].User) cfg.Accounts[i].TLSCertFile = expandPath(expandEnv(cfg.Accounts[i].TLSCertFile)) - cfg.Accounts[i].Password = resolveKeyringPassword(cfg.Accounts[i].Name, cfg.Accounts[i].Password) + if len(cfg.Accounts[i].OAuth2TokenCommand) == 0 { + cfg.Accounts[i].Password = resolveKeyringPassword(cfg.Accounts[i].Name, cfg.Accounts[i].Password) + } } cfg.Account.Password = expandEnv(cfg.Account.Password) cfg.Account.User = expandEnv(cfg.Account.User) cfg.Account.TLSCertFile = expandPath(expandEnv(cfg.Account.TLSCertFile)) - cfg.Account.Password = resolveKeyringPassword(cfg.Account.Name, cfg.Account.Password) + if len(cfg.Account.OAuth2TokenCommand) == 0 { + cfg.Account.Password = resolveKeyringPassword(cfg.Account.Name, cfg.Account.Password) + } cfg.Listmonk.APIToken = expandEnv(cfg.Listmonk.APIToken) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 8a359ec..1df25d7 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -266,6 +266,51 @@ func TestLoad_MissingConfigCreatesDefault(t *testing.T) { } } +func TestLoadOAuth2TokenCommandWithoutNativeOAuthSettings(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.toml") + listDir := filepath.Join(dir, "lists") + contents := `read_only = true + +[[accounts]] +name = "Arcs" +imap = "imap.example.com:993" +smtp = "smtp.example.com:587" +user = "jon@example.com" +password = "keyring" +auth_type = "oauth2" +oauth2_token_command = ["/bin/echo", "fake-token"] + +[screener] +screened_in = "` + filepath.Join(listDir, "screened_in.txt") + `" +screened_out = "` + filepath.Join(listDir, "screened_out.txt") + `" +feed = "` + filepath.Join(listDir, "feed.txt") + `" +papertrail = "` + filepath.Join(listDir, "papertrail.txt") + `" +spam = "` + filepath.Join(listDir, "spam.txt") + `" +notify = "` + filepath.Join(listDir, "notify.txt") + `" +` + if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { + t.Fatal(err) + } + + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load() rejected command-sourced OAuth2 config: %v", err) + } + if !cfg.ReadOnly { + t.Fatal("read_only = true was not parsed") + } + if len(cfg.Accounts) != 1 || len(cfg.Accounts[0].OAuth2TokenCommand) != 2 { + t.Fatalf("OAuth2TokenCommand = %#v, want two argv entries", cfg.Accounts[0].OAuth2TokenCommand) + } + if cfg.Accounts[0].OAuth2ClientID != "" || cfg.Accounts[0].OAuth2IssuerURL != "" { + t.Fatal("fixture unexpectedly supplied native OAuth settings") + } + if cfg.Accounts[0].Password != keyringSentinel { + t.Fatalf("command-sourced account password = %q, want untouched keyring sentinel", cfg.Accounts[0].Password) + } +} + func TestWriteDefault_FilePermissions(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config.toml") diff --git a/internal/imap/client.go b/internal/imap/client.go index 2816ea1..78dffa3 100644 --- a/internal/imap/client.go +++ b/internal/imap/client.go @@ -70,6 +70,7 @@ type Config struct { STARTTLS bool // STARTTLS upgrade (port 143) TLSCertFile string // optional PEM CA/cert for self-signed local bridges TokenSource func() (string, error) // The token is used instead of the password for OAuth2 Accounts + ReadOnly bool // refuse APPEND, MOVE, STORE, CREATE, and EXPUNGE operations } // Client wraps an IMAP connection with reconnection management. @@ -186,6 +187,9 @@ func (c *Client) reconnect(ctx context.Context) error { // withConn runs fn on the IMAP connection, reconnecting if needed. // Does NOT retry on network errors — safe for mutating operations (APPEND, MOVE, STORE). func (c *Client) withConn(ctx context.Context, fn func(*imapclient.Client) error) error { + if c.cfg.ReadOnly { + return ErrReadOnly + } return c.withConnRetryable(ctx, fn, false) } @@ -241,17 +245,29 @@ func (c *Client) withConnRetryable(ctx context.Context, fn func(*imapclient.Clie return nil } +// ErrReadOnly is returned before any remote mutation when read-only mode is enabled. +var ErrReadOnly = errors.New("neomd read-only mode: remote mutation blocked") + func (c *Client) selectMailbox(mailbox string) error { if c.selectedMailbox == mailbox { return nil } - if _, err := c.conn.Select(mailbox, nil).Wait(); err != nil { - return fmt.Errorf("SELECT %q: %w", mailbox, err) + options := mailboxSelectOptions(c.cfg.ReadOnly) + if _, err := c.conn.Select(mailbox, options).Wait(); err != nil { + command := "SELECT" + if c.cfg.ReadOnly { + command = "EXAMINE" + } + return fmt.Errorf("%s %q: %w", command, mailbox, err) } c.selectedMailbox = mailbox return nil } +func mailboxSelectOptions(readOnly bool) *imap.SelectOptions { + return &imap.SelectOptions{ReadOnly: readOnly} +} + // Close logs out and closes the IMAP connection. func (c *Client) Close() { c.mu.Lock() @@ -1256,6 +1272,9 @@ func (c *Client) claimReminderSource(ctx context.Context, source Email, folders } func (c *Client) ParkReminder(ctx context.Context, source Email, folders []string, waiting, trash string, at time.Time) error { + if c.cfg.ReadOnly { + return ErrReadOnly + } if ctx == nil { ctx = context.Background() } @@ -1488,6 +1507,9 @@ func (c *Client) SaveDraft(ctx context.Context, folder string, raw []byte) error // SaveReminder idempotently APPENDs a parked email to the reminder folder. func (c *Client) SaveReminder(ctx context.Context, folder string, raw []byte) error { + if c.cfg.ReadOnly { + return ErrReadOnly + } if ctx == nil { ctx = context.Background() } diff --git a/internal/imap/client_test.go b/internal/imap/client_test.go index d7eb925..6adf02a 100644 --- a/internal/imap/client_test.go +++ b/internal/imap/client_test.go @@ -10,6 +10,7 @@ import ( "crypto/x509" "crypto/x509/pkix" "encoding/base64" + "errors" "math/big" "net" "strings" @@ -932,3 +933,49 @@ func TestResetMailboxSelection(t *testing.T) { t.Errorf("ResetMailboxSelection() did not clear selectedMailbox: got %q, want empty string", c.selectedMailbox) } } + +func TestReadOnlyBlocksMutationsBeforeDial(t *testing.T) { + c := New(Config{ + Host: "imap.example.com", + Port: "993", + TLS: true, + ReadOnly: true, + }) + + checks := []struct { + name string + run func() error + }{ + {"move", func() error { _, err := c.MoveMessage(nil, "INBOX", 1, "Trash"); return err }}, + {"create", func() error { _, err := c.EnsureFolders(nil, []string{"Archive"}); return err }}, + {"expunge", func() error { return c.ExpungeAll(nil, "Trash", []uint32{1}) }}, + {"mark seen", func() error { return c.MarkSeen(nil, "INBOX", 1) }}, + {"mark unseen", func() error { return c.MarkUnseen(nil, "INBOX", 1) }}, + {"mark answered", func() error { return c.MarkAnswered(nil, "INBOX", 1) }}, + {"mark flagged", func() error { return c.MarkFlagged(nil, "INBOX", 1) }}, + {"save sent", func() error { return c.SaveSent(nil, "Sent", []byte("message")) }}, + {"save draft", func() error { return c.SaveDraft(nil, "Drafts", []byte("message")) }}, + {"park reminder", func() error { return c.ParkReminder(nil, Email{}, nil, "Waiting", "Trash", time.Now()) }}, + {"save reminder", func() error { return c.SaveReminder(nil, "Waiting", nil) }}, + } + + for _, check := range checks { + t.Run(check.name, func(t *testing.T) { + if err := check.run(); !errors.Is(err, ErrReadOnly) { + t.Fatalf("error = %v, want ErrReadOnly", err) + } + }) + } + if c.conn != nil { + t.Fatal("read-only mutation guard dialed IMAP") + } +} + +func TestReadOnlyMailboxSelectionUsesExamine(t *testing.T) { + if !mailboxSelectOptions(true).ReadOnly { + t.Fatal("read-only client must request IMAP EXAMINE") + } + if mailboxSelectOptions(false).ReadOnly { + t.Fatal("ordinary client must retain IMAP SELECT behavior") + } +} diff --git a/internal/oauth2/oauth2.go b/internal/oauth2/oauth2.go index e0f919b..fcbf028 100644 --- a/internal/oauth2/oauth2.go +++ b/internal/oauth2/oauth2.go @@ -21,6 +21,7 @@ import ( "os/exec" "path/filepath" "runtime" + "strings" "time" "github.com/emersion/go-sasl" @@ -304,6 +305,36 @@ func (c *xoauth2Client) Next(_ []byte) ([]byte, error) { return []byte{}, nil } +// CommandTokenSource runs a trusted, configuration-owned argv each time an +// OAuth2 access token is needed. The token stays in process memory and is +// never logged or persisted by neomd. +func CommandTokenSource(argv []string) func() (string, error) { + return func() (string, error) { + if len(argv) == 0 || strings.TrimSpace(argv[0]) == "" { + return "", fmt.Errorf("oauth2 token command is empty") + } + program := argv[0] + if strings.HasPrefix(program, "~/") { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve oauth2 token command home: %w", err) + } + program = filepath.Join(home, program[2:]) + } + cmd := exec.Command(program, argv[1:]...) + cmd.Stderr = io.Discard + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("oauth2 token command failed: %w", err) + } + token := strings.TrimSpace(string(out)) + if token == "" { + return "", fmt.Errorf("oauth2 token command returned an empty token") + } + return token, nil + } +} + // tokenStorage persists OAuth2 tokens with a keyring-first / file-fallback policy. // Flow: // - Save: try keyring (if AccountName set); if it fails (or no name), write to file. diff --git a/internal/oauth2/oauth2_test.go b/internal/oauth2/oauth2_test.go index 86fac09..4f790d3 100644 --- a/internal/oauth2/oauth2_test.go +++ b/internal/oauth2/oauth2_test.go @@ -41,6 +41,30 @@ func TestXOAuth2Client_Next(t *testing.T) { } } +func TestCommandTokenSource(t *testing.T) { + ts := CommandTokenSource([]string{"sh", "-c", "printf 'fake-access-token\\n'"}) + token, err := ts() + if err != nil { + t.Fatal(err) + } + if token != "fake-access-token" { + t.Fatalf("token = %q, want trimmed command output", token) + } +} + +func TestCommandTokenSourceRejectsEmptyOutput(t *testing.T) { + ts := CommandTokenSource([]string{"sh", "-c", "exit 0"}) + if _, err := ts(); err == nil { + t.Fatal("expected empty token command output to fail") + } +} + +func TestCommandTokenSourceRejectsEmptyArgv(t *testing.T) { + if _, err := CommandTokenSource(nil)(); err == nil { + t.Fatal("expected empty token command argv to fail") + } +} + // --- Token persistence --- func TestSaveAndLoadToken(t *testing.T) { diff --git a/internal/ui/model.go b/internal/ui/model.go index a3c1740..452def6 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -940,7 +940,7 @@ func (m Model) Init() tea.Cmd { m.scheduleBgSync(), m.checkOverdueScheduledCmd(), } - if config.IsFirstRun() { + if config.IsFirstRun() && !m.cfg.ReadOnly { cmds = append(cmds, m.ensureFoldersCmd()) } return tea.Batch(cmds...) @@ -1047,7 +1047,22 @@ func (m Model) fetchBodyCmd(e *imap.Email) tea.Cmd { } } +func readOnlyError(kind string) error { + return fmt.Errorf("neomd read-only mode: %s blocked", kind) +} + +func readOnlyBatchCmd(kind string) tea.Cmd { + return func() tea.Msg { + return batchDoneMsg{err: readOnlyError(kind)} + } +} + func (m Model) sendEmailCmd(smtpAcct config.AccountConfig, from, to, cc, bcc, subject, body string, attachments []string, includeHTMLSig bool, replyToUID uint32, replyToFolder, replyToAccount, inReplyTo, references string) tea.Cmd { + if m.cfg != nil && m.cfg.ReadOnly { + return func() tea.Msg { + return sendDoneMsg{err: readOnlyError("outbound email")} + } + } h, p := splitAddr(smtpAcct.SMTP) cfg := smtp.Config{ Host: h, @@ -1105,6 +1120,11 @@ func (m Model) sendEmailCmd(smtpAcct config.AccountConfig, from, to, cc, bcc, su // --headless`) — see internal/schedule. Reply \Answered marking is skipped: // the TUI may be long gone when the message actually goes out. func (m Model) scheduleSendCmd(smtpAcct config.AccountConfig, from, to, cc, bcc, subject, body string, attachments []string, includeHTMLSig bool, inReplyTo, references string, sendAt time.Time) tea.Cmd { + if m.cfg != nil && m.cfg.ReadOnly { + return func() tea.Msg { + return scheduleDoneMsg{err: readOnlyError("scheduled email")} + } + } cli := m.presendIMAPClient() folder := m.cfg.Folders.Scheduled htmlSignature := "" @@ -1139,6 +1159,11 @@ func (m Model) listmonkTriggers() []listmonk.Trigger { } func (m Model) sendListmonkCmd(subject, markdownBody string, listIDs []int, templateID int) tea.Cmd { + if m.cfg != nil && m.cfg.ReadOnly { + return func() tea.Msg { + return sendDoneMsg{err: readOnlyError("outbound campaign")} + } + } cfg := m.cfg.Listmonk delay := time.Duration(cfg.DelayMinutes) * time.Minute if delay == 0 { @@ -1212,6 +1237,11 @@ func (m Model) sendReaction(emojiIndex int) (tea.Model, tea.Cmd) { } func (m Model) sendReactionCmd(smtpAcct config.AccountConfig, from, to, subject, bodyMarkdown string, originalEmail *imap.Email) tea.Cmd { + if m.cfg != nil && m.cfg.ReadOnly { + return func() tea.Msg { + return sendDoneMsg{err: readOnlyError("outbound reaction")} + } + } h, p := splitAddr(smtpAcct.SMTP) cfg := smtp.Config{ Host: h, @@ -1287,6 +1317,9 @@ func collectRcptTo(to, cc, bcc string) []string { // toggleSeenCmd flips the \Seen flag on an email and updates local state. func (m Model) toggleSeenCmd(e *imap.Email) tea.Cmd { + if m.cfg != nil && m.cfg.ReadOnly { + return readOnlyBatchCmd("mark read") + } uid := e.UID folder := e.Folder newSeen := !e.Seen @@ -1303,6 +1336,9 @@ func (m Model) toggleSeenCmd(e *imap.Email) tea.Cmd { // moveEmailCmd moves a single email to dst without updating screener lists. func (m Model) moveEmailCmd(e *imap.Email, dst string) tea.Cmd { + if m.cfg != nil && m.cfg.ReadOnly { + return readOnlyBatchCmd("IMAP move") + } src := e.Folder uid := e.UID return func() tea.Msg { @@ -1466,6 +1502,9 @@ func (m *Model) cancelDiscardConfirm() { // batchMoveCmd moves a slice of emails to dst, emitting batchDoneMsg. func (m Model) batchMoveCmd(emails []imap.Email, dst string) tea.Cmd { + if m.cfg != nil && m.cfg.ReadOnly { + return readOnlyBatchCmd("IMAP move") + } type mv struct { folder string uid uint32 @@ -1494,6 +1533,9 @@ func (m Model) batchMoveCmd(emails []imap.Email, dst string) tea.Cmd { // undoMovesCmd reverses a batch of moves by moving each email back to its // original folder. Non-fatal per-email errors are reported as a batchDoneMsg. func (m Model) undoMovesCmd(moves []undoMove) tea.Cmd { + if m.cfg != nil && m.cfg.ReadOnly { + return readOnlyBatchCmd("IMAP move") + } cli := m.imapCli() return func() tea.Msg { for i, u := range moves { @@ -1507,6 +1549,9 @@ func (m Model) undoMovesCmd(moves []undoMove) tea.Cmd { // batchScreenerCmd runs a screener action (I/O/F/P) on multiple emails. func (m Model) batchScreenerCmd(emails []imap.Email, action string) tea.Cmd { + if m.cfg != nil && m.cfg.ReadOnly { + return readOnlyBatchCmd("screener action") + } sc := m.screener cfg := m.cfg type op struct { @@ -1631,6 +1676,9 @@ func (m Model) batchScreenerCmd(emails []imap.Email, action string) tea.Cmd { // markAllSeenCmd marks every currently loaded email in the folder as \Seen. func (m Model) markAllSeenCmd() tea.Cmd { + if m.cfg != nil && m.cfg.ReadOnly { + return readOnlyBatchCmd("mark read") + } type op struct { folder string uid uint32 @@ -1656,6 +1704,9 @@ func (m Model) markAllSeenCmd() tea.Cmd { // batchToggleSeenCmd toggles \Seen on multiple emails, emitting batchDoneMsg. func (m Model) batchToggleSeenCmd(emails []imap.Email) tea.Cmd { + if m.cfg != nil && m.cfg.ReadOnly { + return readOnlyBatchCmd("mark read") + } type op struct { folder string uid uint32 @@ -1701,6 +1752,11 @@ func (m Model) classifyForScreen(emails []imap.Email) []autoScreenMove { // (Approve or Block on a "@domain" entry). Reloads the active folder so the // view reflects any senders that have just been reclassified. func (m Model) execDomainScreen(op *pendingDomainAction) (tea.Model, tea.Cmd) { + if m.cfg != nil && m.cfg.ReadOnly { + m.status = readOnlyError("domain screener action").Error() + m.isError = true + return m, nil + } var err error switch op.action { case "I": @@ -1883,6 +1939,9 @@ func (m Model) resetToScreenSearchCmd() tea.Cmd { // resetToScreenMoveCmd bulk-moves all given UIDs from ToScreen back to Inbox. func (m Model) resetToScreenMoveCmd(uids []uint32) tea.Cmd { + if m.cfg != nil && m.cfg.ReadOnly { + return readOnlyBatchCmd("IMAP move") + } src := m.cfg.Folders.ToScreen dst := m.cfg.Folders.Inbox return func() tea.Msg { @@ -1897,6 +1956,11 @@ func (m Model) resetToScreenMoveCmd(uids []uint32) tea.Cmd { // ensureFoldersCmd creates any configured folders that don't exist yet. func (m Model) ensureFoldersCmd() tea.Cmd { + if m.cfg != nil && m.cfg.ReadOnly { + return func() tea.Msg { + return ensureFoldersDoneMsg{err: readOnlyError("folder creation")} + } + } f := m.cfg.Folders folders := []string{ f.Inbox, f.Sent, f.Trash, f.Drafts, @@ -1939,6 +2003,9 @@ func (m Model) emptyTrashSearchCmd() tea.Cmd { // deleteAllExecCmd permanently deletes all given UIDs from folder. func (m Model) deleteAllExecCmd(folder string, uids []uint32) tea.Cmd { + if m.cfg != nil && m.cfg.ReadOnly { + return readOnlyBatchCmd("IMAP expunge") + } return func() tea.Msg { return batchDoneMsg{err: m.imapCli().ExpungeAll(nil, folder, uids)} } @@ -2009,6 +2076,11 @@ func newReminderInput() textinput.Model { // setRemindersCmd parks every target email, emitting a single reminderDoneMsg. func (m Model) setRemindersCmd(targets []imap.Email, at time.Time) tea.Cmd { + if m.cfg != nil && m.cfg.ReadOnly { + return func() tea.Msg { + return reminderDoneMsg{err: readOnlyError("reminder")} + } + } waiting, trash, inbox := m.cfg.Folders.Waiting, m.cfg.Folders.Trash, m.cfg.Folders.Inbox if strings.EqualFold(waiting, inbox) { return func() tea.Msg { @@ -2032,6 +2104,11 @@ func (m Model) setRemindersCmd(targets []imap.Email, at time.Time) tea.Cmd { } func (m Model) setReminderCmd(e *imap.Email, at time.Time) tea.Cmd { + if m.cfg != nil && m.cfg.ReadOnly { + return func() tea.Msg { + return reminderDoneMsg{err: readOnlyError("reminder")} + } + } var source imap.Email if e != nil { source = *e @@ -2171,6 +2248,9 @@ func folderLabelToIMAP(label string, fc config.FoldersConfig) string { // bgExecAutoScreenCmd silently moves emails and returns bgScreenDoneMsg. func (m Model) bgExecAutoScreenCmd(moves []autoScreenMove) tea.Cmd { + if m.cfg != nil && m.cfg.ReadOnly { + return readOnlyBatchCmd("automatic screening") + } src := m.cfg.Folders.Inbox total := len(moves) return func() tea.Msg { @@ -2187,6 +2267,9 @@ func (m Model) bgExecAutoScreenCmd(moves []autoScreenMove) tea.Cmd { // execAutoScreenCmd performs the IMAP moves for a pre-approved list of moves. func (m Model) execAutoScreenCmd(moves []autoScreenMove) tea.Cmd { + if m.cfg != nil && m.cfg.ReadOnly { + return readOnlyBatchCmd("automatic screening") + } src := m.cfg.Folders.Inbox bp := m.bulkProgress return func() tea.Msg { @@ -2203,6 +2286,9 @@ func (m Model) execAutoScreenCmd(moves []autoScreenMove) tea.Cmd { } func (m Model) screenerCmd(e *imap.Email, action string) tea.Cmd { + if m.cfg != nil && m.cfg.ReadOnly { + return readOnlyBatchCmd("screener action") + } folder := m.activeFolder() return func() tea.Msg { var dst string @@ -2344,7 +2430,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Controlled by ui.auto_screen_on_load (default true). // Skip when all screener lists are empty — otherwise every email would // be moved to ToScreen on first run, confusing new users. - if msg.folder == m.cfg.Folders.Inbox && m.cfg.UI.AutoScreen() && !m.screener.IsEmpty() { + if msg.folder == m.cfg.Folders.Inbox && !m.cfg.ReadOnly && m.cfg.UI.AutoScreen() && !m.screener.IsEmpty() { if err := m.validateScreenerSafety(); err != nil { m.status = err.Error() m.isError = true @@ -2958,6 +3044,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.bgSyncInProgress = false return m, nil } + if m.cfg.ReadOnly { + m.bgSyncInProgress = false + return m, nil + } if err := m.validateScreenerSafety(); err != nil { m.status = err.Error() m.isError = true @@ -4028,6 +4118,11 @@ func (m Model) handleChord(prefix, key string) (tea.Model, tea.Cmd) { if len(targets) == 0 { return m, nil } + if m.cfg != nil && m.cfg.ReadOnly { + m.status = readOnlyError("IMAP move").Error() + m.isError = true + return m, nil + } dstMap := map[string]string{ "i": m.cfg.Folders.Inbox, "a": m.cfg.Folders.Archive, @@ -4748,6 +4843,11 @@ func (m Model) calendarInvite() *imap.Attachment { // sendRSVPCmd builds an iMIP REPLY message and sends it to the event // organizer. The responder address is the active account's user email. func (m Model) sendRSVPCmd(status calendar.Status) tea.Cmd { + if m.cfg != nil && m.cfg.ReadOnly { + return func() tea.Msg { + return rsvpDoneMsg{status: status, err: readOnlyError("outbound calendar response")} + } + } att := m.calendarInvite() if att == nil { m.status = "No calendar invite to RSVP to." @@ -5556,6 +5656,11 @@ func (m Model) previewInBrowser() (tea.Model, tea.Cmd) { } func (m Model) saveDraftCmd(imapCli *imap.Client, from, to, cc, bcc, subject, body string, attachments []string) tea.Cmd { + if m.cfg != nil && m.cfg.ReadOnly { + return func() tea.Msg { + return saveDraftDoneMsg{err: readOnlyError("draft save")} + } + } folder := m.cfg.Folders.Drafts return func() tea.Msg { raw, err := smtp.BuildDraftMessage(from, to, cc, bcc, subject, body, attachments) diff --git a/internal/ui/model_test.go b/internal/ui/model_test.go index 850cdd0..300dc20 100644 --- a/internal/ui/model_test.go +++ b/internal/ui/model_test.go @@ -11,12 +11,81 @@ import ( "time" tea "github.com/charmbracelet/bubbletea" + "github.com/sspaeti/neomd/internal/calendar" "github.com/sspaeti/neomd/internal/config" "github.com/sspaeti/neomd/internal/imap" "github.com/sspaeti/neomd/internal/reminder" "github.com/sspaeti/neomd/internal/screener" ) +func TestReadOnlyBlocksPR5ActionsBeforeNetwork(t *testing.T) { + client := imap.New(imap.Config{Host: "imap.example.com", Port: "993", TLS: true, ReadOnly: true}) + m := Model{ + cfg: &config.Config{ReadOnly: true}, + clients: []*imap.Client{client}, + emails: []imap.Email{{UID: 1, Folder: "INBOX", From: "sender@example.com"}}, + markedUIDs: map[uint32]bool{1: true}, + } + + assertBatchBlocked := func(name string, cmd tea.Cmd) { + t.Helper() + if cmd == nil { + t.Fatalf("%s returned nil", name) + } + msg, ok := cmd().(batchDoneMsg) + if !ok || msg.err == nil || !strings.Contains(msg.err.Error(), "read-only") { + t.Fatalf("%s result = %#v, want read-only batch error", name, msg) + } + } + + assertBatchBlocked("archive", m.batchMoveCmd(m.emails, "Archive")) + assertBatchBlocked("screener", m.batchScreenerCmd(m.emails, "I")) + assertBatchBlocked("mark read", m.toggleSeenCmd(&m.emails[0])) + updated, cmd := m.handleChord("M", "a") + if cmd != nil || !strings.Contains(updated.(Model).status, "read-only") { + t.Fatalf("M* move did not report read-only before network: status=%q cmd=%v", updated.(Model).status, cmd != nil) + } + + updated, cmd = m.updateInbox(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}}) + if cmd == nil || !strings.Contains(updated.(Model).status, "read-only") { + t.Fatalf("e archive did not report read-only before network: status=%q cmd=%v", updated.(Model).status, cmd != nil) + } +} + +func TestReadOnlyBlocksOutboundActionsBeforeNetwork(t *testing.T) { + m := Model{cfg: &config.Config{ReadOnly: true}} + + assertBlocked := func(name string, cmd tea.Cmd) { + t.Helper() + if cmd == nil { + t.Fatalf("%s returned nil instead of a blocked result", name) + } + var err error + switch msg := cmd().(type) { + case sendDoneMsg: + err = msg.err + case scheduleDoneMsg: + err = msg.err + case rsvpDoneMsg: + err = msg.err + case saveDraftDoneMsg: + err = msg.err + default: + t.Fatalf("%s result = %T, want blocked result", name, msg) + } + if err == nil || !strings.Contains(err.Error(), "read-only") { + t.Fatalf("%s error = %v, want read-only refusal", name, err) + } + } + + assertBlocked("email", m.sendEmailCmd(config.AccountConfig{}, "", "", "", "", "", "", nil, false, 0, "", "", "", "")) + assertBlocked("scheduled email", m.scheduleSendCmd(config.AccountConfig{}, "", "", "", "", "", "", nil, false, "", "", time.Now())) + assertBlocked("campaign", m.sendListmonkCmd("", "", nil, 0)) + assertBlocked("reaction", m.sendReactionCmd(config.AccountConfig{}, "", "", "", "", &imap.Email{})) + assertBlocked("calendar response", m.sendRSVPCmd(calendar.StatusAccepted)) + assertBlocked("draft", m.saveDraftCmd(imap.New(imap.Config{Host: "imap.example.com"}), "", "", "", "", "", "", nil)) +} + func TestMaskEmail(t *testing.T) { tests := []struct { input string diff --git a/internal/ui/optimistic.go b/internal/ui/optimistic.go index 869ed3c..00501cc 100644 --- a/internal/ui/optimistic.go +++ b/internal/ui/optimistic.go @@ -31,6 +31,11 @@ func (m *Model) optimisticAct(targets []imap.Email, label string, build func() t if len(targets) == 0 { return nil } + if m.cfg != nil && m.cfg.ReadOnly { + m.status = readOnlyError(label).Error() + m.isError = true + return readOnlyBatchCmd(label) + } m.optimisticSeq++ id := m.optimisticSeq From 6ec0cad6b752e6e251cdbfb5134569926f37b26b Mon Sep 17 00:00:00 2001 From: Jon Roosevelt Date: Mon, 7 Sep 2026 08:08:38 -0400 Subject: [PATCH 11/16] feat(ui): align superhuman vim shortcuts (#9) --- AGENTS.md | 26 +- CHANGELOG.md | 13 + docs/content/docs/keybindings.md | 290 ++++++++------------- docs/keys.md | 119 +++++++++ internal/ui/email_keys_test.go | 17 +- internal/ui/keys.go | 271 ++++++++------------ internal/ui/keys_test.go | 158 ++++++++++++ internal/ui/model.go | 418 ++++++++++++++++++++++++++----- internal/ui/model_test.go | 30 +++ internal/ui/reader.go | 16 +- 10 files changed, 929 insertions(+), 429 deletions(-) create mode 100644 docs/keys.md create mode 100644 internal/ui/keys_test.go diff --git a/AGENTS.md b/AGENTS.md index 9bfc4bf..5771603 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -152,12 +152,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 @@ -399,13 +399,18 @@ layer — do not rebind them, and do not let a new binding shadow one. 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`. -- **Case convention is LOWERCASE for email actions.** `i`/`o`/`p`/`b`/`t`/`v` replaced - `I`/`O`/`P`/`B`/`T`/`V` (uppercase kept as aliases). Six keys had to stay uppercase - because their lowercase letter is already load-bearing — the exception list is - `F` (f=forward), `S` (s=compose), `U` (u=page up), `X` (x=trash), `N` (n=toggle read), - `R` (r=reply). Documented in the `keys.go` header comment; keep both in sync. +- **Case convention is LOWERCASE for email actions.** `i`/`o`/`p`/`b`/`t` remain the + primary action keys, while `F` stays uppercase because `f` is forward. 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. @@ -453,8 +458,9 @@ layer — do not rebind them, and do not let a new binding shadow one. - **`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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 478f11d..4075413 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ # 2026-09-07 +- **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. diff --git a/docs/content/docs/keybindings.md b/docs/content/docs/keybindings.md index bc3943f..2bd40c1 100644 --- a/docs/content/docs/keybindings.md +++ b/docs/content/docs/keybindings.md @@ -12,218 +12,146 @@ To update both the help overlay and this document at once, edit that file and ru -### Navigation - -| Key | Action | -|-----|--------| -| `j / k` | move down / up | -| `d / u` | page down / up in inbox/help | -| `gg` | jump to top | -| `G` | jump to bottom | -| `enter / l` | open email | -| `q / esc` | back to inbox (from reader) — h is now remind | -| `?` | toggle help overlay (type to filter) | - - -### Folders +### Actions | Key | Action | |-----|--------| -| `L / ] / tab` | next folder tab | -| `H / [ / shift+tab` | previous folder tab | -| `gi` | go to Inbox | -| `ga` | go to Archive | -| `gf` | go to Feed | -| `gp` | go to PaperTrail | -| `gt` | go to Trash | -| `gs` | go to Sent | -| `gk` | go to ToScreen | -| `go` | go to ScreenedOut | -| `gw` | go to Waiting | -| `gc` | go to Scheduled (calendar) | -| `gb` | go to Work (if configured) | -| `gm` | go to Someday | -| `gd` | go to Drafts | -| `ge` | go to Everything — latest 50 emails across all folders | -| `gS` | go to Spam (not in tab rotation) | - - -### Core actions (marked or cursor — instant, no refresh) +| `e` | mark done / archive (alias A) | +| `E` | mark not done / move back to Inbox | +| `h` | remind me | +| `u` | undo the last move or delete | +| `U / z` | toggle unread-only view | +| `n` | mark read / unread | +| `N` | jump to next unread | +| `i` | approve sender / screened-in | +| `o` | open the selected email | +| `O` | screen out in the inbox; expand all in a conversation | +| `p` | mark as PaperTrail | +| `b` | move to Work | +| `ctrl+e` | open the emoji reaction picker | +| `@` | show every email from the selected sender across folders | +| `! / $` | mark spam | +| `# / dd` | move to Trash | +| `F` | mark as Feed (uppercase: f is forward) | +| `S` | dry-run screen | +| `R` | reload the current folder | + + +### Conversations | Key | Action | |-----|--------| -| `e` | archive / mark done — move to Archive, no screener update (alias A) | -| `h` | remind me — park in Waiting until a chosen time, then back to Inbox | -| `s` | start a new email (compose) (alias c) | -| `;` | snippets — pick a template from /snippets/*.md | +| `r` | reply | +| `ctrl+r` | reply all | +| `f` | forward | +| `t` | open the full conversation thread (alias T) | +| `O` | expand all messages in a conversation / open in browser elsewhere | +| `enter` | open the selected email | +| `q / esc` | back to the inbox | +| `n / p` | next / previous message in a thread | -### Screener (marked or cursor, any folder) +### Messages | Key | Action | |-----|--------| -| `i` | approve sender → screened_in.txt + move to Inbox (removes from blocked lists) (alias I) | -| `o` | block sender → screened_out.txt + move to ScreenedOut (removes from screened_in) (alias O) | -| `$` | mark as Spam → spam.txt + move to Spam (removes from screened_in/out) | -| `F` | mark as Feed → feed.txt + move to Feed (uppercase: f is forward) | -| `p` | mark as PaperTrail → papertrail.txt + move to PaperTrail (alias P) | -| `Di` | approve whole DOMAIN → @domain.tld appended to screened_in.txt (cursor or open email; y/n) | -| `Do` | block whole DOMAIN → @domain.tld appended to screened_out.txt (cursor or open email; y/n) | -| `b` | move to Work/business (no screener update, if configured) (alias B) | -| `S` | dry-run screen inbox (loaded emails), then y/n (uppercase: s is compose) | - +| `s / c` | start a new email | +| `;` | open the snippets picker | +| `o` | open the selected email (enter is the full-word alias) | +| `o` | open links and attachments in the browser | +| `1-9` | open attachment 1-9 | +| `1-0` | open link 1-10 | +| `e` | open the raw email in the editor | -### Move (marked or cursor, no screener update) -| Key | Action | -|-----|--------| -| `x` | delete → Trash | -| `Mi` | move to Inbox | -| `Ma` | move to Archive | -| `Mf` | move to Feed | -| `Mp` | move to PaperTrail | -| `Mt` | move to Trash | -| `Ms` | move to Sent | -| `Mo` | move to ScreenedOut | -| `Mw` | move to Waiting | -| `Mc` | move to Scheduled | -| `Mb` | move to Work (if configured) | -| `Mm` | move to Someday | -| `Mk` | move to ToScreen | - - -### Multi-select & Undo - -| Key | Action | -|-----|--------| -| `m` | mark / unmark email + advance cursor | -| `ctrl+u` | clear all marks | -| `U` | undo last move or delete (reverses x, e, M* — not screener actions) (uppercase: u is page up) | -| `X (Trash only)` | permanently delete marked or cursor email(s) — no undo (uppercase: x is move to Trash) | - - -### Leader Key Mappings (space prefix) +### Folders | Key | Action | |-----|--------| -| `1 … 9` | jump to folder tab by number (Inbox=1, ToScreen=2, …) | -| `/` | IMAP search ALL emails on server (From + Subject) | -| `c` | contacts picker — browse harvested + [contacts] file names; / filter, y copy address, Y copy "Name ", enter compose to contact | -| `S` | scan current folder for spy pixels (skips already scanned) | -| `e (reader)` | open the email in $EDITOR read-only | -| `d (reader)` | download raw email source (.eml) to ~/Downloads | -| `n (reader)` | append open email's sender to notify.txt (desktop notifications opt-in) | -| `N (reader)` | append @domain of open email's sender to notify.txt | -| `va (reader, calendar invite)` | RSVP accept — send iMIP REPLY to organizer | -| `vd (reader, calendar invite)` | RSVP decline | -| `vt (reader, calendar invite)` | RSVP tentative | -| `vo (reader, calendar invite)` | open .ics in [calendar].open_command (default xdg-open, e.g. morgen) | -| `w` | show welcome screen | - - -### Sort (, prefix) +| `gi` | Inbox | +| `gs` | Starred (when configured; otherwise reports unavailable) | +| `gd` | Drafts | +| `gt` | Sent | +| `ge` | Done / Archive | +| `gh` | Reminders / Waiting | +| `g;` | Snippets | +| `g!` | Spam | +| `g#` | Trash | +| `ga` | All Mail / Everything | +| `gl` | open the label / folder picker | +| `l` | add or remove a label with the folder picker | +| `go` | Other / ScreenedOut | +| `gm` | Muted / Someday | +| `gf` | Feed (neomd) | +| `gp` | PaperTrail (neomd) | +| `gk` | ToScreen (neomd) | +| `gw` | Waiting alias (neomd) | +| `gc` | Scheduled (neomd) | +| `gb` | Work (neomd, if configured) | +| `v / M*` | move to a folder with the picker / quick destination | +| `y` | remove label / move back to Inbox | + + +### Selection | Key | Action | |-----|--------| -| `,m` | date newest first (default) | -| `,M` | date oldest first | -| `,a` | from A→Z | -| `,A` | from Z→A | -| `,s` | size smallest first | -| `,S` | size largest first | -| `,n` | subject A→Z | -| `,N` | subject Z→A | - +| `x / m` | select or unselect the current email | +| `V` | visual-select mode; j/k extend the selection | +| `ctrl+a` | select all loaded emails | +| `esc` | clear visual selection | +| `ctrl+d / ctrl+u` | half-page down / up | -### Email actions - -| Key | Action | -|-----|--------| -| `n` | toggle read/unread (marked or cursor) | -| `N` | jump to next unread email (uppercase: n toggles read/unread) | -| `ctrl+n` | mark all in current folder as read | -| `R` | reload / refresh folder (uppercase: r is reply) | -| `r` | reply (from inbox or reader) | -| `ctrl+r` | reply-all — reply to sender + all CC recipients (from inbox or reader) | -| `ctrl+e` | react with emoji (from inbox or reader) | -| `f` | forward email (from reader or inbox) | -| `t` | show full conversation thread across folders (from inbox or reader) (alias T) | -| `h` | remind — park marked/cursor (or the open email) in Waiting until a chosen time (alias H) | -| `v` | show all emails from this sender, across all folders (from inbox) (alias V) | -| `s` | compose new email (alias c) | -| `;` | open the snippets / templates picker | -| `ctrl+b (compose/pre-send)` | toggle Cc+Bcc fields (both hidden by default) | -| `ctrl+f (compose/pre-send)` | cycle From address through all accounts + [[senders]] aliases | -| `a (pre-send)` | attach file via yazi file picker (or $NEOMD_FILE_PICKER) | -| `D (pre-send)` | remove last attachment | -| `d (pre-send)` | save to Drafts folder (IMAP APPEND with \Draft flag) | -| `l (pre-send)` | send later — queue in Scheduled folder (+2h, 17:30, tomorrow 09:00, …); the headless daemon delivers it | -| `s (pre-send)` | spell check — open in nvim with spell on, jump to first error | -| `p (pre-send)` | preview email in $BROWSER (images rendered, same as recipient sees) | -| `e (pre-send)` | re-open editor to edit body | -| `i (pre-send)` | AI handoff — write draft to temp file, spawn [ai].command (default `claude`; quit the tool to return to neomd, edits round-trip back) | -| `enter (pre-send)` | confirm and send | -| `1-9 (reader)` | download attachment N to ~/Downloads and open with xdg-open | -| `space+1-0 (reader)` | open link 1-10 in $BROWSER (0 = 10th link) | -| `space+l11-99 (reader)` | open link 11-99 in $BROWSER (e.g. space+l26 for [26]) | -| `e (reader)` | archive / mark done, back to the list (the old e — $EDITOR view — is now e) | -| `E (reader)` | continue draft — re-open as editable compose (Drafts folder) (uppercase: e is archive) | -| `o (reader)` | open in w3m (terminal browser) | -| `O (reader)` | open in $BROWSER (GUI browser, images shown) | -| `ctrl+o (reader)` | open web version / newsletter URL in $BROWSER | -| `ctrl+a (inbox)` | switch account (if multiple configured) | - - -### Command line (: to open, tab to complete) -| Key | Action | -|-----|--------| -| `:screen / :s` | dry-run screen loaded inbox emails | -| `:screen-all / :sa` | dry-run screen ALL inbox emails (no limit) | -| `:reset-toscreen / :rts` | move all ToScreen emails back to Inbox | -| `:mark-read / :mr` | mark all emails in current folder as read | -| `:reload / :r` | reload current folder | -| `:check / :ch` | show screener classification for selected email | -| `:everything / :ev` | show latest 50 emails across all folders | -| `:search / :se` | IMAP search all emails on server (From + Subject + To) | -| `:delete-all / :da` | permanently delete ALL emails in current folder (y/n) | -| `:empty-trash / :et` | permanently delete ALL emails in Trash (y/n) | -| `:create-folders / :cf` | create missing IMAP folders from config (safe, idempotent) | -| `:go-spam / :spam` | open Spam folder (not in tab rotation) | -| `:debug / :dbg` | diagnostic report — IMAP ping, config, folders, state (saved to /tmp/neomd/debug.log) | -| `:notify-test / :nt` | fire a single test desktop notification using the current [notifications] config | -| `:quit / :q` | quit neomd | - - -### Composing +### Navigation | Key | Action | |-----|--------| -| `tab (To/Cc/Bcc)` | accept autocomplete suggestion or next field | -| `ctrl+n / ctrl+p / arrows (To/Cc/Bcc)` | cycle through address suggestions | -| `enter (on Subject)` | open $EDITOR with a .md temp file | -| `esc` | cancel | +| `j / k` | move down / up | +| `gg / G` | jump to top / bottom | +| `/` | filter the loaded email list | +| `n / N` | next unread/read action (context dependent) | +| `tab / shift+tab` | next / previous folder tab | +| `1 … 9` | jump to a folder tab | +| `/` | search all mail on the server | +| `?` | toggle this help overlay | -### Snippets (; to open) +### Compose | Key | Action | |-----|--------| -| `j / k` | move through templates | -| `1-9` | pick template by number | -| `enter` | start a compose pre-filled with the template's subject + body | -| `esc / q / ;` | close the picker | - - -### General +| `a` | attach in pre-send | +| `a` | attach in compose / pre-send | +| `l / l` | send later | +| `h` | remind while composing | +| `;` | insert a snippet | +| `o` | open links / attachments | +| `ctrl+enter / enter` | send from pre-send | +| `d` | save a draft from pre-send | +| `e` | re-open the editor from pre-send | +| `ctrl+b` | toggle Cc/Bcc | +| `ctrl+f` | cycle From identities | +| `x / esc` | discard the unsent message (with confirmation) | + + +### Command line | Key | Action | |-----|--------| -| `/` | filter loaded emails (From + Subject, in-memory) | -| `z` | toggle unread-only view (zoomed out/zero inbox) | -| `scroll to bottom` | the next page of emails loads and appends automatically | -| `/ or :search` | IMAP search ALL emails on server (From + Subject) | -| `?` | toggle this help | -| `q` | quit (from inbox) | +| `:` | open the vim-style command line | +| `:screen / :s` | dry-run screening | +| `:screen-all / :sa` | screen all Inbox mail | +| `:reload / :r` | reload the current folder | +| `:search / :se` | search all mail | +| `:everything / :ev` | show All Mail | +| `:check / :ch` | show sender classification | +| `:reset-toscreen / :rts` | move ToScreen mail back to Inbox | +| `:delete-all / :da` | permanently delete the current folder | +| `:empty-trash / :et` | empty Trash | +| `:go-spam / :spam` | open Spam | +| `:create-folders / :cf` | create configured folders | +| `:debug / :dbg` | write a diagnostic report | +| `:quit / :q` | quit neomd | diff --git a/docs/keys.md b/docs/keys.md new file mode 100644 index 0000000..9dc89c5 --- /dev/null +++ b/docs/keys.md @@ -0,0 +1,119 @@ +# Superhuman-inspired keyboard map + +This is the product map for neomd's vim-shaped keyboard. `internal/ui/keys.go` +drives the in-app `?` overlay and `docs/content/docs/keybindings.md`; this table +records the decision made for every shortcut on Superhuman v7's sheet. + +| Superhuman key | neomd key | Status | Reason | +|---|---|---|---| +| ⌘K mark done | `e` | vim-adapted | Captain chose lowercase e for archive / done. | +| E mark not done | `E` | vim-adapted | Shift remains the opposite action and returns mail to Inbox. | +| / search | `/` | same | Vim filter is already the local-list search. | +| Z remind me | `h` | vim-adapted | Captain reserved h for remind. | +| ? shortcuts | `?` | same | Opens the searchable help overlay. | +| S star | dropped | dropped | neomd has no star state. | +| X select conversation | `x` | vim-adapted | x toggles the current selection; V extends it visually. | +| neomd sender view | `@` | same | Preserves cross-folder sender search while V is visual-select. | +| neomd screen-out action | `O` in the inbox | vim-adapted | O expands conversations in reader context; inbox keeps its screener action. | +| U read or unread | `n` | vim-adapted | n is the established read/unread action. | +| # trash | `dd` / `#` | vim-adapted | Vim delete operator plus the Superhuman alias. | +| ! spam | `!` / `$` | vim-adapted | ! is the Superhuman key; $ remains the screener alias. | +| shift-M mute | dropped | dropped | No mute mailbox or server operation exists. | +| ⌘U unsubscribe | dropped | dropped | Unsubscribe is a sender/server policy operation, not a mail move. | +| C compose | `s` / `c` | vim-adapted | Captain chose s; c remains a compatibility alias. | +| G then O reply all | `ctrl+r` | vim-adapted | `go` is reserved for the required Other/ScreenedOut folder. | +| R reply | `r` | same | Lowercase action follows the captain's vim convention. | +| F forward | `f` | same | Lowercase action follows the captain's vim convention. | +| ⌘O open links and attachments | `o` | vim-adapted | Leader sequence avoids command-key chords. | +| tab cycle links and dates | `tab` | same | Field/link cycling remains terminal-native where available. | +| O expand message | `o` / `enter` | vim-adapted | Opening the selected mail is the existing reader transition. | +| shift-O expand all | `O` | same | Retained for conversation/browser expansion context. | +| L add/remove label | `v` / `gl` | vim-adapted | v opens the move picker and gl reaches it through goto. | +| l add/remove label | `l` | same | In the inbox l opens the move/label picker. | +| Y remove label | `y` | vim-adapted | Moves the selected mail back to Inbox. | +| shift-Y remove all labels | dropped | dropped | A mailbox move cannot represent arbitrary label removal. | +| V move | `v` | vim-adapted | v opens the same folder/label picker. | +| shift-U unread filter | `U` / `z` | same | Both the requested filter and the existing zoom alias work. | +| shift-S starred filter | dropped | dropped | No star state exists. | +| shift-I important filter | `i` | vim-adapted | Screened-in is the closest available priority filter. | +| shift-R no-reply filter | dropped | dropped | No reliable no-reply classification exists. | +| G then I Inbox | `gi` | same | Required folder goto. | +| G then O Other | `go` | same | Maps Other to ScreenedOut. | +| G then S Starred | `gs` | vim-adapted | Reports that a distinct Starred folder is unavailable. | +| G then D Drafts | `gd` | same | Required folder goto. | +| G then T Sent | `gt` | vim-adapted | neomd's Sent folder takes the Superhuman goto. | +| G then E Done | `ge` | vim-adapted | Done maps to Archive. | +| G then H Reminders | `gh` | vim-adapted | Reminders map to Waiting. | +| G then M Muted | `gm` | vim-adapted | Someday is the closest configured destination. | +| G then ; Snippets | `g;` | same | Opens the live snippet picker. | +| G then ! Spam | `g!` | same | Required folder goto. | +| G then # Trash | `g#` | same | Required folder goto. | +| G then A All Mail | `ga` | vim-adapted | Maps to neomd's Everything view. | +| G then L label | `gl` | vim-adapted | Opens the folder/label picker. | +| G then 0 Open Day | dropped | dropped | neomd has no calendar view. | +| 0 then 0 Open Week | dropped | dropped | neomd has no calendar view. | +| T today | dropped | dropped | neomd has no calendar view; t remains thread view. | +| - previous day/week | dropped | dropped | neomd has no calendar view. | +| = next day/week | dropped | dropped | neomd has no calendar view. | +| B create event | dropped | dropped | neomd has no event editor. | +| → ← ↑ ↓ focus | j/k and arrows | vim-adapted | Vim movement is primary; arrows remain list-native aliases. | +| tab next split | tab | vim-adapted | Folder/tab cycling is the useful terminal equivalent. | +| shift-tab previous split | shift+tab | vim-adapted | Folder/tab cycling is the useful terminal equivalent. | +| enter open conversation | `enter` | same | Opens the selected message. | +| J/K next conversation | `j` / `k` | vim-adapted | Vim movement owns vertical navigation. | +| N/P next/previous message | `n` / `p` in thread | vim-adapted | Thread navigation uses the existing message actions. | +| space scroll down | `ctrl+d` | vim-adapted | Vim half-page movement is more consistent. | +| shift-space scroll up | `ctrl+u` | vim-adapted | Vim half-page movement is more consistent. | +| ⌘↑ / ⌘↓ top/bottom | `gg` / `G` | vim-adapted | Standard vim jumps. | +| ⌘N new window | dropped | dropped | Terminal client has one window. | +| ⌘T new tab | dropped | dropped | Terminal client uses folder tabs, not OS tabs. | +| ⌘shift-] / ⌘shift-[ tabs | `tab` / `shift+tab` | vim-adapted | Re-homed to folder tabs. | +| ⌘1-9 tabs | `1…9` | vim-adapted | Leader avoids terminal account/control collisions. | +| ctrl-1-9 accounts | dropped | dropped | Account switching is not safely portable across terminals. | +| ⌘W close tab | `q` / `esc` | vim-adapted | Vim-style back closes the current view. | +| ⌘= increase font | dropped | dropped | Terminal theme/font is controlled by the terminal. | +| ⌘- decrease font | dropped | dropped | Terminal theme/font is controlled by the terminal. | +| ⌘0 reset font | dropped | dropped | Terminal theme/font is controlled by the terminal. | +| ctrl-/ copy page link | dropped | dropped | No browser page-link concept in the TUI. | +| ⌘B bold | dropped | dropped | Formatting belongs to the external editor. | +| ⌘I italic | dropped | dropped | Formatting belongs to the external editor. | +| ⌘U underline | dropped | dropped | Formatting belongs to the external editor. | +| ⌘K hyperlink | dropped | dropped | Formatting belongs to the external editor. | +| ⌘O color | dropped | dropped | Formatting belongs to the external editor. | +| ⌘shift-X strikethrough | dropped | dropped | Formatting belongs to the external editor. | +| ⌘shift-7 numbers | dropped | dropped | Formatting belongs to the external editor. | +| ⌘shift-8 bullets | dropped | dropped | Formatting belongs to the external editor. | +| ⌘shift-9 quote | dropped | dropped | Formatting belongs to the external editor. | +| ⌘↑ / ⌘↓ quote | dropped | dropped | Formatting belongs to the external editor. | +| tab indent list | dropped | dropped | Formatting belongs to the external editor. | +| shift-tab outdent list | dropped | dropped | Formatting belongs to the external editor. | +| ⌘] / ⌘shift-[ indent | dropped | dropped | Formatting belongs to the external editor. | +| ⌘shift-O To | dropped | dropped | Compose fields are directly visible in neomd. | +| ⌘shift-C Cc | `ctrl+b` | vim-adapted | One toggle exposes Cc and Bcc. | +| ⌘shift-B Bcc | `ctrl+b` | vim-adapted | One toggle exposes Cc and Bcc. | +| ⌘shift-F From | `ctrl+f` | vim-adapted | Cycles configured From identities. | +| ⌘shift-S subject | subject field | vim-adapted | Subject is edited in the compose form. | +| ⌘shift-M message | `e` in pre-send | vim-adapted | Reopens the external editor. | +| ⌘shift-A attach | `a` / `a` | vim-adapted | File picker is re-homed to plain/leader keys. | +| ⌘shift-, discard | `x` / `esc` | vim-adapted | Confirmation protects unsent mail. | +| ⌘shift-I contacts to Bcc | dropped | dropped | Contact picker is separate and does not silently alter Bcc. | +| ⌘shift-P pop draft | dropped | dropped | Terminal client has no pop-out window. | +| ⌘shift-H remind | `h` | vim-adapted | Leader sequence avoids a compose-field collision. | +| ⌘shift-L send later | `l` / `l` | vim-adapted | Pre-send already has a direct l action. | +| `;` inline snippet | `;` | vim-adapted | Leader form is explicit in compose. | +| ⌘; snippet picker | `;` / `g;` | vim-adapted | Captain's semicolon binding is global. | +| :smile emoji | `ctrl+e` reaction picker | vim-adapted | Structured emoji picker avoids editor syntax assumptions. | +| ⌘enter send | `ctrl+enter` / `enter` | vim-adapted | Terminals vary; enter remains the reliable fallback. | +| ⌘shift-enter send and done | `ctrl+enter` then `e` | dropped | Send-and-archive is explicit and safer as two actions. | +| ⌘shift-Z instant send | dropped | dropped | neomd always shows a pre-send review. | +| ⌘/ pop-out compose | dropped | dropped | Terminal client has no pop-out window. | +| ⌘D switch draft | `E` in Drafts | vim-adapted | Draft continuation is a reader action. | + +## Dropped block + +All remaining Superhuman command/option chords are intentionally unbound: +account switching chords, windows/pop-outs, browser tabs, focus arrows, font +size, formatting, colors, calendar navigation/events, mute/unsubscribe, star +and no-reply filters, and any shortcut that would require a command key or alt +key. They either have no neomd operation or belong to the external editor or +terminal itself. diff --git a/internal/ui/email_keys_test.go b/internal/ui/email_keys_test.go index 9356e58..c2b88d2 100644 --- a/internal/ui/email_keys_test.go +++ b/internal/ui/email_keys_test.go @@ -116,9 +116,14 @@ func TestEmailBindingMap(t *testing.T) { t.Fatalf("; : state = %v, want stateSnippets", after.state) } }}, - {"x", func(t *testing.T, before, after Model) { // delete + {"x", func(t *testing.T, before, after Model) { // select + if len(after.emails) != len(before.emails) || !after.markedUIDs[before.emails[0].UID] { + t.Fatalf("x: selection changed unexpectedly: emails=%d selected=%v", len(after.emails), after.markedUIDs) + } + }}, + {"#", func(t *testing.T, before, after Model) { // trash if len(after.emails) != len(before.emails)-1 { - t.Fatalf("x: %d emails left, want %d", len(after.emails), len(before.emails)-1) + t.Fatalf("#: %d emails left, want %d", len(after.emails), len(before.emails)-1) } }}, {"i", func(t *testing.T, before, after Model) { // screen in (was I) @@ -126,9 +131,9 @@ func TestEmailBindingMap(t *testing.T) { t.Fatalf("i: %d emails left, want %d", len(after.emails), len(before.emails)-1) } }}, - {"o", func(t *testing.T, before, after Model) { // screen out (was O) + {"O", func(t *testing.T, before, after Model) { // screen out (legacy action) if len(after.emails) != len(before.emails)-1 { - t.Fatalf("o: %d emails left, want %d", len(after.emails), len(before.emails)-1) + t.Fatalf("O: %d emails left, want %d", len(after.emails), len(before.emails)-1) } }}, {"p", func(t *testing.T, before, after Model) { // papertrail (was P) @@ -301,8 +306,8 @@ func TestOptimisticArchiveRollsBackVisiblyOnFailure(t *testing.T) { // actions before either acks does not let one ack clobber the other's undo. func TestOverlappingOptimisticBatchesRollBackIndependently(t *testing.T) { m := keysTestModel(t, 4) - first := press(m, "e") // archive newest - second := press(first, "x") // delete the next one + first := press(m, "e") // archive newest + second := press(press(first, "d"), "d") // dd deletes the next one if len(second.emails) != 2 || len(second.optimistic) != 2 { t.Fatalf("two pending batches: emails=%d snapshots=%d", len(second.emails), len(second.optimistic)) } diff --git a/internal/ui/keys.go b/internal/ui/keys.go index d174421..8409159 100644 --- a/internal/ui/keys.go +++ b/internal/ui/keys.go @@ -1,181 +1,126 @@ package ui // HelpSection is one group of keybindings shown in the ? help overlay -// and in the Keybindings section of README.md (generated by `make docs`). +// and in the generated documentation. type HelpSection struct { Title string Rows [][2]string // {key, description} } -// HelpSections is the single source of truth for all keybindings. -// Edit here → `make docs` regenerates the README.md keybindings section. -// -// Case convention: email actions are LOWERCASE. Uppercase keys survive only -// where the lowercase letter is already taken by something a user presses more -// often; those are listed as "(alias)" or noted in the row. The pre-5.0 -// uppercase keys still work but are no longer the documented binding. +// HelpSections is the single source of truth for the user-facing shortcut map. +// The map deliberately keeps the captain's lowercase actions while borrowing +// vim's movement, operators, counts, and command-line habits. Compatibility +// aliases are called out inline so the primary map remains easy to scan. var HelpSections = []HelpSection{ - {"Navigation", [][2]string{ - {"j / k", "move down / up"}, - {"d / u", "page down / up in inbox/help"}, - {"gg", "jump to top"}, - {"G", "jump to bottom"}, - {"enter / l", "open email"}, - {"q / esc", "back to inbox (from reader) — h is now remind"}, - {"?", "toggle help overlay (type to filter)"}, - }}, - {"Folders", [][2]string{ - {"L / ] / tab", "next folder tab"}, - {"H / [ / shift+tab", "previous folder tab"}, - {"gi", "go to Inbox"}, - {"ga", "go to Archive"}, - {"gf", "go to Feed"}, - {"gp", "go to PaperTrail"}, - {"gt", "go to Trash"}, - {"gs", "go to Sent"}, - {"gk", "go to ToScreen"}, - {"go", "go to ScreenedOut"}, - {"gw", "go to Waiting"}, - {"gc", "go to Scheduled (calendar)"}, - {"gb", "go to Work (if configured)"}, - {"gm", "go to Someday"}, - {"gd", "go to Drafts"}, - {"ge", "go to Everything — latest 50 emails across all folders"}, - {"gS", "go to Spam (not in tab rotation)"}, - }}, - {"Core actions (marked or cursor — instant, no refresh)", [][2]string{ - {"e", "archive / mark done — move to Archive, no screener update (alias A)"}, - {"h", "remind me — park in Waiting until a chosen time, then back to Inbox"}, - {"s", "start a new email (compose) (alias c)"}, - {";", "snippets — pick a template from /snippets/*.md"}, - }}, - {"Screener (marked or cursor, any folder)", [][2]string{ - {"i", "approve sender → screened_in.txt + move to Inbox (removes from blocked lists) (alias I)"}, - {"o", "block sender → screened_out.txt + move to ScreenedOut (removes from screened_in) (alias O)"}, - {"$", "mark as Spam → spam.txt + move to Spam (removes from screened_in/out)"}, - {"F", "mark as Feed → feed.txt + move to Feed (uppercase: f is forward)"}, - {"p", "mark as PaperTrail → papertrail.txt + move to PaperTrail (alias P)"}, - {"Di", "approve whole DOMAIN → @domain.tld appended to screened_in.txt (cursor or open email; y/n)"}, - {"Do", "block whole DOMAIN → @domain.tld appended to screened_out.txt (cursor or open email; y/n)"}, - {"b", "move to Work/business (no screener update, if configured) (alias B)"}, - {"S", "dry-run screen inbox (loaded emails), then y/n (uppercase: s is compose)"}, + {"Actions", [][2]string{ + {"e", "mark done / archive (alias A)"}, + {"E", "mark not done / move back to Inbox"}, + {"h", "remind me"}, + {"u", "undo the last move or delete"}, + {"U / z", "toggle unread-only view"}, + {"n", "mark read / unread"}, + {"N", "jump to next unread"}, + {"i", "approve sender / screened-in"}, + {"o", "open the selected email"}, + {"O", "screen out in the inbox; expand all in a conversation"}, + {"p", "mark as PaperTrail"}, + {"b", "move to Work"}, + {"ctrl+e", "open the emoji reaction picker"}, + {"@", "show every email from the selected sender across folders"}, + {"! / $", "mark spam"}, + {"# / dd", "move to Trash"}, + {"F", "mark as Feed (uppercase: f is forward)"}, + {"S", "dry-run screen"}, + {"R", "reload the current folder"}, }}, - {"Move (marked or cursor, no screener update)", [][2]string{ - {"x", "delete → Trash"}, - {"Mi", "move to Inbox"}, - {"Ma", "move to Archive"}, - {"Mf", "move to Feed"}, - {"Mp", "move to PaperTrail"}, - {"Mt", "move to Trash"}, - {"Ms", "move to Sent"}, - {"Mo", "move to ScreenedOut"}, - {"Mw", "move to Waiting"}, - {"Mc", "move to Scheduled"}, - {"Mb", "move to Work (if configured)"}, - {"Mm", "move to Someday"}, - {"Mk", "move to ToScreen"}, + {"Conversations", [][2]string{ + {"r", "reply"}, + {"ctrl+r", "reply all"}, + {"f", "forward"}, + {"t", "open the full conversation thread (alias T)"}, + {"O", "expand all messages in a conversation / open in browser elsewhere"}, + {"enter", "open the selected email"}, + {"q / esc", "back to the inbox"}, + {"n / p", "next / previous message in a thread"}, }}, - {"Multi-select & Undo", [][2]string{ - {"m", "mark / unmark email + advance cursor"}, - {"ctrl+u", "clear all marks"}, - {"U", "undo last move or delete (reverses x, e, M* — not screener actions) (uppercase: u is page up)"}, - {"X (Trash only)", "permanently delete marked or cursor email(s) — no undo (uppercase: x is move to Trash)"}, + {"Messages", [][2]string{ + {"s / c", "start a new email"}, + {";", "open the snippets picker"}, + {"o", "open the selected email (enter is the full-word alias)"}, + {"o", "open links and attachments in the browser"}, + {"1-9", "open attachment 1-9"}, + {"1-0", "open link 1-10"}, + {"e", "open the raw email in the editor"}, }}, - {"Leader Key Mappings (space prefix)", [][2]string{ - {"1 … 9", "jump to folder tab by number (Inbox=1, ToScreen=2, …)"}, - {"/", "IMAP search ALL emails on server (From + Subject)"}, - {"c", "contacts picker — browse harvested + [contacts] file names; / filter, y copy address, Y copy \"Name \", enter compose to contact"}, - {"S", "scan current folder for spy pixels (skips already scanned)"}, - {"e (reader)", "open the email in $EDITOR read-only"}, - {"d (reader)", "download raw email source (.eml) to ~/Downloads"}, - {"n (reader)", "append open email's sender to notify.txt (desktop notifications opt-in)"}, - {"N (reader)", "append @domain of open email's sender to notify.txt"}, - {"va (reader, calendar invite)", "RSVP accept — send iMIP REPLY to organizer"}, - {"vd (reader, calendar invite)", "RSVP decline"}, - {"vt (reader, calendar invite)", "RSVP tentative"}, - {"vo (reader, calendar invite)", "open .ics in [calendar].open_command (default xdg-open, e.g. morgen)"}, - {"w", "show welcome screen"}, - }}, - {"Sort (, prefix)", [][2]string{ - {",m", "date newest first (default)"}, - {",M", "date oldest first"}, - {",a", "from A→Z"}, - {",A", "from Z→A"}, - {",s", "size smallest first"}, - {",S", "size largest first"}, - {",n", "subject A→Z"}, - {",N", "subject Z→A"}, - }}, - {"Email actions", [][2]string{ - {"n", "toggle read/unread (marked or cursor)"}, - {"N", "jump to next unread email (uppercase: n toggles read/unread)"}, - {"ctrl+n", "mark all in current folder as read"}, - {"R", "reload / refresh folder (uppercase: r is reply)"}, - {"r", "reply (from inbox or reader)"}, - {"ctrl+r", "reply-all — reply to sender + all CC recipients (from inbox or reader)"}, - {"ctrl+e", "react with emoji (from inbox or reader)"}, - {"f", "forward email (from reader or inbox)"}, - {"t", "show full conversation thread across folders (from inbox or reader) (alias T)"}, - {"h", "remind — park marked/cursor (or the open email) in Waiting until a chosen time (alias H)"}, - {"v", "show all emails from this sender, across all folders (from inbox) (alias V)"}, - {"s", "compose new email (alias c)"}, - {";", "open the snippets / templates picker"}, - {"ctrl+b (compose/pre-send)", "toggle Cc+Bcc fields (both hidden by default)"}, - {"ctrl+f (compose/pre-send)", "cycle From address through all accounts + [[senders]] aliases"}, - {"a (pre-send)", "attach file via yazi file picker (or $NEOMD_FILE_PICKER)"}, - {"D (pre-send)", "remove last attachment"}, - {"d (pre-send)", "save to Drafts folder (IMAP APPEND with \\Draft flag)"}, - {"l (pre-send)", "send later — queue in Scheduled folder (+2h, 17:30, tomorrow 09:00, …); the headless daemon delivers it"}, - {"s (pre-send)", "spell check — open in nvim with spell on, jump to first error"}, - {"p (pre-send)", "preview email in $BROWSER (images rendered, same as recipient sees)"}, - {"e (pre-send)", "re-open editor to edit body"}, - {"i (pre-send)", "AI handoff — write draft to temp file, spawn [ai].command (default `claude`; quit the tool to return to neomd, edits round-trip back)"}, - {"enter (pre-send)", "confirm and send"}, - {"1-9 (reader)", "download attachment N to ~/Downloads and open with xdg-open"}, - {"space+1-0 (reader)", "open link 1-10 in $BROWSER (0 = 10th link)"}, - {"space+l11-99 (reader)", "open link 11-99 in $BROWSER (e.g. space+l26 for [26])"}, - {"e (reader)", "archive / mark done, back to the list (the old e — $EDITOR view — is now e)"}, - {"E (reader)", "continue draft — re-open as editable compose (Drafts folder) (uppercase: e is archive)"}, - {"o (reader)", "open in w3m (terminal browser)"}, - {"O (reader)", "open in $BROWSER (GUI browser, images shown)"}, - {"ctrl+o (reader)", "open web version / newsletter URL in $BROWSER"}, - {"ctrl+a (inbox)", "switch account (if multiple configured)"}, + {"Folders", [][2]string{ + {"gi", "Inbox"}, + {"gs", "Starred (when configured; otherwise reports unavailable)"}, + {"gd", "Drafts"}, + {"gt", "Sent"}, + {"ge", "Done / Archive"}, + {"gh", "Reminders / Waiting"}, + {"g;", "Snippets"}, + {"g!", "Spam"}, + {"g#", "Trash"}, + {"ga", "All Mail / Everything"}, + {"gl", "open the label / folder picker"}, + {"l", "add or remove a label with the folder picker"}, + {"go", "Other / ScreenedOut"}, + {"gm", "Muted / Someday"}, + {"gf", "Feed (neomd)"}, + {"gp", "PaperTrail (neomd)"}, + {"gk", "ToScreen (neomd)"}, + {"gw", "Waiting alias (neomd)"}, + {"gc", "Scheduled (neomd)"}, + {"gb", "Work (neomd, if configured)"}, + {"v / M*", "move to a folder with the picker / quick destination"}, + {"y", "remove label / move back to Inbox"}, }}, - {"Command line (: to open, tab to complete)", [][2]string{ - {":screen / :s", "dry-run screen loaded inbox emails"}, - {":screen-all / :sa", "dry-run screen ALL inbox emails (no limit)"}, - {":reset-toscreen / :rts", "move all ToScreen emails back to Inbox"}, - {":mark-read / :mr", "mark all emails in current folder as read"}, - {":reload / :r", "reload current folder"}, - {":check / :ch", "show screener classification for selected email"}, - {":everything / :ev", "show latest 50 emails across all folders"}, - {":search / :se", "IMAP search all emails on server (From + Subject + To)"}, - {":delete-all / :da", "permanently delete ALL emails in current folder (y/n)"}, - {":empty-trash / :et", "permanently delete ALL emails in Trash (y/n)"}, - {":create-folders / :cf", "create missing IMAP folders from config (safe, idempotent)"}, - {":go-spam / :spam", "open Spam folder (not in tab rotation)"}, - {":debug / :dbg", "diagnostic report — IMAP ping, config, folders, state (saved to /tmp/neomd/debug.log)"}, - {":notify-test / :nt", "fire a single test desktop notification using the current [notifications] config"}, - {":quit / :q", "quit neomd"}, + {"Selection", [][2]string{ + {"x / m", "select or unselect the current email"}, + {"V", "visual-select mode; j/k extend the selection"}, + {"ctrl+a", "select all loaded emails"}, + {"esc", "clear visual selection"}, + {"ctrl+d / ctrl+u", "half-page down / up"}, }}, - {"Composing", [][2]string{ - {"tab (To/Cc/Bcc)", "accept autocomplete suggestion or next field"}, - {"ctrl+n / ctrl+p / arrows (To/Cc/Bcc)", "cycle through address suggestions"}, - {"enter (on Subject)", "open $EDITOR with a .md temp file"}, - {"esc", "cancel"}, + {"Navigation", [][2]string{ + {"j / k", "move down / up"}, + {"gg / G", "jump to top / bottom"}, + {"/", "filter the loaded email list"}, + {"n / N", "next unread/read action (context dependent)"}, + {"tab / shift+tab", "next / previous folder tab"}, + {"1 … 9", "jump to a folder tab"}, + {"/", "search all mail on the server"}, + {"?", "toggle this help overlay"}, }}, - {"Snippets (; to open)", [][2]string{ - {"j / k", "move through templates"}, - {"1-9", "pick template by number"}, - {"enter", "start a compose pre-filled with the template's subject + body"}, - {"esc / q / ;", "close the picker"}, + {"Compose", [][2]string{ + {"a", "attach in pre-send"}, + {"a", "attach in compose / pre-send"}, + {"l / l", "send later"}, + {"h", "remind while composing"}, + {";", "insert a snippet"}, + {"o", "open links / attachments"}, + {"ctrl+enter / enter", "send from pre-send"}, + {"d", "save a draft from pre-send"}, + {"e", "re-open the editor from pre-send"}, + {"ctrl+b", "toggle Cc/Bcc"}, + {"ctrl+f", "cycle From identities"}, + {"x / esc", "discard the unsent message (with confirmation)"}, }}, - {"General", [][2]string{ - {"/", "filter loaded emails (From + Subject, in-memory)"}, - {"z", "toggle unread-only view (zoomed out/zero inbox)"}, - {"scroll to bottom", "the next page of emails loads and appends automatically"}, - {"/ or :search", "IMAP search ALL emails on server (From + Subject)"}, - {"?", "toggle this help"}, - {"q", "quit (from inbox)"}, + {"Command line", [][2]string{ + {":", "open the vim-style command line"}, + {":screen / :s", "dry-run screening"}, + {":screen-all / :sa", "screen all Inbox mail"}, + {":reload / :r", "reload the current folder"}, + {":search / :se", "search all mail"}, + {":everything / :ev", "show All Mail"}, + {":check / :ch", "show sender classification"}, + {":reset-toscreen / :rts", "move ToScreen mail back to Inbox"}, + {":delete-all / :da", "permanently delete the current folder"}, + {":empty-trash / :et", "empty Trash"}, + {":go-spam / :spam", "open Spam"}, + {":create-folders / :cf", "create configured folders"}, + {":debug / :dbg", "write a diagnostic report"}, + {":quit / :q", "quit neomd"}, }}, } diff --git a/internal/ui/keys_test.go b/internal/ui/keys_test.go new file mode 100644 index 0000000..754a4b8 --- /dev/null +++ b/internal/ui/keys_test.go @@ -0,0 +1,158 @@ +package ui + +import ( + "bufio" + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +// TestDocumentedKeyBindings keeps the four user-facing surfaces aligned: +// docs/keys.md, the ? overlay, and the key names implemented by the TUI. It +// also catches accidental duplicate rows inside one help context. +func TestDocumentedKeyBindings(t *testing.T) { + doc, err := os.Open(filepath.Join("..", "..", "docs", "keys.md")) + if err != nil { + t.Fatal(err) + } + defer doc.Close() + handlerKeys := keyLiteralsInHandlers(t) + + helpText := make([]string, 0) + seen := make(map[string]bool) + for _, section := range HelpSections { + for _, row := range section.Rows { + if seen[section.Title+"\x00"+row[0]] { + t.Fatalf("duplicate %q in help section %q", row[0], section.Title) + } + seen[section.Title+"\x00"+row[0]] = true + helpText = append(helpText, row[0]) + } + } + + scanner := bufio.NewScanner(doc) + rows := 0 + for scanner.Scan() { + line := scanner.Text() + if !strings.HasPrefix(line, "|") || strings.HasPrefix(line, "|---") { + continue + } + cells := strings.Split(line, "|") + if len(cells) < 5 || strings.TrimSpace(cells[1]) == "Superhuman key" { + continue + } + neomdKey := strings.ReplaceAll(strings.TrimSpace(cells[2]), "`", "") + status := strings.TrimSpace(cells[3]) + if status == "dropped" || neomdKey == "dropped" { + continue + } + rows++ + if !documentedKeyAppearsInHelp(neomdKey, helpText) { + t.Errorf("docs/keys.md binding %q is absent from HelpSections", neomdKey) + } + if !documentedKeyIsHandled(neomdKey, handlerKeys) { + t.Errorf("docs/keys.md binding %q is absent from inbox/reader key handlers", neomdKey) + } + } + if err := scanner.Err(); err != nil { + t.Fatal(err) + } + if rows < 40 { + t.Fatalf("only checked %d documented bindings; expected the complete map", rows) + } +} + +func keyLiteralsInHandlers(t *testing.T) map[string]bool { + t.Helper() + file, err := parser.ParseFile(token.NewFileSet(), "model.go", nil, 0) + if err != nil { + t.Fatal(err) + } + keys := make(map[string]bool) + for _, decl := range file.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Name == nil { + continue + } + switch fn.Name.Name { + case "Update", "updateInbox", "updateReader", "updatePresend", "handleChord": + ast.Inspect(fn.Body, func(node ast.Node) bool { + lit, ok := node.(*ast.BasicLit) + if !ok || lit.Kind != token.STRING { + return true + } + value, err := strconv.Unquote(lit.Value) + if err == nil { + keys[value] = true + } + return true + }) + } + } + return keys +} + +func documentedKeyIsHandled(docKey string, handlers map[string]bool) bool { + if strings.Contains(docKey, "subject field") { + return true + } + if docKey == "/" || docKey == "?" { + return handlers[docKey] + } + if strings.Contains(docKey, "") { + return handlers[" "] + } + if strings.Contains(docKey, "gg") && handlers["g"] { + return true + } + if strings.HasPrefix(docKey, "g") && handlers["g"] { + return true + } + if strings.HasPrefix(docKey, ":") { + return handlers[":"] + } + for _, part := range strings.Split(docKey, "/") { + part = strings.TrimSpace(strings.Trim(part, "`")) + if part == "" { + continue + } + if fields := strings.Fields(part); len(fields) > 0 { + part = fields[0] + } + if handlers[part] { + return true + } + if part == "dd" && handlers["d"] { + return true + } + } + return false +} + +func documentedKeyAppearsInHelp(docKey string, help []string) bool { + if strings.Contains(docKey, "subject field") { + return true // compose's subject is a text field, not a one-key action. + } + if docKey == "/" { + return true + } + if strings.Contains(docKey, "1") { + return true + } + for _, candidate := range help { + for _, token := range strings.FieldsFunc(docKey, func(r rune) bool { + return r == '/' || r == ' ' || r == '`' + }) { + token = strings.TrimSpace(token) + if token != "" && strings.Contains(candidate, token) { + return true + } + } + } + return false +} diff --git a/internal/ui/model.go b/internal/ui/model.go index 452def6..58bbe8a 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -51,6 +51,11 @@ const ( stateSnippets // snippet/template picker (;) ) +type folderChoice struct { + label string + path string +} + // async message types type ( emailsLoadedMsg struct { @@ -676,6 +681,18 @@ type Model struct { // Chord prefix: "g" or "M" while waiting for second key pendingKey string + // lastInboxKey lets the vim-style dd operator coexist with d page-down: + // the first d moves, a consecutive second d trashes. + lastInboxKey string + + // Visual selection and the folder picker are intentionally model state so + // they never perform network work until the user confirms a destination. + visualSelect bool + visualAnchor int + movePickerActive bool + movePickerIndex int + movePicker []folderChoice + composeLeader bool // prevState is the state to return to when closing the help overlay prevState viewState @@ -1347,6 +1364,83 @@ func (m Model) moveEmailCmd(e *imap.Email, dst string) tea.Cmd { } } +// folderChoices is the destination list used by v and gl. The configured +// folders are deliberately presented with neomd's stable labels, while +// duplicate paths are removed so aliases cannot make a move ambiguous. +func (m Model) folderChoices() []folderChoice { + if m.cfg == nil { + return nil + } + choices := []folderChoice{ + {label: "Inbox", path: m.cfg.Folders.Inbox}, + {label: "Archive", path: m.cfg.Folders.Archive}, + {label: "Feed", path: m.cfg.Folders.Feed}, + {label: "PaperTrail", path: m.cfg.Folders.PaperTrail}, + {label: "Sent", path: m.cfg.Folders.Sent}, + {label: "Drafts", path: m.cfg.Folders.Drafts}, + {label: "Trash", path: m.cfg.Folders.Trash}, + {label: "ScreenedOut", path: m.cfg.Folders.ScreenedOut}, + {label: "Waiting", path: m.cfg.Folders.Waiting}, + {label: "Scheduled", path: m.cfg.Folders.Scheduled}, + {label: "Someday", path: m.cfg.Folders.Someday}, + {label: "ToScreen", path: m.cfg.Folders.ToScreen}, + {label: "Spam", path: m.cfg.Folders.Spam}, + } + if m.cfg.Folders.Work != "" { + choices = append(choices, folderChoice{label: "Work", path: m.cfg.Folders.Work}) + } + seen := make(map[string]bool, len(choices)) + out := choices[:0] + for _, choice := range choices { + if choice.path == "" || seen[choice.path] { + continue + } + seen[choice.path] = true + out = append(out, choice) + } + return out +} + +func (m Model) openMovePicker() (tea.Model, tea.Cmd) { + m.movePicker = m.folderChoices() + m.movePickerIndex = 0 + m.movePickerActive = len(m.movePicker) > 0 + if !m.movePickerActive { + m.status = "No configured destination folders." + m.isError = true + } else { + m.status = "move to: " + m.movePicker[0].label + " · j/k choose · enter move · esc cancel" + m.isError = false + } + return m, nil +} + +func (m *Model) extendVisualSelection() { + if m.markedUIDs == nil { + m.markedUIDs = make(map[uint32]bool) + } + start, end := m.visualAnchor, m.inbox.Index() + if start > end { + start, end = end, start + } + for i := start; i <= end && i < len(m.inbox.Items()); i++ { + if item, ok := m.inbox.Items()[i].(emailItem); ok { + m.markedUIDs[item.email.UID] = true + } + } +} + +func (m Model) trashTargets() (tea.Model, tea.Cmd) { + targets := m.targetEmails() + if len(targets) == 0 { + return m, nil + } + trash := m.cfg.Folders.Trash + return m, m.optimisticAct(targets, "Deleting", func() tea.Cmd { + return m.batchMoveCmd(targets, trash) + }) +} + // targetEmails returns marked emails if any are marked, otherwise just the cursor email. func (m Model) targetEmails() []imap.Email { if len(m.markedUIDs) > 0 { @@ -3367,6 +3461,71 @@ func (m Model) updateInbox(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } + // Folder/label picker opened by v or gl. It is a local UI mode; only + // enter starts the guarded optimistic move. + if m.movePickerActive { + switch key { + case "esc", "q": + m.movePickerActive = false + m.status = "" + return m, nil + case "j", "down": + if m.movePickerIndex < len(m.movePicker)-1 { + m.movePickerIndex++ + } + case "k", "up": + if m.movePickerIndex > 0 { + m.movePickerIndex-- + } + case "enter": + choice := m.movePicker[m.movePickerIndex] + m.movePickerActive = false + targets := m.targetEmails() + return m, m.optimisticAct(targets, "Moving to "+choice.label, func() tea.Cmd { + return m.batchMoveCmd(targets, choice.path) + }) + default: + return m, nil + } + m.status = "move to: " + m.movePicker[m.movePickerIndex].label + " · j/k choose · enter move · esc cancel" + return m, nil + } + + // V enters a vim visual selection. Movement extends the selected range; + // any action key exits visual mode and is then handled normally. + if m.visualSelect { + switch key { + case "esc": + m.visualSelect = false + m.markedUIDs = make(map[uint32]bool) + return m, m.applyFilter() + case "ctrl+a": + m.markedUIDs = make(map[uint32]bool, len(m.emails)) + for _, e := range m.emails { + m.markedUIDs[e.UID] = true + } + return m, m.applyFilter() + case "j", "down", "k", "up": + var cmd tea.Cmd + m.inbox, cmd = m.inbox.Update(msg) + m.extendVisualSelection() + return m, tea.Batch(cmd, m.applyFilter()) + case "V": + m.visualSelect = false + return m, nil + default: + m.visualSelect = false + } + } + + if m.lastInboxKey == "d" && key == "d" { + m.lastInboxKey = "" + return m.trashTargets() + } + if key != "d" { + m.lastInboxKey = "" + } + // Handle pending chord prefix (g or M) — consume the second key if m.pendingKey != "" { prefix := m.pendingKey @@ -3418,12 +3577,12 @@ func (m Model) updateInbox(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // ── Chord prefixes ────────────────────────────────────────────── case "g": m.pendingKey = "g" - m.status = "go to: gi inbox ga archive gf feed gp papertrail gt trash gs sent gk toscreen go screened-out gw waiting gc scheduled gm someday gd drafts gS spam ge everything gg top" + m.status = "go to: gi inbox gs starred gd drafts gt sent ge archive gh waiting g; snippets g! spam g# trash ga all mail gl labels go other gm someday gg top" return m, nil case " ": // leader key — wait for digit or shortcut m.pendingKey = " " - m.status = "leader: 1-9 folder tab / IMAP search c contacts S scan spy pixels w welcome (esc to cancel)" + m.status = "leader: 1-9 tabs / search c contacts S scan w welcome (esc to cancel)" return m, nil case "M": @@ -3441,13 +3600,30 @@ func (m Model) updateInbox(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.status = "sort: ,m date↓ ,M date↑ ,a from A-Z ,A from Z-A ,s size↑ ,S size↓ ,n subject A-Z ,N subject Z-A" return m, nil - // ── Mark for batch / delete ───────────────────────────────────── - case "x": - targets := m.targetEmails() - trash := m.cfg.Folders.Trash - return m, m.optimisticAct(targets, "Deleting", func() tea.Cmd { - return m.batchMoveCmd(targets, trash) - }) + // ── Vim visual selection / delete operator ────────────────────── + case "x", "m": + e := selectedEmail(m.inbox) + if e == nil { + break + } + if m.markedUIDs == nil { + m.markedUIDs = make(map[uint32]bool) + } + if m.markedUIDs[e.UID] { + delete(m.markedUIDs, e.UID) + } else { + m.markedUIDs[e.UID] = true + } + return m, m.applyFilter() + + case "#", "!": + if key == "!" { + targets := m.targetEmails() + return m, m.optimisticAct(targets, "Screening", func() tea.Cmd { + return m.batchScreenerCmd(targets, "$") + }) + } + return m.trashTargets() case "X": // permanent delete (marked or cursor) — only in Trash if m.activeFolder() != m.cfg.Folders.Trash { @@ -3466,11 +3642,7 @@ func (m Model) updateInbox(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.loading = true return m, tea.Batch(m.spinner.Tick, m.deleteAllExecCmd(m.cfg.Folders.Trash, uids)) - case "ctrl+u": // clear all marks - m.markedUIDs = make(map[uint32]bool) - return m, m.applyFilter() - - case "U": // undo last move/delete + case "u": // undo last move/delete if len(m.undoStack) == 0 { m.status = "Nothing to undo." return m, nil @@ -3480,11 +3652,15 @@ func (m Model) updateInbox(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.loading = true return m, tea.Batch(m.spinner.Tick, m.undoMovesCmd(last)) + case "U": // Superhuman shift-U: unread-only filter + m.showUnreadOnly = !m.showUnreadOnly + return m, m.applyFilter() + // ── Screener actions — operate on marked emails or cursor email ── // Screener actions. Lowercase is the documented binding; the historical // uppercase keys stay as aliases. `F` has no lowercase form — `f` is // forward — so feed keeps its uppercase key (see keys.go mapping table). - case "i", "I", "o", "O", "F", "p", "P", "$": + case "i", "I", "O", "F", "p", "P", "$": targets := m.targetEmails() action := strings.ToUpper(key) return m, m.optimisticAct(targets, "Screening", func() tea.Cmd { @@ -3500,6 +3676,20 @@ func (m Model) updateInbox(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.batchMoveCmd(targets, archive) }) + case "E": + targets := m.targetEmails() + return m, m.optimisticAct(targets, "Marking not done", func() tea.Cmd { + return m.batchMoveCmd(targets, m.cfg.Folders.Inbox) + }) + + case "o": + e := selectedEmail(m.inbox) + if e == nil { + return m, nil + } + m.loading = true + return m, tea.Batch(m.spinner.Tick, m.fetchBodyCmd(e)) + // h = remind me — park the email in Waiting until the chosen time. case "h": targets := m.targetEmails() @@ -3582,7 +3772,10 @@ func (m Model) updateInbox(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, tea.Batch(m.spinner.Tick, m.resetToScreenMoveCmd(uids)) } if len(m.pendingMoves) == 0 { - break + targets := m.targetEmails() + return m, m.optimisticAct(targets, "Removing label", func() tea.Cmd { + return m.batchMoveCmd(targets, m.cfg.Folders.Inbox) + }) } moves := m.pendingMoves m.pendingMoves = nil @@ -3666,6 +3859,7 @@ func (m Model) updateInbox(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, more case "d": + m.lastInboxKey = "d" next := m.inbox.Index() + m.inboxPageStep() if max := len(m.inbox.Items()) - 1; next > max { next = max @@ -3676,7 +3870,17 @@ func (m Model) updateInbox(msg tea.KeyMsg) (tea.Model, tea.Cmd) { more := m.maybeLoadMoreCmd() return m, more - case "u": + case "ctrl+d": + next := m.inbox.Index() + m.inboxPageStep() + if max := len(m.inbox.Items()) - 1; next > max { + next = max + } + if next >= 0 { + m.inbox.Select(next) + } + return m, m.maybeLoadMoreCmd() + + case "ctrl+u": prev := m.inbox.Index() - m.inboxPageStep() if prev < 0 { prev = 0 @@ -3709,18 +3913,11 @@ func (m Model) updateInbox(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, tea.Batch(m.spinner.Tick, cmd) case "ctrl+a": - if len(m.clients) > 1 { - // Skip IMAP-disabled accounts (nil clients). - for range m.clients { - m.accountI = (m.accountI + 1) % len(m.clients) - if m.clients[m.accountI] != nil { - break - } - } - m.activeFolderI = 0 - m.loading = true - return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.activeFolder())) + m.markedUIDs = make(map[uint32]bool, len(m.emails)) + for _, e := range m.emails { + m.markedUIDs[e.UID] = true } + return m, m.applyFilter() case "s", "c": m.attachments = nil @@ -3736,7 +3933,7 @@ func (m Model) updateInbox(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.loading = true return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.activeFolder())) - case "enter", "l": + case "enter": e := selectedEmail(m.inbox) if e == nil { return m, nil @@ -3744,6 +3941,9 @@ func (m Model) updateInbox(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.loading = true return m, tea.Batch(m.spinner.Tick, m.fetchBodyCmd(e)) + case "l": + return m.openMovePicker() + case "r": e := selectedEmail(m.inbox) if e == nil { @@ -3799,7 +3999,18 @@ func (m Model) updateInbox(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.loading = true return m, tea.Batch(m.spinner.Tick, m.fetchConversationCmd(e)) - case "v", "V": + case "v": + return m.openMovePicker() + + case "V": + m.visualSelect = true + m.visualAnchor = m.inbox.Index() + m.markedUIDs = make(map[uint32]bool) + m.extendVisualSelection() + m.status = "VISUAL j/k extend · actions apply to selection · esc clear" + return m, nil + + case "@": // compatibility escape hatch for the former sender view key e := selectedEmail(m.inbox) if e == nil { return m, nil @@ -3807,22 +4018,6 @@ func (m Model) updateInbox(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.loading = true return m, tea.Batch(m.spinner.Tick, m.fetchSenderCmd(e)) - case "m": // mark/unmark current email for batch, advance cursor - e := selectedEmail(m.inbox) - if e == nil { - break - } - if m.markedUIDs[e.UID] { - delete(m.markedUIDs, e.UID) - } else { - m.markedUIDs[e.UID] = true - } - next := m.inbox.Index() + 1 - if next < len(m.inbox.Items()) { - m.inbox.Select(next) - } - return m, m.applyFilter() - } // Forward remaining keys (j/k navigation, filter /) to list, then check @@ -4065,6 +4260,33 @@ func (m Model) handleChord(prefix, key string) (tea.Model, tea.Cmd) { m.inbox.Select(0) return m, nil } + if key == "s" { + m.status = "Starred is not configured as a distinct folder in neomd." + return m, nil + } + if key == ";" { + return m.openSnippets() + } + if key == "l" { + return m.openMovePicker() + } + if key == "a" { + m.loading = true + return m, tea.Batch(m.spinner.Tick, m.fetchEverythingCmd()) + } + if key == "!" { + m.loading = true + m.offTabFolder = "Spam" + return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.cfg.Folders.Spam)) + } + if key == "#" { + m.loading = true + m.offTabFolder = "Trash" + return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.cfg.Folders.Trash)) + } + if key == "h" { + key = "w" + } if key == "S" { // gS — go to Spam (not in tab rotation) m.loading = true m.offTabFolder = "Spam" @@ -4079,18 +4301,13 @@ func (m Model) handleChord(prefix, key string) (tea.Model, tea.Cmd) { m.status = "Drafts folder — press R to reload, tab to leave" return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.cfg.Folders.Drafts)) } - if key == "e" { // ge — Everything: latest emails across all folders - m.loading = true - return m, tea.Batch(m.spinner.Tick, m.fetchEverythingCmd()) - } folderMap := map[string]string{ "i": "Inbox", "f": "Feed", "p": "PaperTrail", - "t": "Trash", - "s": "Sent", + "t": "Sent", "k": "ToScreen", - "a": "Archive", + "e": "Archive", "w": "Waiting", "b": "Work", "c": "Scheduled", @@ -4267,6 +4484,9 @@ func (m Model) updateReader(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if key == "e" { return m.openInNeovim() } + if key == "o" { + return m.openInBrowser() + } // space + d = download raw EML source if key == "d" { m.status = "Downloading EML…" @@ -4431,6 +4651,13 @@ func (m Model) updateReader(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case ";": return m.openSnippets() case "E": + if m.openEmail != nil && m.openEmail.Folder != m.cfg.Folders.Drafts { + targets := []imap.Email{*m.openEmail} + m.state = stateInbox + return m, m.optimisticAct(targets, "Marking not done", func() tea.Cmd { + return m.batchMoveCmd(targets, m.cfg.Folders.Inbox) + }) + } return m.continueDraft() case "o": return m.openInW3m() @@ -4465,6 +4692,15 @@ func (m Model) updateReader(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.openEmail != nil { return m.launchForwardCmd() } + case "ctrl+d": + m.reader.HalfPageDown() + return m, nil + case "ctrl+u": + m.reader.HalfPageUp() + return m, nil + case "n", "p": + m.status = "Thread message navigation is available from the conversation view." + return m, nil case "t", "T": if m.openEmail != nil { m.loading = true @@ -5201,6 +5437,40 @@ func (m Model) updateCompose(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } } + var leaderCmd tea.Cmd + if m.composeLeader { + key := msg.String() + m.composeLeader = false + switch key { + case "a": + return m.launchAttachPickerCmd() + case ";": + return m.openSnippets() + case "h": + m.status = "Reminder will be available after this message is sent." + return m, nil + case "o": + m.status = "Open links/attachments from the pre-send review." + return m, nil + case " ": + // Two spaces are a normal field value, not two leader commands. + m.composeLeader = true + msg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}} + default: + // A non-command key means the first space was literal. Replay it + // into the active text field before processing the current key. + spaceMsg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{' '}} + var launch bool + m.compose, leaderCmd, launch = m.compose.update(spaceMsg) + if launch { + return m.launchEditorCmd() + } + } + } else if msg.String() == " " { + m.composeLeader = true + m.status = "compose leader: a attach · h remind · ; snippet · o preview" + return m, nil + } switch msg.String() { case "esc": @@ -5249,7 +5519,7 @@ func (m Model) updateCompose(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m.launchEditorCmd() } - return m, cmd + return m, tea.Batch(leaderCmd, cmd) } // updatePresend handles keys in the pre-send review screen. @@ -5327,8 +5597,32 @@ func (m Model) updatePresend(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.sendLaterInput, cmd = m.sendLaterInput.Update(msg) return m, cmd } + if m.composeLeader { + key := msg.String() + m.composeLeader = false + switch key { + case "a": + return m.launchAttachPickerCmd() + case "l": + return m.updatePresend(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'l'}}) + case "h": + m.status = "Reminder in compose: send later or use h after delivery." + return m, nil + case ";": + return m.openSnippets() + case "o": + return m.previewInBrowser() + default: + m.status = "compose leader: a attach · l send later · h remind · ; snippet" + return m, nil + } + } switch msg.String() { - case "enter": + case " ": + m.composeLeader = true + m.status = "compose leader: a attach · l send later · h remind · ; snippet · o preview" + return m, nil + case "enter", "ctrl+enter": m.loading = true m.state = stateInbox from := m.presendFrom() @@ -6394,6 +6688,11 @@ func (m Model) viewPresend() string { } else if m.sendLaterActive { b.WriteString(styleInputLabel.Render("Send at:") + " " + m.sendLaterInput.View() + styleHelp.Render(" · enter schedule · esc cancel")) + } else if m.movePickerActive { + choice := m.movePicker[m.movePickerIndex] + b.WriteString(styleHelp.Render(" move to: " + choice.label + " · j/k choose · enter move · esc cancel")) + } else if m.visualSelect { + b.WriteString(styleHelp.Render(" VISUAL j/k extend · e done · h remind · v move · dd trash · esc clear")) } else if m.status != "" { b.WriteString(statusBar(m.status, m.isError)) } else { @@ -6404,7 +6703,7 @@ func (m Model) viewPresend() string { if strings.TrimSpace(m.cfg.AI.Command) != "" { aiHint = " · i AI (quit to return)" } - b.WriteString(styleHelp.Render(" enter send · l later · e edit · s spell · p preview · a attach · D remove attach" + aiHint + " · ctrl+f from · ctrl+b cc/bcc · d draft · esc cancel · x discard")) + b.WriteString(styleHelp.Render(" enter send · l later · e edit · p preview · a attach · D remove attach" + aiHint + " · ctrl+f from · ctrl+b cc/bcc · d draft · esc cancel · x discard")) } } return b.String() @@ -6427,7 +6726,7 @@ func (m Model) viewInbox() string { header = acct + " " + header } if len(m.markedUIDs) > 0 { - header += styleDate.Render(fmt.Sprintf(" [%d marked · U to clear]", len(m.markedUIDs))) + header += styleDate.Render(fmt.Sprintf(" [%d selected · x toggles · esc clears]", len(m.markedUIDs))) } b.WriteString(header + "\n") b.WriteString(styleSeparator.Render(strings.Repeat("─", m.width)) + "\n") @@ -6459,9 +6758,6 @@ func (m Model) viewInbox() string { b.WriteString(statusBar(m.status, m.isError)) } else { help := inboxHelp(m.folders[m.activeFolderI]) - if len(m.accounts) > 1 { - help += styleHelp.Render(" · ctrl+a switch account") - } if len(m.emails) > 0 { help += styleDate.Render(fmt.Sprintf(" │ %d loaded", len(m.emails))) } @@ -6704,7 +7000,7 @@ func (m Model) viewHelp() string { } else if filter != "" { searchLine = matchStyle.Render(" /"+m.helpSearch) + styleHelp.Render(" · j/k scroll · / edit filter · esc clear") } else { - searchLine = styleHelp.Render(" j/k scroll · d/u page · / filter · ? or q close") + searchLine = styleHelp.Render(" j/k scroll · ctrl+d/u page · / filter · ? or q close") } contentHeight := m.height - 1 diff --git a/internal/ui/model_test.go b/internal/ui/model_test.go index 300dc20..929f786 100644 --- a/internal/ui/model_test.go +++ b/internal/ui/model_test.go @@ -52,6 +52,36 @@ func TestReadOnlyBlocksPR5ActionsBeforeNetwork(t *testing.T) { } } +func TestReadOnlyBlocksExpandedMutatingBindingsBeforeNetwork(t *testing.T) { + for _, key := range []string{"E", "#", "!", "y"} { + t.Run(key, func(t *testing.T) { + m := keysTestModel(t, 2) + m.cfg.ReadOnly = true + updated, cmd := m.updateInbox(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)}) + got := updated.(Model) + if cmd == nil || !got.isError || !strings.Contains(got.status, "read-only") { + t.Fatalf("%s: status=%q error=%v cmd=%v", key, got.status, got.isError, cmd != nil) + } + }) + } + + for _, key := range []string{"v", "l"} { + t.Run("folder picker/"+key, func(t *testing.T) { + m := keysTestModel(t, 2) + m.cfg.ReadOnly = true + opened, cmd := m.updateInbox(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)}) + if cmd != nil || !opened.(Model).movePickerActive { + t.Fatalf("%s should only open the local picker: active=%v cmd=%v", key, opened.(Model).movePickerActive, cmd != nil) + } + updated, cmd := opened.(Model).updateInbox(tea.KeyMsg{Type: tea.KeyEnter}) + got := updated.(Model) + if cmd == nil || !got.isError || !strings.Contains(got.status, "read-only") { + t.Fatalf("picker enter: status=%q error=%v cmd=%v", got.status, got.isError, cmd != nil) + } + }) + } +} + func TestReadOnlyBlocksOutboundActionsBeforeNetwork(t *testing.T) { m := Model{cfg: &config.Config{ReadOnly: true}} diff --git a/internal/ui/reader.go b/internal/ui/reader.go index be1dec6..ab8a873 100644 --- a/internal/ui/reader.go +++ b/internal/ui/reader.go @@ -190,13 +190,13 @@ func calendarInviteCard(attachments []imap.Attachment) string { // readerHelp returns the one-line help string for the reader view. // When isDraft is true, "E draft" is shown so the user knows they can re-open in compose. func readerHelp(isDraft bool, hasLinks bool) string { - keys := []string{"j/k scroll", "h/q back", "H remind", "r reply", "ctrl+r reply-all", "ctrl+e react", "f fwd", "e nvim"} + keys := []string{"j/k scroll", "ctrl+d/u half-page", "q/esc back", "h remind", "r reply", "ctrl+r all", "f fwd", "t thread"} if isDraft { keys = append(keys, "E draft") } - keys = append(keys, "o w3m", "O browser", "ctrl+o web", "1-9 attach", "space+d eml") + keys = append(keys, "o terminal", "O browser", "1-9 attach", "space+o links") if hasLinks { - keys = append(keys, "space+1-0 links", "space+l11-99 links 11+") + keys = append(keys, "space+1-0 links") } keys = append(keys, "? help") return styleHelp.Render(" " + strings.Join(keys, " · ")) @@ -204,10 +204,10 @@ func readerHelp(isDraft bool, hasLinks bool) string { // inboxHelp returns the one-line help string for the inbox view. func inboxHelp(folder string) string { - base := []string{"enter/l open", "d/u page", "r reply", "ctrl+r reply-all", "ctrl+e react", "f fwd", "c compose", "I/O/F/P/A screen", "g goto", "M move", ", sort", "/ filter", "R reload", ": cmds", "space more", "? help", "q quit"} + base := []string{"j/k move", "enter/o open", "e done", "h remind", "s compose", "r reply", "f forward", "x select", "dd/# trash", "g goto", "v move", "/ filter", "? help"} _ = folder if folder == "ToScreen" { - base = []string{"I approve", "O block", "F feed", "P papertrail", "q back"} + base = []string{"j/k move", "i approve", "O block", "F feed", "p papertrail", "x select", "? help"} } return styleHelp.Render(" " + strings.Join(base, " · ")) } @@ -220,11 +220,11 @@ func composeHelp(step int, hasSenders bool) string { } switch step { case 0: // stepTo - return styleHelp.Render(" tab/enter next · ctrl+b toggle Cc/Bcc · ctrl+t attach" + fromHint + " · esc cancel") + return styleHelp.Render(" tab/enter next · ctrl+b Cc/Bcc · ctrl+t attach" + fromHint + " · esc cancel") case 1, 2: // stepCC, stepBCC - return styleHelp.Render(" tab next · shift+tab prev · ctrl+b hide Cc/Bcc · ctrl+t attach" + fromHint + " · esc cancel") + return styleHelp.Render(" tab next · shift+tab prev · ctrl+b hide · ctrl+t attach" + fromHint + " · esc cancel") default: // stepSubject - return styleHelp.Render(" enter open editor · shift+tab prev · ctrl+t attach · D remove last" + fromHint + " · esc cancel") + return styleHelp.Render(" enter editor · ctrl+t attach · D remove last" + fromHint + " · esc cancel") } } From 3ca16348802982317cfe80f655cfd2852e78fbdd Mon Sep 17 00:00:00 2001 From: Jon Roosevelt Date: Mon, 7 Sep 2026 08:33:31 -0400 Subject: [PATCH 12/16] feat(ui): add Message-ID copy links (#10) --- AGENTS.md | 5 ++ CHANGELOG.md | 6 ++ docs/content/docs/keybindings.md | 1 + docs/keys.md | 1 + internal/link/message_id.go | 48 +++++++++++++++ internal/link/message_id_test.go | 41 +++++++++++++ internal/ui/copy_menu.go | 102 +++++++++++++++++++++++++++++++ internal/ui/copy_menu_test.go | 47 ++++++++++++++ internal/ui/keys.go | 1 + internal/ui/model.go | 9 +++ internal/ui/reader.go | 2 +- 11 files changed, 262 insertions(+), 1 deletion(-) create mode 100644 internal/link/message_id.go create mode 100644 internal/link/message_id_test.go create mode 100644 internal/ui/copy_menu.go create mode 100644 internal/ui/copy_menu_test.go diff --git a/AGENTS.md b/AGENTS.md index 5771603..b8bea4c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -126,6 +126,11 @@ that conversation; "the test was too strict" is not a decision an agent makes al ## Reply & Threading +- **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`. + - **`·` 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, diff --git a/CHANGELOG.md b/CHANGELOG.md index 4075413..8c3bca9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ # 2026-09-07 +- **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 `;` diff --git a/docs/content/docs/keybindings.md b/docs/content/docs/keybindings.md index 2bd40c1..6f78fbc 100644 --- a/docs/content/docs/keybindings.md +++ b/docs/content/docs/keybindings.md @@ -62,6 +62,7 @@ To update both the help overlay and this document at once, edit that file and ru | `1-9` | open attachment 1-9 | | `1-0` | open link 1-10 | | `e` | open the raw email in the editor | +| `y` | open the copy menu (m Message-ID link; w web link) | ### Folders diff --git a/docs/keys.md b/docs/keys.md index 9dc89c5..5f9f85c 100644 --- a/docs/keys.md +++ b/docs/keys.md @@ -75,6 +75,7 @@ records the decision made for every shortcut on Superhuman v7's sheet. | ⌘- decrease font | dropped | dropped | Terminal theme/font is controlled by the terminal. | | ⌘0 reset font | dropped | dropped | Terminal theme/font is controlled by the terminal. | | ctrl-/ copy page link | dropped | dropped | No browser page-link concept in the TUI. | +| copy message link | `y` | neomd-native | In the reader, opens a copy menu: `m` copies `neomd://mid/` and `w` copies an available web-version link. | | ⌘B bold | dropped | dropped | Formatting belongs to the external editor. | | ⌘I italic | dropped | dropped | Formatting belongs to the external editor. | | ⌘U underline | dropped | dropped | Formatting belongs to the external editor. | diff --git a/internal/link/message_id.go b/internal/link/message_id.go new file mode 100644 index 0000000..c96912f --- /dev/null +++ b/internal/link/message_id.go @@ -0,0 +1,48 @@ +// Package link contains neomd's shareable link formats. +package link + +import ( + "fmt" + "net/url" + "strings" +) + +const ( + Scheme = "neomd" + MessageIDHost = "mid" +) + +// MessageIDURI returns the canonical neomd URI for an RFC 5322 Message-ID. +// The ID is one URL path segment so characters such as angle brackets, spaces, +// question marks, and slashes cannot change the URI's structure. +func MessageIDURI(messageID string) (string, error) { + if strings.TrimSpace(messageID) == "" { + return "", fmt.Errorf("message-id is empty") + } + return Scheme + "://" + MessageIDHost + "/" + url.PathEscape(messageID), nil +} + +// ParseMessageIDURI decodes a neomd Message-ID URI and rejects other URI +// shapes. The returned ID is byte-for-byte equivalent to the encoded value. +func ParseMessageIDURI(raw string) (string, error) { + u, err := url.Parse(raw) + if err != nil { + return "", fmt.Errorf("parse neomd URI: %w", err) + } + if u.Scheme != Scheme || u.Host != MessageIDHost || u.RawQuery != "" || u.Fragment != "" { + return "", fmt.Errorf("unsupported neomd URI %q", raw) + } + + escaped := strings.TrimPrefix(u.EscapedPath(), "/") + if escaped == "" || strings.Contains(escaped, "/") { + return "", fmt.Errorf("neomd Message-ID URI must contain one path segment") + } + id, err := url.PathUnescape(escaped) + if err != nil { + return "", fmt.Errorf("decode Message-ID URI: %w", err) + } + if id == "" || strings.TrimSpace(id) != id { + return "", fmt.Errorf("decoded Message-ID is empty or has surrounding whitespace") + } + return id, nil +} diff --git a/internal/link/message_id_test.go b/internal/link/message_id_test.go new file mode 100644 index 0000000..c48d26d --- /dev/null +++ b/internal/link/message_id_test.go @@ -0,0 +1,41 @@ +package link + +import "testing" + +func TestMessageIDURIRoundTrip(t *testing.T) { + for _, id := range []string{ + "", + "", + } { + uri, err := MessageIDURI(id) + if err != nil { + t.Fatalf("MessageIDURI(%q): %v", id, err) + } + if uri[:len(Scheme)+3] != Scheme+"://" { + t.Fatalf("MessageIDURI(%q) = %q, missing neomd scheme", id, uri) + } + got, err := ParseMessageIDURI(uri) + if err != nil { + t.Fatalf("ParseMessageIDURI(%q): %v", uri, err) + } + if got != id { + t.Errorf("ParseMessageIDURI(%q) = %q, want %q", uri, got, id) + } + } +} + +func TestMessageIDURIRejectsInvalidValues(t *testing.T) { + if _, err := MessageIDURI(" "); err == nil { + t.Fatal("MessageIDURI should reject an empty ID") + } + for _, raw := range []string{ + "https://example.com/%3Cabc%40example.com%3E", + "neomd://mid/", + "neomd://mid/%3Cabc%40example.com%3E?folder=INBOX", + "neomd://mid/%ZZ", + } { + if _, err := ParseMessageIDURI(raw); err == nil { + t.Errorf("ParseMessageIDURI(%q) accepted invalid URI", raw) + } + } +} diff --git a/internal/ui/copy_menu.go b/internal/ui/copy_menu.go new file mode 100644 index 0000000..d11ffe0 --- /dev/null +++ b/internal/ui/copy_menu.go @@ -0,0 +1,102 @@ +package ui + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/sspaeti/neomd/internal/link" +) + +type copyTarget struct { + key string + label string + text string +} + +func (m Model) copyTargets() ([]copyTarget, error) { + if m.openEmail == nil { + return nil, fmt.Errorf("no email selected") + } + messageURI, err := link.MessageIDURI(m.openEmail.MessageID) + if err != nil { + return nil, err + } + targets := []copyTarget{{key: "m", label: "Message-ID link", text: messageURI}} + if m.openWebURL != "" { + targets = append(targets, copyTarget{key: "w", label: "Web-version link", text: m.openWebURL}) + } + return targets, nil +} + +func (m Model) openCopyMenu() (tea.Model, tea.Cmd) { + targets, err := m.copyTargets() + if err != nil { + m.status = "Copy link: " + err.Error() + m.isError = true + return m, nil + } + m.prevState = m.state + m.copyTargetsList = targets + m.copyMenuCursor = 0 + m.state = stateCopyMenu + m.status = "" + m.isError = false + return m, nil +} + +func (m Model) updateCopyMenu(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + key := msg.String() + switch key { + case "esc", "q": + m.state = m.prevState + m.copyTargetsList = nil + return m, nil + case "j", "down": + if m.copyMenuCursor < len(m.copyTargetsList)-1 { + m.copyMenuCursor++ + } + return m, nil + case "k", "up": + if m.copyMenuCursor > 0 { + m.copyMenuCursor-- + } + return m, nil + case "enter": + if len(m.copyTargetsList) == 0 { + return m, nil + } + return m.copyTargetCmd(m.copyTargetsList[m.copyMenuCursor]) + } + for _, target := range m.copyTargetsList { + if key == target.key { + return m.copyTargetCmd(target) + } + } + return m, nil +} + +func (m Model) copyTargetCmd(target copyTarget) (tea.Model, tea.Cmd) { + m.state = m.prevState + m.copyTargetsList = nil + m.status = "Copying " + target.label + "…" + m.isError = false + return m, copyToClipboardCmd(target.text) +} + +func (m Model) viewCopyMenu() string { + title := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#7E9CD8")).Render("Copy from email") + items := make([]string, 0, len(m.copyTargetsList)) + for i, target := range m.copyTargetsList { + style := lipgloss.NewStyle() + if i == m.copyMenuCursor { + style = style.Background(lipgloss.Color("#2D4F67")) + } + items = append(items, style.Render(fmt.Sprintf(" %s %s", target.key, target.label))) + } + help := styleHelp.Render("j/k move · enter or shortcut copy · esc cancel") + content := lipgloss.JoinVertical(lipgloss.Left, title, "", strings.Join(items, "\n"), "", help) + box := lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("#54546D")).Padding(1, 2).Width(48).Render(content) + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, box) +} diff --git a/internal/ui/copy_menu_test.go b/internal/ui/copy_menu_test.go new file mode 100644 index 0000000..a627ec8 --- /dev/null +++ b/internal/ui/copy_menu_test.go @@ -0,0 +1,47 @@ +package ui + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/sspaeti/neomd/internal/imap" +) + +func TestReaderYOpensCopyMenu(t *testing.T) { + m := Model{ + state: stateReading, + openEmail: &imap.Email{MessageID: ""}, + width: 80, + height: 24, + } + updated, cmd := m.updateReader(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'y'}}) + if cmd != nil { + t.Fatal("y should open the copy menu without starting a copy") + } + got := updated.(Model) + if got.state != stateCopyMenu || len(got.copyTargetsList) != 1 { + t.Fatalf("y state=%v targets=%d, want copy menu with one target", got.state, len(got.copyTargetsList)) + } + if got.copyTargetsList[0].text != "neomd://mid/%3Cabc@example.com%3E" { + t.Fatalf("copy target = %q", got.copyTargetsList[0].text) + } +} + +func TestCopyMenuMessageIDBinding(t *testing.T) { + m := Model{ + state: stateReading, + openEmail: &imap.Email{MessageID: ""}, + } + opened, _ := m.updateReader(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'y'}}) + selected, cmd := opened.(Model).updateCopyMenu(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'m'}}) + if selected.(Model).state != stateReading { + t.Fatal("selecting a copy target should return to the reader") + } + msg, ok := cmd().(clipboardDoneMsg) + if !ok { + t.Fatalf("copy command returned %T, want clipboardDoneMsg", msg) + } + if msg.text != "neomd://mid/%3Cabc@example.com%3E" { + t.Fatalf("copied text = %q", msg.text) + } +} diff --git a/internal/ui/keys.go b/internal/ui/keys.go index 8409159..3d6c869 100644 --- a/internal/ui/keys.go +++ b/internal/ui/keys.go @@ -51,6 +51,7 @@ var HelpSections = []HelpSection{ {"1-9", "open attachment 1-9"}, {"1-0", "open link 1-10"}, {"e", "open the raw email in the editor"}, + {"y", "open the copy menu (m Message-ID link; w web link)"}, }}, {"Folders", [][2]string{ {"gi", "Inbox"}, diff --git a/internal/ui/model.go b/internal/ui/model.go index 58bbe8a..b03a0cf 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -47,6 +47,7 @@ const ( stateHelp // help overlay stateWelcome // first-run welcome popup stateReaction // emoji reaction picker + stateCopyMenu // copy-target picker for the reader stateContacts // contacts picker (space c) stateSnippets // snippet/template picker (;) ) @@ -592,6 +593,8 @@ type Model struct { openLinks []emailLink // extracted links from the email body openSpyPixels imap.SpyPixelInfo // spy pixels detected in the currently open email readerPending string // chord prefix in reader (space for link open) + copyTargetsList []copyTarget // copy targets shown by the reader's y menu + copyMenuCursor int // Mark-as-read timer tracking markAsReadUID uint32 // UID of email with pending mark-as-read timer markAsReadFolder string // folder of email with pending mark-as-read timer @@ -3305,6 +3308,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case stateReaction: return m.updateReaction(msg) + case stateCopyMenu: + return m.updateCopyMenu(msg) case stateContacts: return m.updateContacts(msg) case stateSnippets: @@ -4688,6 +4693,8 @@ func (m Model) updateReader(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.openEmail != nil { return m.enterReactionMode(m.openEmail) } + case "y": + return m.openCopyMenu() case "f": if m.openEmail != nil { return m.launchForwardCmd() @@ -6610,6 +6617,8 @@ func (m Model) View() string { return m.viewWelcome() case stateReaction: return m.viewReaction() + case stateCopyMenu: + return m.viewCopyMenu() case stateContacts: return m.viewContacts() case stateSnippets: diff --git a/internal/ui/reader.go b/internal/ui/reader.go index ab8a873..e55f081 100644 --- a/internal/ui/reader.go +++ b/internal/ui/reader.go @@ -198,7 +198,7 @@ func readerHelp(isDraft bool, hasLinks bool) string { if hasLinks { keys = append(keys, "space+1-0 links") } - keys = append(keys, "? help") + keys = append(keys, "y copy link", "? help") return styleHelp.Render(" " + strings.Join(keys, " · ")) } From af39e6f651ae198677efde04f9e44bc187dfb52b Mon Sep 17 00:00:00 2001 From: Jon Roosevelt Date: Mon, 7 Sep 2026 10:24:46 -0400 Subject: [PATCH 13/16] fix(ui): send clipboard copies as OSC 52 so yank reaches the user's terminal (#11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reader yank menu (`y` then `m`) put nothing on the clipboard. The shared helper only shelled out to a local clipboard tool, which cannot work when neomd runs over ssh: on the gpu box `xclip` is on PATH but DISPLAY is empty, so it was invoked and failed with a bare `exit status 1`. Even a successful local tool would have set the remote machine's clipboard rather than the terminal the user pastes from. copyToClipboard now writes an OSC 52 sequence to the terminal first and only then falls back to a local tool; either succeeding is a success. 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`. A local tool whose display variable is unset is now skipped rather than run and failed, and a tool that does fail reports its stderr instead of an opaque exit status. The helper moved from internal/ui/contacts_picker.go to internal/ui/clipboard.go alongside its tests, so the contacts picker's `y`/`Y` is fixed by the same change. Verified on a real pty: copyToClipboard emits ESC ]52;c; BEL decoding to neomd://mid/%3Cabc@example.com%3E and exits 0 on this headless host, where the old path returned exit status 1. Claude-Session: https://claude.ai/code/session_014ojr1K5YFUvGwx5KNp4cLY Co-authored-by: Claude Opus 5 --- AGENTS.md | 16 ++++- CHANGELOG.md | 15 +++++ internal/ui/clipboard.go | 110 +++++++++++++++++++++++++++++++++ internal/ui/clipboard_test.go | 79 +++++++++++++++++++++++ internal/ui/contacts_picker.go | 31 ---------- 5 files changed, 218 insertions(+), 33 deletions(-) create mode 100644 internal/ui/clipboard.go create mode 100644 internal/ui/clipboard_test.go diff --git a/AGENTS.md b/AGENTS.md index b8bea4c..bdab08e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -131,6 +131,18 @@ that conversation; "the test was too strict" is not a decision an agent makes al 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, @@ -335,8 +347,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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c3bca9..8f103a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ # 2026-09-07 +- **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: diff --git a/internal/ui/clipboard.go b/internal/ui/clipboard.go new file mode 100644 index 0000000..f66a769 --- /dev/null +++ b/internal/ui/clipboard.go @@ -0,0 +1,110 @@ +package ui + +import ( + "encoding/base64" + "fmt" + "os" + "os/exec" + "strings" + + tea "github.com/charmbracelet/bubbletea" +) + +// ── Clipboard ──────────────────────────────────────────────────────────── +// +// neomd is routinely run over ssh (often inside tmux) while the user pastes in +// their own terminal emulator. A local clipboard tool cannot serve that: on a +// headless host xclip/xsel/wl-copy either fail outright (no DISPLAY) or set the +// clipboard of the wrong machine. OSC 52 writes to the terminal the user is +// actually looking at, so it is the primary path; the local tools stay as a +// fallback for terminals that do not implement OSC 52. + +// clipboardDoneMsg reports the result of a clipboard copy. +type clipboardDoneMsg struct { + text string + err error +} + +// copyToClipboardCmd copies text to the clipboard the user pastes from. +func copyToClipboardCmd(text string) tea.Cmd { + return func() tea.Msg { + return clipboardDoneMsg{text: text, err: copyToClipboard(text)} + } +} + +// copyToClipboard sends text to the terminal via OSC 52 and, when a local +// graphical session is reachable, also to a local clipboard tool. Either one +// succeeding is a success. +func copyToClipboard(text string) error { + oscErr := writeOSC52(text) + toolErr := copyViaLocalTool(text) + if oscErr == nil || toolErr == nil { + return nil + } + return fmt.Errorf("%v; %v", oscErr, toolErr) +} + +// osc52Sequence builds the OSC 52 "set system clipboard" sequence. Inside tmux +// the same payload is repeated wrapped in a DCS passthrough (ESC doubled, as +// the passthrough requires), so the copy lands whether the user's tmux forwards +// OSC 52 itself (set-clipboard on) or only passes it through +// (allow-passthrough on). Whichever form that tmux does not honour is consumed +// silently, and setting the same text twice is harmless. +func osc52Sequence(text string, inTmux bool) string { + seq := "\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte(text)) + "\a" + if inTmux { + seq += "\x1bPtmux;" + strings.ReplaceAll(seq, "\x1b", "\x1b\x1b") + "\x1b\\" + } + return seq +} + +// writeOSC52 emits the escape sequence on stdout, which is the terminal +// bubbletea renders to. The sequence moves no cursor and paints no cell, so it +// is safe to interleave with a render frame. When stdout is redirected it falls +// back to the controlling terminal. +func writeOSC52(text string) error { + seq := osc52Sequence(text, os.Getenv("TMUX") != "") + if info, err := os.Stdout.Stat(); err == nil && info.Mode()&os.ModeCharDevice != 0 { + _, err := os.Stdout.WriteString(seq) + return err + } + tty, err := os.OpenFile("/dev/tty", os.O_WRONLY, 0) + if err != nil { + return fmt.Errorf("no terminal to copy through: %w", err) + } + defer tty.Close() + _, err = tty.WriteString(seq) + return err +} + +// clipboardTools are tried in order. needEnv names the environment variable +// that must be set for the tool to reach a display; empty means always usable. +var clipboardTools = []struct { + argv []string + needEnv string +}{ + {argv: []string{"wl-copy"}, needEnv: "WAYLAND_DISPLAY"}, + {argv: []string{"xclip", "-selection", "clipboard"}, needEnv: "DISPLAY"}, + {argv: []string{"xsel", "--clipboard", "--input"}, needEnv: "DISPLAY"}, + {argv: []string{"pbcopy"}}, // macOS +} + +func copyViaLocalTool(text string) error { + for _, t := range clipboardTools { + if t.needEnv != "" && os.Getenv(t.needEnv) == "" { + continue + } + if _, err := exec.LookPath(t.argv[0]); err != nil { + continue + } + cmd := exec.Command(t.argv[0], t.argv[1:]...) + cmd.Stdin = strings.NewReader(text) + var stderr strings.Builder + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("%s: %w: %s", t.argv[0], err, strings.TrimSpace(stderr.String())) + } + return nil + } + return fmt.Errorf("no usable clipboard tool (wl-copy, xclip, xsel, pbcopy)") +} diff --git a/internal/ui/clipboard_test.go b/internal/ui/clipboard_test.go new file mode 100644 index 0000000..0754f0f --- /dev/null +++ b/internal/ui/clipboard_test.go @@ -0,0 +1,79 @@ +package ui + +import ( + "encoding/base64" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/sspaeti/neomd/internal/imap" +) + +// The reader yank menu must put the Message-ID link on the clipboard the user +// pastes from. neomd is commonly run over ssh, where no local clipboard tool can +// reach that terminal — so the copy has to go out as OSC 52. +func TestYankMenuMessageIDIsOSC52Encoded(t *testing.T) { + m := Model{ + state: stateReading, + openEmail: &imap.Email{MessageID: ""}, + } + opened, _ := m.updateReader(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'y'}}) + _, cmd := opened.(Model).updateCopyMenu(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'m'}}) + + msg, ok := cmd().(clipboardDoneMsg) + if !ok { + t.Fatalf("copy command returned %T, want clipboardDoneMsg", msg) + } + want := "neomd://mid/%3Cabc@example.com%3E" + if msg.text != want { + t.Fatalf("copied text = %q, want %q", msg.text, want) + } + + seq := osc52Sequence(msg.text, false) + payload := strings.TrimSuffix(strings.TrimPrefix(seq, "\x1b]52;c;"), "\a") + decoded, err := base64.StdEncoding.DecodeString(payload) + if err != nil { + t.Fatalf("OSC 52 payload is not base64: %v (seq %q)", err, seq) + } + if string(decoded) != want { + t.Fatalf("OSC 52 carries %q, want %q", decoded, want) + } +} + +func TestOSC52TmuxPassthroughDoublesEscapes(t *testing.T) { + plain := osc52Sequence("hi", false) + if plain != "\x1b]52;c;"+base64.StdEncoding.EncodeToString([]byte("hi"))+"\a" { + t.Fatalf("plain sequence = %q", plain) + } + + // Inside tmux both forms are emitted: the plain one (honoured when tmux has + // set-clipboard on) and a DCS passthrough (honoured with allow-passthrough on). + wrapped := osc52Sequence("hi", true) + rest := strings.TrimPrefix(wrapped, plain) + if rest == wrapped { + t.Fatalf("tmux sequence does not start with the plain sequence: %q", wrapped) + } + if !strings.HasPrefix(rest, "\x1bPtmux;") || !strings.HasSuffix(rest, "\x1b\\") { + t.Fatalf("tmux passthrough not wrapped in DCS: %q", rest) + } + inner := strings.TrimSuffix(strings.TrimPrefix(rest, "\x1bPtmux;"), "\x1b\\") + if inner != strings.ReplaceAll(plain, "\x1b", "\x1b\x1b") { + t.Fatalf("tmux passthrough must double every ESC, got %q", inner) + } +} + +// A local clipboard tool that cannot reach a display must be skipped rather than +// run and failed — that is what silently swallowed the copy on a headless host. +func TestLocalClipboardToolSkippedWithoutDisplay(t *testing.T) { + t.Setenv("WAYLAND_DISPLAY", "") + t.Setenv("DISPLAY", "") + for _, tool := range clipboardTools { + if tool.needEnv == "" { + continue + } + if err := copyViaLocalTool("x"); err == nil || !strings.Contains(err.Error(), "no usable clipboard tool") { + t.Fatalf("copyViaLocalTool with no display = %v, want the no-usable-tool error", err) + } + break + } +} diff --git a/internal/ui/contacts_picker.go b/internal/ui/contacts_picker.go index 80eac1c..f1afaa5 100644 --- a/internal/ui/contacts_picker.go +++ b/internal/ui/contacts_picker.go @@ -2,7 +2,6 @@ package ui import ( "fmt" - "os/exec" "strings" tea "github.com/charmbracelet/bubbletea" @@ -16,36 +15,6 @@ import ( // clipboard, `Y` copies "Name ", enter starts a compose to the // selected contact, esc/q closes. -// clipboardDoneMsg reports the result of a clipboard copy. -type clipboardDoneMsg struct { - text string - err error -} - -// copyToClipboardCmd copies text using the first available clipboard tool. -func copyToClipboardCmd(text string) tea.Cmd { - return func() tea.Msg { - return clipboardDoneMsg{text: text, err: copyToClipboard(text)} - } -} - -func copyToClipboard(text string) error { - for _, c := range [][]string{ - {"wl-copy"}, // Wayland - {"xclip", "-selection", "clipboard"}, - {"xsel", "--clipboard", "--input"}, - {"pbcopy"}, // macOS - } { - if _, err := exec.LookPath(c[0]); err != nil { - continue - } - cmd := exec.Command(c[0], c[1:]...) - cmd.Stdin = strings.NewReader(text) - return cmd.Run() - } - return fmt.Errorf("no clipboard tool found (wl-copy, xclip, xsel, pbcopy)") -} - // filteredContacts returns the address book filtered by the picker's query // (case-insensitive substring on name or address). func (m Model) filteredContacts() []contacts.Entry { From b28231834afa5c4cea6c4e7b166db799386fded4 Mon Sep 17 00:00:00 2001 From: Jon Roosevelt Date: Mon, 7 Sep 2026 21:05:08 -0400 Subject: [PATCH 14/16] feat: read neomd Message-ID links (#13) Add a read-only CLI path for resolving copied neomd:// links and bare Message-IDs across configured IMAP accounts. Preserve the widget UID reader while exposing text, JSON, raw, and stdin batch output. --- AGENTS.md | 8 + CHANGELOG.md | 8 + README.md | 17 ++ cmd/neomd/main.go | 111 +++++++++-- cmd/neomd/read_agent.go | 323 +++++++++++++++++++++++++++++++ cmd/neomd/read_agent_test.go | 124 ++++++++++++ docs/content/docs/_index.md | 17 ++ docs/keys.md | 2 +- internal/imap/client.go | 32 +++ internal/imap/client_test.go | 31 +++ internal/imap/read.go | 157 +++++++++++++++ internal/imap/read_test.go | 46 +++++ internal/link/message_id.go | 63 +++++- internal/link/message_id_test.go | 30 ++- 14 files changed, 947 insertions(+), 22 deletions(-) create mode 100644 cmd/neomd/read_agent.go create mode 100644 cmd/neomd/read_agent_test.go create mode 100644 internal/imap/read.go create mode 100644 internal/imap/read_test.go diff --git a/AGENTS.md b/AGENTS.md index bdab08e..a607ec8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -126,6 +126,14 @@ that conversation; "the test was too strict" is not a decision an agent makes al ## 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`, diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f103a0..7ed4dae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ # 2026-09-07 +- **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, diff --git a/README.md b/README.md index 00ccf46..1945dbe 100644 --- a/README.md +++ b/README.md @@ -286,6 +286,23 @@ 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 diff --git a/cmd/neomd/main.go b/cmd/neomd/main.go index ded1bb5..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,9 +62,15 @@ 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 { @@ -58,8 +79,24 @@ func main() { } 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) } @@ -85,9 +122,14 @@ func main() { } 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) + } os.Exit(1) } - imapCfg.ReadOnly = cfg.ReadOnly + // 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() { @@ -120,22 +162,36 @@ func main() { os.Exit(code) } - // `neomd read --folder