···66- **`: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
77- **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
88- **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
99-- **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
99+- **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
1010+- **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
1111+- **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
1212+- **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)
1313+- **Default signature for new users** — new installs get `*sent from [neomd](https://neomd.ssp.sh)*` as the default signature
10141115# 2026-04-05
1216- **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
···108108| `n` | toggle read/unread (marked or cursor) |
109109| `ctrl+n` | mark all in current folder as read |
110110| `R` | reload / refresh folder |
111111-| `r` | reply (from reader) |
112112-| `shift+R` | reply-all — reply to sender + all CC recipients (from reader) |
111111+| `r` | reply (from inbox or reader) |
112112+| `ctrl+r` | reply-all — reply to sender + all CC recipients (from inbox or reader) |
113113| `f` | forward email (from reader or inbox) |
114114| `c` | compose new email |
115115| `ctrl+b (compose/pre-send)` | toggle Cc+Bcc fields (both hidden by default) |
+19-1
internal/imap/client.go
···277277 }
278278 }
279279 if len(m.Envelope.To) > 0 {
280280- e.To = m.Envelope.To[0].Addr()
280280+ to := make([]string, 0, len(m.Envelope.To))
281281+ for _, a := range m.Envelope.To {
282282+ to = append(to, a.Addr())
283283+ }
284284+ e.To = strings.Join(to, ", ")
281285 }
282286 if len(m.Envelope.Cc) > 0 {
283287 cc := make([]string, 0, len(m.Envelope.Cc))
···577581 } else {
578582 e.From = a.Addr()
579583 }
584584+ }
585585+ if len(m.Envelope.To) > 0 {
586586+ to := make([]string, 0, len(m.Envelope.To))
587587+ for _, a := range m.Envelope.To {
588588+ to = append(to, a.Addr())
589589+ }
590590+ e.To = strings.Join(to, ", ")
591591+ }
592592+ if len(m.Envelope.Cc) > 0 {
593593+ cc := make([]string, 0, len(m.Envelope.Cc))
594594+ for _, a := range m.Envelope.Cc {
595595+ cc = append(cc, a.Addr())
596596+ }
597597+ e.CC = strings.Join(cc, ", ")
580598 }
581599 }
582600 emails = append(emails, e)
+98-5
internal/integration_test.go
···561561 email := waitForEmail(t, cli, "INBOX", subject, 30*time.Second)
562562 defer cleanupEmail(t, cli, "INBOX", email.UID)
563563564564- // Fetch body and verify To header contains both addresses
565565- markdown, _, _, _, err := cli.FetchBody(context.Background(), "INBOX", email.UID)
564564+ // Verify To field contains both addresses (not just the first)
565565+ if !strings.Contains(email.To, env.user) {
566566+ t.Errorf("To field missing primary address, got: %q", email.To)
567567+ }
568568+ if !strings.Contains(email.To, user2) {
569569+ t.Errorf("To field missing second address %q, got: %q", user2, email.To)
570570+ }
571571+572572+ // Verify CC is populated
573573+ if email.CC == "" {
574574+ t.Logf("Note: CC not populated in envelope (fetch path may not include it)")
575575+ } else if !strings.Contains(email.CC, env.user) {
576576+ t.Errorf("CC field missing %q, got: %q", env.user, email.CC)
577577+ }
578578+579579+ t.Logf("Email delivered with To: %s, CC: %s", email.To, email.CC)
580580+}
581581+582582+func TestIntegration_ReplyAllPreservesRecipients(t *testing.T) {
583583+ env := loadEnv(t)
584584+ cli := env.imapClient()
585585+ defer cli.Close()
586586+587587+ // Three distinct addresses to properly test reply-all.
588588+ // demo sends to simu + simon, then reply-all should CC both back.
589589+ user2 := getEnvOr("NEOMD_TEST_USER2", "simu@sspaeti.com")
590590+ user3 := getEnvOr("NEOMD_TEST_USER3", "simon@ssp.sh")
591591+592592+ // Step 1: Send a group email from demo to user2, CC user3
593593+ origSubject := uniqueSubject("reply-all-orig")
594594+ origBody := "Original group email for reply-all test."
595595+596596+ err := smtp.Send(env.smtpConfig(), user2, user3, "", origSubject, origBody, nil)
597597+ if err != nil {
598598+ t.Fatalf("Send original: %v", err)
599599+ }
600600+601601+ // The email lands in demo's Sent (via SaveSent) but also in demo's INBOX
602602+ // if demo is in CC. Since demo is not in To/CC here, we save to Sent to
603603+ // have a copy to inspect.
604604+ raw, err := smtp.BuildMessage(env.from, user2, user3, origSubject, origBody, nil)
605605+ if err != nil {
606606+ t.Fatalf("BuildMessage: %v", err)
607607+ }
608608+ err = cli.SaveSent(context.Background(), "Sent", raw)
609609+ if err != nil {
610610+ t.Fatalf("SaveSent: %v", err)
611611+ }
612612+613613+ original := waitForEmail(t, cli, "Sent", origSubject, 15*time.Second)
614614+ defer cleanupEmail(t, cli, "Sent", original.UID)
615615+616616+ // Step 2: Simulate reply-all from user2's perspective.
617617+ // Reply-all logic: To = original sender, CC = all To + CC minus self.
618618+ replySubject := "Re: " + origSubject
619619+ replyBody := "Reply-all response.\n\n> " + origBody
620620+621621+ // To = original sender (demo)
622622+ replyTo := env.user
623623+624624+ // CC = original To + CC, minus the replier (user2)
625625+ allRecipients := original.To
626626+ if original.CC != "" {
627627+ allRecipients += ", " + original.CC
628628+ }
629629+ var replyCC []string
630630+ user2Lower := strings.ToLower(user2)
631631+ for _, addr := range strings.Split(allRecipients, ",") {
632632+ a := strings.TrimSpace(addr)
633633+ if a != "" && strings.ToLower(a) != user2Lower {
634634+ replyCC = append(replyCC, a)
635635+ }
636636+ }
637637+ replyCCStr := strings.Join(replyCC, ", ")
638638+639639+ t.Logf("Reply-all: To=%s CC=%s", replyTo, replyCCStr)
640640+641641+ err = smtp.Send(env.smtpConfig(), replyTo, replyCCStr, "", replySubject, replyBody, nil)
566642 if err != nil {
567567- t.Fatalf("FetchBody: %v", err)
643643+ t.Fatalf("Send reply-all: %v", err)
644644+ }
645645+646646+ // Step 3: Verify the reply arrives at demo (the To recipient)
647647+ reply := waitForEmail(t, cli, "INBOX", replySubject, 30*time.Second)
648648+ defer cleanupEmail(t, cli, "INBOX", reply.UID)
649649+650650+ if !strings.Contains(reply.Subject, "Re:") {
651651+ t.Errorf("Reply subject missing Re: prefix, got: %q", reply.Subject)
652652+ }
653653+654654+ // To should be the demo account (original sender)
655655+ if !strings.Contains(reply.To, env.user) {
656656+ t.Errorf("Reply To missing demo address, got: %q", reply.To)
568657 }
569569- _ = markdown
570658571571- t.Logf("Email delivered with To: %s, CC: %s", to, cc)
659659+ // CC should contain user3 (simon@ssp.sh)
660660+ if !strings.Contains(reply.CC, user3) {
661661+ t.Errorf("Reply CC missing %q, got: %q", user3, reply.CC)
662662+ }
663663+664664+ t.Logf("Reply-all delivered: To=%s CC=%s", reply.To, reply.CC)
572665}
573666574667// --- Helpers ---
+2-2
internal/ui/keys.go
···8080 {"n", "toggle read/unread (marked or cursor)"},
8181 {"ctrl+n", "mark all in current folder as read"},
8282 {"R", "reload / refresh folder"},
8383- {"r", "reply (from reader)"},
8484- {"shift+R", "reply-all — reply to sender + all CC recipients (from reader)"},
8383+ {"r", "reply (from inbox or reader)"},
8484+ {"ctrl+r", "reply-all — reply to sender + all CC recipients (from inbox or reader)"},
8585 {"f", "forward email (from reader or inbox)"},
8686 {"c", "compose new email"},
8787 {"ctrl+b (compose/pre-send)", "toggle Cc+Bcc fields (both hidden by default)"},
+15
internal/ui/model.go
···19661966 m.loading = true
19671967 return m, tea.Batch(m.spinner.Tick, m.fetchBodyCmd(e))
1968196819691969+ case "ctrl+r":
19701970+ e := selectedEmail(m.inbox)
19711971+ if e == nil {
19721972+ return m, nil
19731973+ }
19741974+ if idx := m.matchFromIndex(e.To, e.CC); idx >= 0 {
19751975+ m.presendFromI = idx
19761976+ }
19771977+ m.pendingReplyAll = true
19781978+ m.loading = true
19791979+ return m, tea.Batch(m.spinner.Tick, m.fetchBodyCmd(e))
19801980+19691981 case "f":
19701982 e := selectedEmail(m.inbox)
19711983 if e == nil {
···22682280 return m.launchReplyCmd()
22692281 }
22702282 case "R":
22832283+ m.loading = true
22842284+ return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.activeFolder()))
22852285+ case "ctrl+r":
22712286 if m.openEmail != nil {
22722287 return m.launchReplyAllCmd()
22732288 }
+2-2
internal/ui/reader.go
···126126// readerHelp returns the one-line help string for the reader view.
127127// When isDraft is true, "E draft" is shown so the user knows they can re-open in compose.
128128func readerHelp(isDraft bool, hasLinks bool) string {
129129- keys := []string{"j/k scroll", "h/q back", "r reply", "f fwd", "e nvim"}
129129+ keys := []string{"j/k scroll", "h/q back", "r reply", "ctrl+r reply-all", "f fwd", "e nvim"}
130130 if isDraft {
131131 keys = append(keys, "E draft")
132132 }
···140140141141// inboxHelp returns the one-line help string for the inbox view.
142142func inboxHelp(folder string) string {
143143- 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"}
143143+ 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"}
144144 _ = folder
145145 if folder == "ToScreen" {
146146 base = []string{"I approve", "O block", "F feed", "P papertrail", "q back"}