···11# Changelog
2233# 2026-04-21
44-- **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
55-- **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
44+- **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
55+- **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
6677# 2026-04-18
88- **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
···19631963 return m, tea.Batch(m.bgFetchInboxCmd(), m.scheduleBgSync())
1964196419651965 case bgInboxFetchedMsg:
19661966- m.bgSyncInProgress = false // clear flag regardless of success/failure
19661966+ // Keep bgSyncInProgress set until the entire fetch-and-screen cycle completes.
19671967+ // Clear it only on early-exit paths where no follow-up work is scheduled.
19671968 if msg.emails == nil {
19681969 // Error case (network down, etc.) - silently skip until next tick
19701970+ m.bgSyncInProgress = false
19691971 return m, nil
19701972 }
19711973 if err := m.validateScreenerSafety(); err != nil {
19721974 m.status = err.Error()
19731975 m.isError = true
19761976+ m.bgSyncInProgress = false
19741977 return m, nil
19751978 }
19761979 moves := m.classifyForScreen(msg.emails)
19771980 if len(moves) == 0 {
19811981+ // No moves needed - background sync is complete
19821982+ m.bgSyncInProgress = false
19781983 return m, nil
19791984 }
19851985+ // bgSyncInProgress stays set - will be cleared in bgScreenDoneMsg
19801986 return m, m.bgExecAutoScreenCmd(moves)
1981198719821988 case bgScreenDoneMsg:
19891989+ // Background sync cycle complete - clear the guard flag
19901990+ m.bgSyncInProgress = false
19831991 if msg.moved > 0 {
19841992 if msg.moved < msg.total {
19851993 m.status = fmt.Sprintf("Background sync: screened %d/%d — press R to retry", msg.moved, msg.total)
···3899390739003908 cc := ""
39013909 if replyAll {
39023902- // Collect original To + CC, exclude all own addresses
39103910+ // Collect original To + CC, exclude all own addresses.
39113911+ // Build exclusion set from both account User (IMAP login) and From (send-as)
39123912+ // to handle setups where they differ (e.g., user123@provider vs simon@domain).
39033913 ownAddrs := make(map[string]bool)
39143914+ // Add all account User addresses (IMAP login)
39153915+ for _, acc := range m.accounts {
39163916+ ownAddrs[strings.ToLower(extractEmailAddr(acc.User))] = true
39173917+ }
39183918+ // Add all From addresses (accounts + sender aliases)
39043919 for _, from := range m.presendFroms() {
39053920 ownAddrs[strings.ToLower(extractEmailAddr(from))] = true
39063921 }
+169
internal/ui/model_test.go
···448448 t.Fatalf("subject = %q, want unchanged real subject", got.emails[0].Subject)
449449 }
450450}
451451+452452+func TestReplyAllExcludesAllOwnAddresses(t *testing.T) {
453453+ tests := []struct {
454454+ name string
455455+ cfg *config.Config
456456+ email *imap.Email
457457+ wantCC string
458458+ wantExcl []string // addresses that should be excluded
459459+ }{
460460+ {
461461+ name: "single account - exclude From",
462462+ cfg: &config.Config{
463463+ Accounts: []config.AccountConfig{
464464+ {User: "simon@ssp.sh", From: "Simon Späti <simon@ssp.sh>"},
465465+ },
466466+ },
467467+ email: &imap.Email{
468468+ From: "kristen@rilldata.com",
469469+ To: "simon@ssp.sh",
470470+ CC: "marianne@rilldata.com",
471471+ },
472472+ wantCC: "marianne@rilldata.com",
473473+ wantExcl: []string{"simon@ssp.sh"},
474474+ },
475475+ {
476476+ name: "user != from (critical edge case)",
477477+ cfg: &config.Config{
478478+ Accounts: []config.AccountConfig{
479479+ {User: "user123@mail.provider.com", From: "Simon Späti <simon@ssp.sh>"},
480480+ },
481481+ },
482482+ email: &imap.Email{
483483+ From: "alice@example.com",
484484+ To: "user123@mail.provider.com",
485485+ CC: "bob@example.com",
486486+ },
487487+ wantCC: "bob@example.com",
488488+ wantExcl: []string{"user123@mail.provider.com", "simon@ssp.sh"},
489489+ },
490490+ {
491491+ name: "multiple accounts - exclude all",
492492+ cfg: &config.Config{
493493+ Accounts: []config.AccountConfig{
494494+ {User: "personal@example.com", From: "Me <personal@example.com>"},
495495+ {User: "work@company.com", From: "Me <work@company.com>"},
496496+ },
497497+ },
498498+ email: &imap.Email{
499499+ From: "client@business.com",
500500+ To: "work@company.com, client-team@business.com",
501501+ CC: "personal@example.com, other@business.com",
502502+ },
503503+ wantCC: "client-team@business.com, other@business.com",
504504+ wantExcl: []string{"work@company.com", "personal@example.com"},
505505+ },
506506+ {
507507+ name: "sender aliases excluded",
508508+ cfg: &config.Config{
509509+ Accounts: []config.AccountConfig{
510510+ {User: "me@example.com", From: "Me <me@example.com>"},
511511+ },
512512+ Senders: []config.SenderConfig{
513513+ {From: "Support <support@example.com>"},
514514+ },
515515+ },
516516+ email: &imap.Email{
517517+ From: "customer@client.com",
518518+ To: "support@example.com",
519519+ CC: "me@example.com, customer-team@client.com",
520520+ },
521521+ wantCC: "customer-team@client.com",
522522+ wantExcl: []string{"me@example.com", "support@example.com"},
523523+ },
524524+ {
525525+ name: "case insensitive matching",
526526+ cfg: &config.Config{
527527+ Accounts: []config.AccountConfig{
528528+ {User: "simon@ssp.sh", From: "Simon <simon@ssp.sh>"},
529529+ },
530530+ },
531531+ email: &imap.Email{
532532+ From: "alice@example.com",
533533+ To: "SIMON@SSP.SH",
534534+ CC: "Simon <Simon@Ssp.Sh>, bob@example.com",
535535+ },
536536+ wantCC: "bob@example.com",
537537+ wantExcl: []string{"simon@ssp.sh"},
538538+ },
539539+ {
540540+ name: "named addresses with brackets",
541541+ cfg: &config.Config{
542542+ Accounts: []config.AccountConfig{
543543+ {User: "me@work.com", From: "John Doe <me@work.com>"},
544544+ },
545545+ },
546546+ email: &imap.Email{
547547+ From: "Jane <jane@client.com>",
548548+ To: "John Doe <me@work.com>",
549549+ CC: "Alice <alice@client.com>, Bob <me@work.com>",
550550+ },
551551+ wantCC: "Alice <alice@client.com>",
552552+ wantExcl: []string{"me@work.com"},
553553+ },
554554+ {
555555+ name: "empty CC when all recipients are self",
556556+ cfg: &config.Config{
557557+ Accounts: []config.AccountConfig{
558558+ {User: "me@example.com", From: "Me <me@example.com>"},
559559+ },
560560+ },
561561+ email: &imap.Email{
562562+ From: "sender@client.com",
563563+ To: "me@example.com",
564564+ CC: "",
565565+ },
566566+ wantCC: "",
567567+ wantExcl: []string{"me@example.com"},
568568+ },
569569+ }
570570+571571+ for _, tt := range tests {
572572+ t.Run(tt.name, func(t *testing.T) {
573573+ m := Model{
574574+ cfg: tt.cfg,
575575+ accounts: tt.cfg.ActiveAccounts(),
576576+ }
577577+578578+ // Build the exclusion set exactly as launchReplyWithCC does
579579+ ownAddrs := make(map[string]bool)
580580+ for _, acc := range m.accounts {
581581+ ownAddrs[strings.ToLower(extractEmailAddr(acc.User))] = true
582582+ }
583583+ for _, from := range m.presendFroms() {
584584+ ownAddrs[strings.ToLower(extractEmailAddr(from))] = true
585585+ }
586586+587587+ // Verify all expected addresses are in the exclusion set
588588+ for _, excl := range tt.wantExcl {
589589+ lowerExcl := strings.ToLower(extractEmailAddr(excl))
590590+ if !ownAddrs[lowerExcl] {
591591+ t.Errorf("expected %q to be in exclusion set, but it's missing", excl)
592592+ }
593593+ }
594594+595595+ // Simulate the reply-all CC building logic
596596+ var parts []string
597597+ for _, addr := range splitAddrs(tt.email.To + "," + tt.email.CC) {
598598+ if a := strings.TrimSpace(addr); a != "" {
599599+ addrLower := strings.ToLower(extractEmailAddr(a))
600600+ if !ownAddrs[addrLower] {
601601+ parts = append(parts, a)
602602+ }
603603+ }
604604+ }
605605+ gotCC := strings.Join(parts, ", ")
606606+607607+ if gotCC != tt.wantCC {
608608+ t.Errorf("reply-all CC = %q, want %q", gotCC, tt.wantCC)
609609+ }
610610+611611+ // Double-check: verify none of the excluded addresses appear in the result
612612+ for _, excl := range tt.wantExcl {
613613+ if strings.Contains(strings.ToLower(gotCC), strings.ToLower(extractEmailAddr(excl))) {
614614+ t.Errorf("excluded address %q should not appear in CC: %q", excl, gotCC)
615615+ }
616616+ }
617617+ })
618618+ }
619619+}