A minimal email TUI where you read with Markdown and write in Neovim. neomd.ssp.sh/docs
email markdown neovim tui
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix multiple sender and reply all

sspaeti 598712a6 96999419

+143 -13
+5 -1
CHANGELOG.md
··· 6 6 - **`:recover` / `:rec` command** — reopens the most recent draft backup as a compose session; To/Cc/Bcc/Subject are parsed from the backup and pre-filled automatically 7 7 - **Screener docs: "screening happens once"** — documented that auto-screening only runs on the Inbox folder; emails moved to ToScreen by another device are not re-classified; use `:reset-toscreen` to move them back for re-screening 8 8 - **Test suite** — 147 unit tests across 8 packages covering screener classification, MIME message building, editor parsing, config loading, IMAP search, OAuth2 token handling, rendering, and security invariants (file permissions, BCC privacy, credential leak prevention); CI workflow runs `go test` + `go vet` on every PR 9 - - **Integration tests** (`make test-integration`) — end-to-end tests against a real IMAP/SMTP server: send plain email and verify From/To/Subject/HTML body round-trip, CC header, file attachment content, non-ASCII subject encoding (umlauts + emoji), IMAP search with `from:`/`subject:` prefixes, and move + undo; all test emails cleaned up automatically; skipped without credentials so `make test` stays fast and offline 9 + - **Integration tests** (`make test-integration`) — end-to-end tests against a real IMAP/SMTP server: send plain email and verify From/To/Subject/HTML body round-trip, CC header, file attachment content, non-ASCII subject encoding (umlauts + emoji), IMAP search with `from:`/`subject:` prefixes, move + undo, inline images, signature HTML rendering, SaveSent IMAP APPEND, comma-separated multiple recipients, and reply-all with 3 distinct addresses; all test emails cleaned up automatically; skipped without credentials so `make test` stays fast and offline 10 + - **Fix: multiple To recipients** — `Send()` now correctly splits comma-separated To addresses into individual SMTP RCPT TO commands; previously the entire `"a@x.com, b@x.com"` string was passed as a single address, causing delivery failures 11 + - **Fix: To/CC display** — reader and inbox now show all To and CC addresses, not just the first; `FetchHeadersByUID` (used by search/everything) now also populates To and CC fields 12 + - **Reply-all rebind to `ctrl+r`** — `R` (Shift+R) is now consistently reload/refresh in all views; reply-all moved to `ctrl+r` which works from both inbox list and reader (previously `R` conflicted between reload in inbox and reply-all in reader) 13 + - **Default signature for new users** — new installs get `*sent from [neomd](https://neomd.ssp.sh)*` as the default signature 10 14 11 15 # 2026-04-05 12 16 - **OAuth2 authentication** ([#3](https://github.com/ssp-data/neomd/pull/3), thanks [@notthatjesus](https://github.com/notthatjesus)) — accounts can set `auth_type = "oauth2"` with `oauth2_client_id`, `oauth2_client_secret`, `oauth2_issuer_url`, and `oauth2_scopes` instead of a password; on first launch neomd opens the browser for the authorization code flow, persists the token to `~/.config/neomd/tokens/<account>.json`, and refreshes it automatically; works with Gmail, Office365, and any OIDC-discoverable provider via XOAUTH2 over IMAP and SMTP; password auth paths unchanged for existing accounts
+2 -2
docs/keybindings.md
··· 108 108 | `n` | toggle read/unread (marked or cursor) | 109 109 | `ctrl+n` | mark all in current folder as read | 110 110 | `R` | reload / refresh folder | 111 - | `r` | reply (from reader) | 112 - | `shift+R` | reply-all — reply to sender + all CC recipients (from reader) | 111 + | `r` | reply (from inbox or reader) | 112 + | `ctrl+r` | reply-all — reply to sender + all CC recipients (from inbox or reader) | 113 113 | `f` | forward email (from reader or inbox) | 114 114 | `c` | compose new email | 115 115 | `ctrl+b (compose/pre-send)` | toggle Cc+Bcc fields (both hidden by default) |
+19 -1
internal/imap/client.go
··· 277 277 } 278 278 } 279 279 if len(m.Envelope.To) > 0 { 280 - e.To = m.Envelope.To[0].Addr() 280 + to := make([]string, 0, len(m.Envelope.To)) 281 + for _, a := range m.Envelope.To { 282 + to = append(to, a.Addr()) 283 + } 284 + e.To = strings.Join(to, ", ") 281 285 } 282 286 if len(m.Envelope.Cc) > 0 { 283 287 cc := make([]string, 0, len(m.Envelope.Cc)) ··· 577 581 } else { 578 582 e.From = a.Addr() 579 583 } 584 + } 585 + if len(m.Envelope.To) > 0 { 586 + to := make([]string, 0, len(m.Envelope.To)) 587 + for _, a := range m.Envelope.To { 588 + to = append(to, a.Addr()) 589 + } 590 + e.To = strings.Join(to, ", ") 591 + } 592 + if len(m.Envelope.Cc) > 0 { 593 + cc := make([]string, 0, len(m.Envelope.Cc)) 594 + for _, a := range m.Envelope.Cc { 595 + cc = append(cc, a.Addr()) 596 + } 597 + e.CC = strings.Join(cc, ", ") 580 598 } 581 599 } 582 600 emails = append(emails, e)
+98 -5
internal/integration_test.go
··· 561 561 email := waitForEmail(t, cli, "INBOX", subject, 30*time.Second) 562 562 defer cleanupEmail(t, cli, "INBOX", email.UID) 563 563 564 - // Fetch body and verify To header contains both addresses 565 - markdown, _, _, _, err := cli.FetchBody(context.Background(), "INBOX", email.UID) 564 + // Verify To field contains both addresses (not just the first) 565 + if !strings.Contains(email.To, env.user) { 566 + t.Errorf("To field missing primary address, got: %q", email.To) 567 + } 568 + if !strings.Contains(email.To, user2) { 569 + t.Errorf("To field missing second address %q, got: %q", user2, email.To) 570 + } 571 + 572 + // Verify CC is populated 573 + if email.CC == "" { 574 + t.Logf("Note: CC not populated in envelope (fetch path may not include it)") 575 + } else if !strings.Contains(email.CC, env.user) { 576 + t.Errorf("CC field missing %q, got: %q", env.user, email.CC) 577 + } 578 + 579 + t.Logf("Email delivered with To: %s, CC: %s", email.To, email.CC) 580 + } 581 + 582 + func TestIntegration_ReplyAllPreservesRecipients(t *testing.T) { 583 + env := loadEnv(t) 584 + cli := env.imapClient() 585 + defer cli.Close() 586 + 587 + // Three distinct addresses to properly test reply-all. 588 + // demo sends to simu + simon, then reply-all should CC both back. 589 + user2 := getEnvOr("NEOMD_TEST_USER2", "simu@sspaeti.com") 590 + user3 := getEnvOr("NEOMD_TEST_USER3", "simon@ssp.sh") 591 + 592 + // Step 1: Send a group email from demo to user2, CC user3 593 + origSubject := uniqueSubject("reply-all-orig") 594 + origBody := "Original group email for reply-all test." 595 + 596 + err := smtp.Send(env.smtpConfig(), user2, user3, "", origSubject, origBody, nil) 597 + if err != nil { 598 + t.Fatalf("Send original: %v", err) 599 + } 600 + 601 + // The email lands in demo's Sent (via SaveSent) but also in demo's INBOX 602 + // if demo is in CC. Since demo is not in To/CC here, we save to Sent to 603 + // have a copy to inspect. 604 + raw, err := smtp.BuildMessage(env.from, user2, user3, origSubject, origBody, nil) 605 + if err != nil { 606 + t.Fatalf("BuildMessage: %v", err) 607 + } 608 + err = cli.SaveSent(context.Background(), "Sent", raw) 609 + if err != nil { 610 + t.Fatalf("SaveSent: %v", err) 611 + } 612 + 613 + original := waitForEmail(t, cli, "Sent", origSubject, 15*time.Second) 614 + defer cleanupEmail(t, cli, "Sent", original.UID) 615 + 616 + // Step 2: Simulate reply-all from user2's perspective. 617 + // Reply-all logic: To = original sender, CC = all To + CC minus self. 618 + replySubject := "Re: " + origSubject 619 + replyBody := "Reply-all response.\n\n> " + origBody 620 + 621 + // To = original sender (demo) 622 + replyTo := env.user 623 + 624 + // CC = original To + CC, minus the replier (user2) 625 + allRecipients := original.To 626 + if original.CC != "" { 627 + allRecipients += ", " + original.CC 628 + } 629 + var replyCC []string 630 + user2Lower := strings.ToLower(user2) 631 + for _, addr := range strings.Split(allRecipients, ",") { 632 + a := strings.TrimSpace(addr) 633 + if a != "" && strings.ToLower(a) != user2Lower { 634 + replyCC = append(replyCC, a) 635 + } 636 + } 637 + replyCCStr := strings.Join(replyCC, ", ") 638 + 639 + t.Logf("Reply-all: To=%s CC=%s", replyTo, replyCCStr) 640 + 641 + err = smtp.Send(env.smtpConfig(), replyTo, replyCCStr, "", replySubject, replyBody, nil) 566 642 if err != nil { 567 - t.Fatalf("FetchBody: %v", err) 643 + t.Fatalf("Send reply-all: %v", err) 644 + } 645 + 646 + // Step 3: Verify the reply arrives at demo (the To recipient) 647 + reply := waitForEmail(t, cli, "INBOX", replySubject, 30*time.Second) 648 + defer cleanupEmail(t, cli, "INBOX", reply.UID) 649 + 650 + if !strings.Contains(reply.Subject, "Re:") { 651 + t.Errorf("Reply subject missing Re: prefix, got: %q", reply.Subject) 652 + } 653 + 654 + // To should be the demo account (original sender) 655 + if !strings.Contains(reply.To, env.user) { 656 + t.Errorf("Reply To missing demo address, got: %q", reply.To) 568 657 } 569 - _ = markdown 570 658 571 - t.Logf("Email delivered with To: %s, CC: %s", to, cc) 659 + // CC should contain user3 (simon@ssp.sh) 660 + if !strings.Contains(reply.CC, user3) { 661 + t.Errorf("Reply CC missing %q, got: %q", user3, reply.CC) 662 + } 663 + 664 + t.Logf("Reply-all delivered: To=%s CC=%s", reply.To, reply.CC) 572 665 } 573 666 574 667 // --- Helpers ---
+2 -2
internal/ui/keys.go
··· 80 80 {"n", "toggle read/unread (marked or cursor)"}, 81 81 {"ctrl+n", "mark all in current folder as read"}, 82 82 {"R", "reload / refresh folder"}, 83 - {"r", "reply (from reader)"}, 84 - {"shift+R", "reply-all — reply to sender + all CC recipients (from reader)"}, 83 + {"r", "reply (from inbox or reader)"}, 84 + {"ctrl+r", "reply-all — reply to sender + all CC recipients (from inbox or reader)"}, 85 85 {"f", "forward email (from reader or inbox)"}, 86 86 {"c", "compose new email"}, 87 87 {"ctrl+b (compose/pre-send)", "toggle Cc+Bcc fields (both hidden by default)"},
+15
internal/ui/model.go
··· 1966 1966 m.loading = true 1967 1967 return m, tea.Batch(m.spinner.Tick, m.fetchBodyCmd(e)) 1968 1968 1969 + case "ctrl+r": 1970 + e := selectedEmail(m.inbox) 1971 + if e == nil { 1972 + return m, nil 1973 + } 1974 + if idx := m.matchFromIndex(e.To, e.CC); idx >= 0 { 1975 + m.presendFromI = idx 1976 + } 1977 + m.pendingReplyAll = true 1978 + m.loading = true 1979 + return m, tea.Batch(m.spinner.Tick, m.fetchBodyCmd(e)) 1980 + 1969 1981 case "f": 1970 1982 e := selectedEmail(m.inbox) 1971 1983 if e == nil { ··· 2268 2280 return m.launchReplyCmd() 2269 2281 } 2270 2282 case "R": 2283 + m.loading = true 2284 + return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.activeFolder())) 2285 + case "ctrl+r": 2271 2286 if m.openEmail != nil { 2272 2287 return m.launchReplyAllCmd() 2273 2288 }
+2 -2
internal/ui/reader.go
··· 126 126 // readerHelp returns the one-line help string for the reader view. 127 127 // When isDraft is true, "E draft" is shown so the user knows they can re-open in compose. 128 128 func readerHelp(isDraft bool, hasLinks bool) string { 129 - keys := []string{"j/k scroll", "h/q back", "r reply", "f fwd", "e nvim"} 129 + keys := []string{"j/k scroll", "h/q back", "r reply", "ctrl+r reply-all", "f fwd", "e nvim"} 130 130 if isDraft { 131 131 keys = append(keys, "E draft") 132 132 } ··· 140 140 141 141 // inboxHelp returns the one-line help string for the inbox view. 142 142 func inboxHelp(folder string) string { 143 - base := []string{"enter/l open", "r reply", "f fwd", "c compose", "I/O/F/P/A screen", "g goto", "M move", "/ filter", "R reload", "? help", "q quit"} 143 + base := []string{"enter/l open", "r reply", "ctrl+r reply-all", "f fwd", "c compose", "I/O/F/P/A screen", "g goto", "M move", "/ filter", "R reload", "? help", "q quit"} 144 144 _ = folder 145 145 if folder == "ToScreen" { 146 146 base = []string{"I approve", "O block", "F feed", "P papertrail", "q back"}