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 roborev reviews

sspaeti b55e59b5 1794d525

+188 -4
+2 -2
CHANGELOG.md
··· 1 1 # Changelog 2 2 3 3 # 2026-04-21 4 - - **Fix: potential memory leak in background sync** — fixed infinite loop that occurred when IMAP errors (e.g., after suspend/resume) triggered immediate retry instead of waiting for next scheduled interval; `bgFetchInboxCmd()` now returns nil on error instead of `bgSyncTickMsg{}`, preventing tight loop that consumes large amounts of RAM; added `bgSyncInProgress` flag to prevent concurrent background syncs from piling up during slow network conditions 5 - - **Fix: reply-all excludes all own addresses** — `ctrl+r` reply-all now excludes all configured email addresses (accounts + sender aliases) from the CC field; previously only excluded the active account's address, causing your own email to appear in CC when the original email was sent to one of your other addresses 4 + - **Fix: potential memory leak in background sync** — fixed infinite loop that occurred when IMAP errors (e.g., after suspend/resume) triggered immediate retry instead of waiting for next scheduled interval; `bgFetchInboxCmd()` now returns nil on error instead of `bgSyncTickMsg{}`, preventing tight loop that consumes large amounts of RAM; added `bgSyncInProgress` flag that covers the entire fetch-and-screen cycle (kept set until `bgScreenDoneMsg`), preventing concurrent background syncs from piling up during slow network or long screening operations 5 + - **Fix: reply-all excludes all own addresses** — `ctrl+r` reply-all now excludes both IMAP login addresses (`account.User`) and send-as addresses (`account.From`, `sender.From`) from the CC field; fixes edge cases where `user != from` (e.g., login as `user123@provider.com` but send as `simon@domain.com`) would still leak the login address into CC; previously only excluded `From` addresses. Added test suite added covering single/multi-account, sender aliases, case sensitivity, and named addresses 6 6 7 7 # 2026-04-18 8 8 - **Headless daemon mode (`--headless`)** — run neomd as a background daemon on a server (NAS, VPS, always-on device) to continuously screen emails without launching the TUI; daemon fetches inbox and auto-screens every `bg_sync_interval` minutes (configurable in config); watches screener list files (`~/.config/neomd/lists/*.txt`) and reloads when they change (designed for Syncthing multi-device sync); graceful shutdown on SIGTERM/SIGINT; structured logging to stdout with `log/slog`; use case: run daemon on NAS with `bg_sync_interval = 5`, disable background sync on laptop/Android with `bg_sync_interval = 0`, classify senders in TUI, Syncthing syncs lists to NAS, daemon auto-screens incoming emails so mobile IMAP apps see correctly filtered folders; perfect for using neomd's screener with native mobile email clients; daemon only reads screener lists and moves emails (never modifies lists), all sender classification happens in TUI; includes comprehensive tests for classification logic and daemon lifecycle; documented in `docs/configurations/headless.md` with Syncthing setup guide, systemd service example, and multi-device workflow; `make daemon` target for testing
+17 -2
internal/ui/model.go
··· 1963 1963 return m, tea.Batch(m.bgFetchInboxCmd(), m.scheduleBgSync()) 1964 1964 1965 1965 case bgInboxFetchedMsg: 1966 - m.bgSyncInProgress = false // clear flag regardless of success/failure 1966 + // Keep bgSyncInProgress set until the entire fetch-and-screen cycle completes. 1967 + // Clear it only on early-exit paths where no follow-up work is scheduled. 1967 1968 if msg.emails == nil { 1968 1969 // Error case (network down, etc.) - silently skip until next tick 1970 + m.bgSyncInProgress = false 1969 1971 return m, nil 1970 1972 } 1971 1973 if err := m.validateScreenerSafety(); err != nil { 1972 1974 m.status = err.Error() 1973 1975 m.isError = true 1976 + m.bgSyncInProgress = false 1974 1977 return m, nil 1975 1978 } 1976 1979 moves := m.classifyForScreen(msg.emails) 1977 1980 if len(moves) == 0 { 1981 + // No moves needed - background sync is complete 1982 + m.bgSyncInProgress = false 1978 1983 return m, nil 1979 1984 } 1985 + // bgSyncInProgress stays set - will be cleared in bgScreenDoneMsg 1980 1986 return m, m.bgExecAutoScreenCmd(moves) 1981 1987 1982 1988 case bgScreenDoneMsg: 1989 + // Background sync cycle complete - clear the guard flag 1990 + m.bgSyncInProgress = false 1983 1991 if msg.moved > 0 { 1984 1992 if msg.moved < msg.total { 1985 1993 m.status = fmt.Sprintf("Background sync: screened %d/%d — press R to retry", msg.moved, msg.total) ··· 3899 3907 3900 3908 cc := "" 3901 3909 if replyAll { 3902 - // Collect original To + CC, exclude all own addresses 3910 + // Collect original To + CC, exclude all own addresses. 3911 + // Build exclusion set from both account User (IMAP login) and From (send-as) 3912 + // to handle setups where they differ (e.g., user123@provider vs simon@domain). 3903 3913 ownAddrs := make(map[string]bool) 3914 + // Add all account User addresses (IMAP login) 3915 + for _, acc := range m.accounts { 3916 + ownAddrs[strings.ToLower(extractEmailAddr(acc.User))] = true 3917 + } 3918 + // Add all From addresses (accounts + sender aliases) 3904 3919 for _, from := range m.presendFroms() { 3905 3920 ownAddrs[strings.ToLower(extractEmailAddr(from))] = true 3906 3921 }
+169
internal/ui/model_test.go
··· 448 448 t.Fatalf("subject = %q, want unchanged real subject", got.emails[0].Subject) 449 449 } 450 450 } 451 + 452 + func TestReplyAllExcludesAllOwnAddresses(t *testing.T) { 453 + tests := []struct { 454 + name string 455 + cfg *config.Config 456 + email *imap.Email 457 + wantCC string 458 + wantExcl []string // addresses that should be excluded 459 + }{ 460 + { 461 + name: "single account - exclude From", 462 + cfg: &config.Config{ 463 + Accounts: []config.AccountConfig{ 464 + {User: "simon@ssp.sh", From: "Simon Späti <simon@ssp.sh>"}, 465 + }, 466 + }, 467 + email: &imap.Email{ 468 + From: "kristen@rilldata.com", 469 + To: "simon@ssp.sh", 470 + CC: "marianne@rilldata.com", 471 + }, 472 + wantCC: "marianne@rilldata.com", 473 + wantExcl: []string{"simon@ssp.sh"}, 474 + }, 475 + { 476 + name: "user != from (critical edge case)", 477 + cfg: &config.Config{ 478 + Accounts: []config.AccountConfig{ 479 + {User: "user123@mail.provider.com", From: "Simon Späti <simon@ssp.sh>"}, 480 + }, 481 + }, 482 + email: &imap.Email{ 483 + From: "alice@example.com", 484 + To: "user123@mail.provider.com", 485 + CC: "bob@example.com", 486 + }, 487 + wantCC: "bob@example.com", 488 + wantExcl: []string{"user123@mail.provider.com", "simon@ssp.sh"}, 489 + }, 490 + { 491 + name: "multiple accounts - exclude all", 492 + cfg: &config.Config{ 493 + Accounts: []config.AccountConfig{ 494 + {User: "personal@example.com", From: "Me <personal@example.com>"}, 495 + {User: "work@company.com", From: "Me <work@company.com>"}, 496 + }, 497 + }, 498 + email: &imap.Email{ 499 + From: "client@business.com", 500 + To: "work@company.com, client-team@business.com", 501 + CC: "personal@example.com, other@business.com", 502 + }, 503 + wantCC: "client-team@business.com, other@business.com", 504 + wantExcl: []string{"work@company.com", "personal@example.com"}, 505 + }, 506 + { 507 + name: "sender aliases excluded", 508 + cfg: &config.Config{ 509 + Accounts: []config.AccountConfig{ 510 + {User: "me@example.com", From: "Me <me@example.com>"}, 511 + }, 512 + Senders: []config.SenderConfig{ 513 + {From: "Support <support@example.com>"}, 514 + }, 515 + }, 516 + email: &imap.Email{ 517 + From: "customer@client.com", 518 + To: "support@example.com", 519 + CC: "me@example.com, customer-team@client.com", 520 + }, 521 + wantCC: "customer-team@client.com", 522 + wantExcl: []string{"me@example.com", "support@example.com"}, 523 + }, 524 + { 525 + name: "case insensitive matching", 526 + cfg: &config.Config{ 527 + Accounts: []config.AccountConfig{ 528 + {User: "simon@ssp.sh", From: "Simon <simon@ssp.sh>"}, 529 + }, 530 + }, 531 + email: &imap.Email{ 532 + From: "alice@example.com", 533 + To: "SIMON@SSP.SH", 534 + CC: "Simon <Simon@Ssp.Sh>, bob@example.com", 535 + }, 536 + wantCC: "bob@example.com", 537 + wantExcl: []string{"simon@ssp.sh"}, 538 + }, 539 + { 540 + name: "named addresses with brackets", 541 + cfg: &config.Config{ 542 + Accounts: []config.AccountConfig{ 543 + {User: "me@work.com", From: "John Doe <me@work.com>"}, 544 + }, 545 + }, 546 + email: &imap.Email{ 547 + From: "Jane <jane@client.com>", 548 + To: "John Doe <me@work.com>", 549 + CC: "Alice <alice@client.com>, Bob <me@work.com>", 550 + }, 551 + wantCC: "Alice <alice@client.com>", 552 + wantExcl: []string{"me@work.com"}, 553 + }, 554 + { 555 + name: "empty CC when all recipients are self", 556 + cfg: &config.Config{ 557 + Accounts: []config.AccountConfig{ 558 + {User: "me@example.com", From: "Me <me@example.com>"}, 559 + }, 560 + }, 561 + email: &imap.Email{ 562 + From: "sender@client.com", 563 + To: "me@example.com", 564 + CC: "", 565 + }, 566 + wantCC: "", 567 + wantExcl: []string{"me@example.com"}, 568 + }, 569 + } 570 + 571 + for _, tt := range tests { 572 + t.Run(tt.name, func(t *testing.T) { 573 + m := Model{ 574 + cfg: tt.cfg, 575 + accounts: tt.cfg.ActiveAccounts(), 576 + } 577 + 578 + // Build the exclusion set exactly as launchReplyWithCC does 579 + ownAddrs := make(map[string]bool) 580 + for _, acc := range m.accounts { 581 + ownAddrs[strings.ToLower(extractEmailAddr(acc.User))] = true 582 + } 583 + for _, from := range m.presendFroms() { 584 + ownAddrs[strings.ToLower(extractEmailAddr(from))] = true 585 + } 586 + 587 + // Verify all expected addresses are in the exclusion set 588 + for _, excl := range tt.wantExcl { 589 + lowerExcl := strings.ToLower(extractEmailAddr(excl)) 590 + if !ownAddrs[lowerExcl] { 591 + t.Errorf("expected %q to be in exclusion set, but it's missing", excl) 592 + } 593 + } 594 + 595 + // Simulate the reply-all CC building logic 596 + var parts []string 597 + for _, addr := range splitAddrs(tt.email.To + "," + tt.email.CC) { 598 + if a := strings.TrimSpace(addr); a != "" { 599 + addrLower := strings.ToLower(extractEmailAddr(a)) 600 + if !ownAddrs[addrLower] { 601 + parts = append(parts, a) 602 + } 603 + } 604 + } 605 + gotCC := strings.Join(parts, ", ") 606 + 607 + if gotCC != tt.wantCC { 608 + t.Errorf("reply-all CC = %q, want %q", gotCC, tt.wantCC) 609 + } 610 + 611 + // Double-check: verify none of the excluded addresses appear in the result 612 + for _, excl := range tt.wantExcl { 613 + if strings.Contains(strings.ToLower(gotCC), strings.ToLower(extractEmailAddr(excl))) { 614 + t.Errorf("excluded address %q should not appear in CC: %q", excl, gotCC) 615 + } 616 + } 617 + }) 618 + } 619 + }