From f227c588c2a326bdc9fcba72ae58289648e90c82 Mon Sep 17 00:00:00 2001 From: Jesus Rodriguez Date: Sat, 16 May 2026 23:32:19 +0200 Subject: [PATCH 01/17] Added SignatureConfig to the AccountConfig. Added a method to Config to resolve the Signature either from the account or the UI. Replaced the calls to the signature in model.go --- internal/config/config.go | 54 ++++++++++++++++--------------- internal/ui/model.go | 68 ++++++++++++++++++++++++--------------- 2 files changed, 70 insertions(+), 52 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 487de82..2d5961a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -46,6 +46,9 @@ 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 + + // Signature + Signature SignatureConfig `toml:"signature_block"` // Per account Signature } // IsOAuth2 reports whether this account uses OAuth2 instead of password auth. @@ -264,20 +267,6 @@ type UIConfig struct { MarkAsReadAfterSecs int `toml:"mark_as_read_after_secs"` // seconds in reader before marking as read (0 = immediate, default 7) } -// TextSignature returns the text/markdown signature for editor and text/plain part. -// Prefers signature_block.text, falls back to legacy signature field. -func (u UIConfig) TextSignature() string { - if u.SignatureBlock.Text != "" { - return u.SignatureBlock.Text - } - return u.Signature -} - -// HTMLSignature returns the HTML signature for text/html part, or empty if not configured. -func (u UIConfig) HTMLSignature() string { - return u.SignatureBlock.HTML -} - // DraftBackups returns the max number of rolling draft backups (default 20, -1 = disabled). func (u UIConfig) DraftBackups() int { if u.DraftBackupCount == 0 { @@ -402,6 +391,19 @@ func (c *Config) ActiveAccounts() []AccountConfig { return nil } +// Signature resolves what signature to use and returns a SignatureConfig. +// It's up to the caller to decide to use either HTML or Text. The function +// resolves first to dedicated account signature if defined, else the UI signature +func (c *Config) Signature(a AccountConfig) SignatureConfig { + if a.Signature.Text != "" || a.Signature.HTML != "" { + return a.Signature + } + if c.UI.SignatureBlock.Text != "" || c.UI.SignatureBlock.HTML != "" { + return c.UI.SignatureBlock + } + return SignatureConfig{Text: c.UI.Signature} +} + // DefaultPath returns ~/.config/neomd/config.toml. func DefaultPath() string { home, _ := os.UserHomeDir() @@ -418,7 +420,7 @@ var cacheDirName = "neomd" func HistoryPath() string { if dir, err := os.UserCacheDir(); err == nil { p := filepath.Join(dir, cacheDirName) - _ = os.MkdirAll(p, 0700) + _ = os.MkdirAll(p, 0o700) return filepath.Join(p, "cmd_history") } return filepath.Join(os.TempDir(), fmt.Sprintf("neomd_%d_cmd_history", os.Getuid())) @@ -428,11 +430,11 @@ func HistoryPath() string { func DraftsBackupDir() string { if dir, err := os.UserCacheDir(); err == nil { p := filepath.Join(dir, cacheDirName, "drafts") - _ = os.MkdirAll(p, 0700) + _ = os.MkdirAll(p, 0o700) return p } p := filepath.Join(os.TempDir(), fmt.Sprintf("neomd_%d_drafts", os.Getuid())) - _ = os.MkdirAll(p, 0700) + _ = os.MkdirAll(p, 0o700) return p } @@ -440,7 +442,7 @@ func DraftsBackupDir() string { func CrashLogPath() string { if dir, err := os.UserCacheDir(); err == nil { p := filepath.Join(dir, cacheDirName) - _ = os.MkdirAll(p, 0700) + _ = os.MkdirAll(p, 0o700) return filepath.Join(p, "crash.log") } return filepath.Join(os.TempDir(), fmt.Sprintf("neomd_%d_crash.log", os.Getuid())) @@ -450,7 +452,7 @@ func CrashLogPath() string { func SpyPixelCachePath() string { if dir, err := os.UserCacheDir(); err == nil { p := filepath.Join(dir, cacheDirName) - _ = os.MkdirAll(p, 0700) + _ = os.MkdirAll(p, 0o700) return filepath.Join(p, "spy_pixels") } return filepath.Join(os.TempDir(), fmt.Sprintf("neomd_%d_spy_pixels", os.Getuid())) @@ -461,7 +463,7 @@ func SpyPixelCachePath() string { func NotifyStatePath() string { if dir, err := os.UserCacheDir(); err == nil { p := filepath.Join(dir, cacheDirName) - _ = os.MkdirAll(p, 0700) + _ = os.MkdirAll(p, 0o700) return filepath.Join(p, "notify_state.json") } return filepath.Join(os.TempDir(), fmt.Sprintf("neomd_%d_notify_state.json", os.Getuid())) @@ -484,8 +486,8 @@ func IsFirstRun() bool { // MarkWelcomeShown creates the marker so IsFirstRun returns false next time. func MarkWelcomeShown() { p := welcomePath() - _ = os.MkdirAll(filepath.Dir(p), 0700) - _ = os.WriteFile(p, []byte("1"), 0600) + _ = os.MkdirAll(filepath.Dir(p), 0o700) + _ = os.WriteFile(p, []byte("1"), 0o600) } // Load reads config from path (or default location if path is empty). @@ -528,9 +530,9 @@ func Load(path string) (*Config, error) { cfg.Screener.Notify, } { if p != "" { - _ = os.MkdirAll(filepath.Dir(p), 0700) + _ = os.MkdirAll(filepath.Dir(p), 0o700) if _, err := os.Stat(p); os.IsNotExist(err) { - _ = os.WriteFile(p, nil, 0600) + _ = os.WriteFile(p, nil, 0o600) } } } @@ -691,10 +693,10 @@ func defaults() *Config { } func writeDefault(path string, cfg *Config) error { - if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return err } - f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0600) + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0o600) if err != nil { return err } diff --git a/internal/ui/model.go b/internal/ui/model.go index 34e948b..cb5136d 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -216,7 +216,7 @@ type MailtoParams struct { // a draft after a crash) and avoids cluttering /tmp/. func neomdTempDir() string { dir := filepath.Join(os.TempDir(), "neomd") - os.MkdirAll(dir, 0700) //nolint + os.MkdirAll(dir, 0o700) //nolint return dir } @@ -277,7 +277,7 @@ func backupDraft(tmpPath string, maxBackups int) { if err != nil || len(src) == 0 { return } - _ = os.WriteFile(dst, src, 0600) + _ = os.WriteFile(dst, src, 0o600) // Prune oldest if over limit. files := listBackupsByAge(dir) @@ -367,11 +367,19 @@ func (m Model) writeDebugReport() tea.Cmd { b.WriteString("\n## Folder Mapping\n\n") f := m.cfg.Folders folders := [][2]string{ - {"Inbox", f.Inbox}, {"Sent", f.Sent}, {"Trash", f.Trash}, - {"Drafts", f.Drafts}, {"ToScreen", f.ToScreen}, {"Feed", f.Feed}, - {"PaperTrail", f.PaperTrail}, {"ScreenedOut", f.ScreenedOut}, - {"Archive", f.Archive}, {"Waiting", f.Waiting}, - {"Scheduled", f.Scheduled}, {"Someday", f.Someday}, {"Spam", f.Spam}, + {"Inbox", f.Inbox}, + {"Sent", f.Sent}, + {"Trash", f.Trash}, + {"Drafts", f.Drafts}, + {"ToScreen", f.ToScreen}, + {"Feed", f.Feed}, + {"PaperTrail", f.PaperTrail}, + {"ScreenedOut", f.ScreenedOut}, + {"Archive", f.Archive}, + {"Waiting", f.Waiting}, + {"Scheduled", f.Scheduled}, + {"Someday", f.Someday}, + {"Spam", f.Spam}, {"Work", f.Work}, } for _, kv := range folders { @@ -390,8 +398,11 @@ func (m Model) writeDebugReport() tea.Cmd { b.WriteString("\n## Screener Lists\n\n") sc := m.cfg.Screener lists := [][2]string{ - {"screened_in", sc.ScreenedIn}, {"screened_out", sc.ScreenedOut}, - {"feed", sc.Feed}, {"papertrail", sc.PaperTrail}, {"spam", sc.Spam}, + {"screened_in", sc.ScreenedIn}, + {"screened_out", sc.ScreenedOut}, + {"feed", sc.Feed}, + {"papertrail", sc.PaperTrail}, + {"spam", sc.Spam}, {"notify", sc.Notify}, } for _, kv := range lists { @@ -450,7 +461,7 @@ func (m Model) writeDebugReport() tea.Cmd { // Write to file path := filepath.Join(neomdTempDir(), "debug.log") - if err := os.WriteFile(path, []byte(b.String()), 0600); err != nil { + if err := os.WriteFile(path, []byte(b.String()), 0o600); err != nil { return errMsg{fmt.Errorf("write debug report: %w", err)} } @@ -903,7 +914,7 @@ func (m Model) sendEmailCmd(smtpAcct config.AccountConfig, from, to, cc, bcc, su replyCli := m.imapCliForAccount(replyToAccount) htmlSignature := "" if includeHTMLSig { - htmlSignature = m.cfg.UI.HTMLSignature() + htmlSignature = m.cfg.Signature(smtpAcct).HTML } return func() tea.Msg { // Build raw MIME once — reused for both SMTP delivery and Sent copy. @@ -1591,8 +1602,10 @@ func (m Model) spyScanCmd() tea.Cmd { for i, uid := range uids { spy, err := cli.ScanSpyPixels(nil, folder, uid) if err != nil { - return spyScanProgressMsg{err: err, scanned: i, total: total, found: found, - spyKeys: spyFound, scannedKeys: allScanned} + return spyScanProgressMsg{ + err: err, scanned: i, total: total, found: found, + spyKeys: spyFound, scannedKeys: allScanned, + } } key := spyPixelKey(folder, uid) allScanned = append(allScanned, key) @@ -3191,7 +3204,7 @@ func loadCmdHistory(path string) []string { // Called in a goroutine — errors are silently ignored. func saveCmdHistory(path string, history []string) { content := strings.Join(history, "\n") + "\n" - _ = os.WriteFile(path, []byte(content), 0600) + _ = os.WriteFile(path, []byte(content), 0o600) } // loadSpyPixelCache reads the spy pixel cache from disk. @@ -3230,7 +3243,7 @@ func saveSpyPixelCache(spyKeys, scannedKeys map[string]bool) { lines = append(lines, "-"+k) } } - _ = os.WriteFile(config.SpyPixelCachePath(), []byte(strings.Join(lines, "\n")+"\n"), 0600) + _ = os.WriteFile(config.SpyPixelCachePath(), []byte(strings.Join(lines, "\n")+"\n"), 0o600) } // safeGo runs fn in a goroutine with panic recovery. If the goroutine panics, @@ -3251,7 +3264,7 @@ func safeGo(fn func()) { // writeCrashLog appends a panic record to the crash log file. func writeCrashLog(r interface{}, stack []byte) { path := config.CrashLogPath() - f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600) + f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) if err != nil { return } @@ -3809,7 +3822,7 @@ func (m Model) openInBrowser() (tea.Model, tea.Cmd) { continue } - if err := os.WriteFile(imgPath, a.Data, 0600); err != nil { + if err := os.WriteFile(imgPath, a.Data, 0o600); err != nil { continue } tmpImages = append(tmpImages, imgPath) @@ -3962,7 +3975,7 @@ func (m Model) downloadOpenAttachmentCmd(a imap.Attachment) tea.Cmd { return attachOpenDoneMsg{err: err} } dir := filepath.Join(home, "Downloads") - if err := os.MkdirAll(dir, 0755); err != nil { + if err := os.MkdirAll(dir, 0o755); err != nil { return attachOpenDoneMsg{err: fmt.Errorf("create Downloads: %w", err)} } // Avoid overwriting existing files by appending a counter before the extension. @@ -3978,7 +3991,7 @@ func (m Model) downloadOpenAttachmentCmd(a imap.Attachment) tea.Cmd { } } } - if err := os.WriteFile(dst, a.Data, 0644); err != nil { + if err := os.WriteFile(dst, a.Data, 0o644); err != nil { return attachOpenDoneMsg{err: fmt.Errorf("save attachment: %w", err)} } ext := strings.ToLower(filepath.Ext(base)) @@ -4021,7 +4034,7 @@ func (m Model) downloadEMLCmd() tea.Cmd { return emlDownloadedMsg{err: err} } dir := filepath.Join(home, "Downloads") - if err := os.MkdirAll(dir, 0755); err != nil { + if err := os.MkdirAll(dir, 0o755); err != nil { return emlDownloadedMsg{err: fmt.Errorf("create Downloads: %w", err)} } // Sanitize subject for filename @@ -4048,7 +4061,7 @@ func (m Model) downloadEMLCmd() tea.Cmd { } } } - if err := os.WriteFile(dst, raw, 0644); err != nil { + if err := os.WriteFile(dst, raw, 0o644); err != nil { return emlDownloadedMsg{err: fmt.Errorf("save EML: %w", err)} } return emlDownloadedMsg{path: dst} @@ -4204,7 +4217,7 @@ func (m Model) openICSCmd() tea.Cmd { return nil } dir := filepath.Join(home, ".cache", "neomd", "ical") - if err := os.MkdirAll(dir, 0700); err != nil { + if err := os.MkdirAll(dir, 0o700); err != nil { m.status = "open .ics: " + err.Error() m.isError = true return nil @@ -4218,7 +4231,7 @@ func (m Model) openICSCmd() tea.Cmd { name = fmt.Sprintf("invite-%d.ics", time.Now().Unix()) } dst := filepath.Join(dir, name) - if err := os.WriteFile(dst, att.Data, 0600); err != nil { + if err := os.WriteFile(dst, att.Data, 0o600); err != nil { m.status = "open .ics: " + err.Error() m.isError = true return nil @@ -4602,7 +4615,8 @@ func (m Model) launchSpellCheckCmd(ps *pendingSendData) (tea.Model, tea.Cmd) { // Open nvim with spell on and jump to first misspelled word. // VimEnter + defer_fn ensures spell activates AFTER all plugins load. - cmd := exec.Command("nvim", + cmd := exec.Command( + "nvim", "-c", `autocmd VimEnter * ++once lua vim.defer_fn(function() vim.wo.spell = true; vim.bo.spelllang = "en_us,de"; vim.cmd("normal! gg]s") end, 100)`, tmpPath, ) @@ -4747,7 +4761,8 @@ func (m Model) previewInBrowser() (tea.Model, tea.Cmd) { // Inject HTML signature before tag if enabled (matching send path) if includeHTMLSig { - htmlSig := m.cfg.UI.HTMLSignature() + acct := m.presendSMTPAccount() + htmlSig := m.cfg.Signature(acct).HTML if htmlSig != "" { idx := strings.LastIndex(htmlBody, "") if idx >= 0 { @@ -4811,7 +4826,8 @@ func (m Model) launchEditorCmd() (tea.Model, tea.Cmd) { // When a mailto body is present, insert it before the signature so // the signature always appears at the bottom of the composed message. - sig := m.cfg.UI.TextSignature() + acct := m.presendSMTPAccount() + sig := m.cfg.Signature(acct).Text var prelude string if mailtoBody != "" { // Build headers without signature, append body, then signature. From 11c8c1f0aa256013ae8d03d53fdcd2a14ca46ebd Mon Sep 17 00:00:00 2001 From: Jesus Rodriguez Date: Sat, 16 May 2026 23:33:06 +0200 Subject: [PATCH 02/17] Added dedicated test for the signature blocks --- internal/config/config_test.go | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 94f7670..a45901c 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -179,6 +179,38 @@ func TestBulkThreshold(t *testing.T) { } } +func TestSignature(t *testing.T) { + accountWithBoth := AccountConfig{Signature: SignatureConfig{Text: "acct-text", HTML: "

acct-html

"}} + accountTextOnly := AccountConfig{Signature: SignatureConfig{Text: "acct-text"}} + accountHTMLOnly := AccountConfig{Signature: SignatureConfig{HTML: "

acct-html

"}} + accountEmpty := AccountConfig{} + + uiWithBlock := UIConfig{SignatureBlock: SignatureConfig{Text: "ui-text", HTML: "

ui-html

"}} + uiLegacyOnly := UIConfig{Signature: "legacy-sig"} + uiEmpty := UIConfig{} + + tests := []struct { + name string + cfg *Config + acct AccountConfig + want SignatureConfig + }{ + {"account block wins when populated", &Config{UI: uiWithBlock}, accountWithBoth, SignatureConfig{Text: "acct-text", HTML: "

acct-html

"}}, + {"account text-only does not leak ui html", &Config{UI: uiWithBlock}, accountTextOnly, SignatureConfig{Text: "acct-text"}}, + {"account html-only does not leak ui text", &Config{UI: uiWithBlock}, accountHTMLOnly, SignatureConfig{HTML: "

acct-html

"}}, + {"falls back to ui block when account empty", &Config{UI: uiWithBlock}, accountEmpty, SignatureConfig{Text: "ui-text", HTML: "

ui-html

"}}, + {"falls back to legacy ui.signature", &Config{UI: uiLegacyOnly}, accountEmpty, SignatureConfig{Text: "legacy-sig"}}, + {"all empty returns zero value", &Config{UI: uiEmpty}, accountEmpty, SignatureConfig{}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.cfg.Signature(tt.acct); got != tt.want { + t.Errorf("Signature() = %+v, want %+v", got, tt.want) + } + }) + } +} + func TestLabelFor(t *testing.T) { // Custom IMAP folder names (e.g. HEY-style labels). fc := FoldersConfig{ From d744fd57638c519353beb17aabe1b738359d9ab9 Mon Sep 17 00:00:00 2001 From: Jesus Rodriguez Date: Sun, 17 May 2026 00:47:13 +0200 Subject: [PATCH 03/17] Added AccountFoldersConfig to allow per-account overrides of Sent/Trash/Drafts/Spam. Added Config.ResolveFolders to merge the account block onto the global folders field-by-field. Added unit tests pinning the field-by-field semantics. --- internal/config/config.go | 32 ++++++++++++++ internal/config/config_test.go | 80 ++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+) diff --git a/internal/config/config.go b/internal/config/config.go index 2d5961a..e1902e5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -49,6 +49,9 @@ type AccountConfig struct { // Signature Signature SignatureConfig `toml:"signature_block"` // Per account Signature + + // Folders + Folders AccountFoldersConfig `toml:"folders"` // Per account folders } // IsOAuth2 reports whether this account uses OAuth2 instead of password auth. @@ -169,6 +172,17 @@ type FoldersConfig struct { TabOrder []string `toml:"tab_order"` } +// AccountFoldersConfig is the subset of folder names that may be overridden +// per account. The other folders in FoldersConfig (Inbox, ToScreen, Feed, +// PaperTrail, Archive, Waiting, Scheduled, Someday, Work, TabOrder) are +// global concepts and not configurable per account. +type AccountFoldersConfig struct { + Sent string `toml:"sent"` + Trash string `toml:"trash"` + Drafts string `toml:"drafts"` + Spam string `toml:"spam"` +} + // defaultTabOrder is the built-in tab order when tab_order is not configured. var defaultTabOrder = []string{"inbox", "to_screen", "feed", "papertrail", "waiting", "someday", "scheduled", "sent", "archive", "screened_out", "drafts", "trash"} @@ -404,6 +418,24 @@ func (c *Config) Signature(a AccountConfig) SignatureConfig { return SignatureConfig{Text: c.UI.Signature} } +// ResolveFolders returns the global folders with any per account override applied. +func (c *Config) ResolveFolders(a AccountConfig) FoldersConfig { + out := c.Folders + if a.Folders.Sent != "" { + out.Sent = a.Folders.Sent + } + if a.Folders.Trash != "" { + out.Trash = a.Folders.Trash + } + if a.Folders.Drafts != "" { + out.Drafts = a.Folders.Drafts + } + if a.Folders.Spam != "" { + out.Spam = a.Folders.Spam + } + return out +} + // DefaultPath returns ~/.config/neomd/config.toml. func DefaultPath() string { home, _ := os.UserHomeDir() diff --git a/internal/config/config_test.go b/internal/config/config_test.go index a45901c..4fb9179 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -3,6 +3,7 @@ package config import ( "os" "path/filepath" + "reflect" "strings" "testing" @@ -211,6 +212,85 @@ func TestSignature(t *testing.T) { } } +func TestResolveFolders(t *testing.T) { + globalFolders := FoldersConfig{ + Inbox: "INBOX", + Sent: "Sent", + Trash: "Trash", + Drafts: "Drafts", + ToScreen: "ToScreen", + Feed: "Feed", + PaperTrail: "PaperTrail", + ScreenedOut: "ScreenedOut", + Archive: "Archive", + Waiting: "Waiting", + Scheduled: "Scheduled", + Someday: "Someday", + Spam: "Spam", + Work: "Work", + TabOrder: []string{"inbox", "sent"}, + } + + tests := []struct { + name string + cfg *Config + acct AccountConfig + want FoldersConfig + }{ + { + name: "empty account folders inherit global as-is", + cfg: &Config{Folders: globalFolders}, + acct: AccountConfig{}, + want: globalFolders, + }, + { + name: "sparse override only changes that field", + cfg: &Config{Folders: globalFolders}, + acct: AccountConfig{Folders: AccountFoldersConfig{Sent: "[Gmail]/Sent Mail"}}, + want: func() FoldersConfig { + f := globalFolders + f.Sent = "[Gmail]/Sent Mail" + return f + }(), + }, + { + name: "full override of all four account-level fields, non-overridable stay global", + cfg: &Config{Folders: globalFolders}, + acct: AccountConfig{Folders: AccountFoldersConfig{ + Sent: "[Gmail]/Sent Mail", + Trash: "[Gmail]/Trash", + Drafts: "[Gmail]/Drafts", + Spam: "[Gmail]/Spam", + }}, + want: FoldersConfig{ + Inbox: "INBOX", + Sent: "[Gmail]/Sent Mail", + Trash: "[Gmail]/Trash", + Drafts: "[Gmail]/Drafts", + ToScreen: "ToScreen", + Feed: "Feed", + PaperTrail: "PaperTrail", + ScreenedOut: "ScreenedOut", + Archive: "Archive", + Waiting: "Waiting", + Scheduled: "Scheduled", + Someday: "Someday", + Spam: "[Gmail]/Spam", + Work: "Work", + TabOrder: []string{"inbox", "sent"}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.cfg.ResolveFolders(tt.acct) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("ResolveFolders() = %+v, want %+v", got, tt.want) + } + }) + } +} + func TestLabelFor(t *testing.T) { // Custom IMAP folder names (e.g. HEY-style labels). fc := FoldersConfig{ From f41bcc759c7cdd50ea0733d801f4a21475b812dd Mon Sep 17 00:00:00 2001 From: Jesus Rodriguez Date: Sun, 17 May 2026 00:48:20 +0200 Subject: [PATCH 04/17] Routed Sent/Drafts IMAP saves through per-account folder resolution. Added sentDraftsIMAPAccount() as a single resolver for the account whose Sent/Drafts mailboxes receive a send or draft save, so the IMAP client and the folder name can never drift apart under per-account folder configs. Removed the now-unused sentDraftsIMAPClient and presendIMAPClient wrappers. --- internal/ui/model.go | 44 +++++++++++++++++++++++++-------------- internal/ui/model_test.go | 12 +++++------ 2 files changed, 34 insertions(+), 22 deletions(-) diff --git a/internal/ui/model.go b/internal/ui/model.go index cb5136d..98adaeb 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -799,15 +799,23 @@ func (m Model) primaryIMAPClient() *imap.Client { return nil } -func (m Model) sentDraftsIMAPClient() *imap.Client { +// sentDraftsIMAPAccount returns the account whose Sent/Drafts IMAP folders +// should receive a sent message or saved draft. When +// StoreSentDraftsInSendingAccount is true the sending account is used; +// otherwise the first account with a live IMAP client (matching +// primaryIMAPClient's "first non-nil" logic). Use this so both the IMAP +// client and the resolved folder name come from the same account — +// preventing "no such mailbox" mismatches under per-account folder configs. +func (m Model) sentDraftsIMAPAccount() config.AccountConfig { if m.cfg != nil && m.cfg.StoreSentDraftsInSendingAccount { - return m.imapCliForAccount(m.presendSMTPAccount().Name) + return m.presendSMTPAccount() } - return m.primaryIMAPClient() -} - -func (m Model) presendIMAPClient() *imap.Client { - return m.sentDraftsIMAPClient() + for i, c := range m.clients { + if c != nil { + return m.accounts[i] + } + } + return m.accounts[0] } func (m *Model) applyEditedFrom(from string) { @@ -909,8 +917,9 @@ func (m Model) sendEmailCmd(smtpAcct config.AccountConfig, from, to, cc, bcc, su TLSCertFile: smtpAcct.TLSCertFile, TokenSource: m.tokenSourceFor(smtpAcct.Name), } - cli := m.sentDraftsIMAPClient() - sentFolder := m.cfg.Folders.Sent + sentAcct := m.sentDraftsIMAPAccount() + cli := m.imapCliForAccount(sentAcct.Name) + sentFolder := m.cfg.ResolveFolders(sentAcct).Sent replyCli := m.imapCliForAccount(replyToAccount) htmlSignature := "" if includeHTMLSig { @@ -1038,8 +1047,9 @@ func (m Model) sendReactionCmd(smtpAcct config.AccountConfig, from, to, subject, TLSCertFile: smtpAcct.TLSCertFile, TokenSource: m.tokenSourceFor(smtpAcct.Name), } - cli := m.sentDraftsIMAPClient() - sentFolder := m.cfg.Folders.Sent + sentAcct := m.sentDraftsIMAPAccount() + cli := m.imapCliForAccount(sentAcct.Name) + sentFolder := m.cfg.ResolveFolders(sentAcct).Sent replyCli := m.imapCli() return func() tea.Msg { @@ -4168,8 +4178,9 @@ func (m Model) sendRSVPCmd(status calendar.Status) tea.Cmd { } to := ev.Organizer - sentCli := m.sentDraftsIMAPClient() - sentFolder := m.cfg.Folders.Sent + sentAcct := m.sentDraftsIMAPAccount() + sentCli := m.imapCliForAccount(sentAcct.Name) + sentFolder := m.cfg.ResolveFolders(sentAcct).Sent openEmail := m.openEmail replyCli := m.imapCliForAccount(acct.Name) return func() tea.Msg { @@ -4567,7 +4578,7 @@ func (m Model) updatePresend(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil case "d": // Save to Drafts without sending. - return m, m.saveDraftCmd(m.presendIMAPClient(), m.presendFrom(), ps.to, ps.cc, ps.bcc, ps.subject, ps.body, m.attachments) + return m, m.saveDraftCmd(m.sentDraftsIMAPAccount(), m.presendFrom(), ps.to, ps.cc, ps.bcc, ps.subject, ps.body, m.attachments) case "ctrl+b": // Toggle CC/BCC fields — show input prompts to add/edit them. m.compose.extraVisible = !m.compose.extraVisible @@ -4803,8 +4814,9 @@ 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 { - folder := m.cfg.Folders.Drafts +func (m Model) saveDraftCmd(acct config.AccountConfig, from, to, cc, bcc, subject, body string, attachments []string) tea.Cmd { + imapCli := m.imapCliForAccount(acct.Name) + folder := m.cfg.ResolveFolders(acct).Drafts return func() tea.Msg { raw, err := smtp.BuildDraftMessage(from, to, cc, bcc, subject, body, attachments) if err != nil { diff --git a/internal/ui/model_test.go b/internal/ui/model_test.go index fe47e80..66faa2c 100644 --- a/internal/ui/model_test.go +++ b/internal/ui/model_test.go @@ -244,7 +244,7 @@ func TestReactionAutoSelectsCorrectFromAndSMTP(t *testing.T) { } } -func TestSentDraftsIMAPClient_DefaultsToPrimaryAccount(t *testing.T) { +func TestSentDraftsIMAPAccount_DefaultsToPrimaryAccount(t *testing.T) { cfg := &config.Config{ Accounts: []config.AccountConfig{ {Name: "Personal", From: "me@example.com"}, @@ -260,12 +260,12 @@ func TestSentDraftsIMAPClient_DefaultsToPrimaryAccount(t *testing.T) { presendFromI: 1, // sending as Work } - if got := m.sentDraftsIMAPClient(); got != personal { - t.Fatal("sentDraftsIMAPClient() should default to the primary IMAP account") + if got := m.sentDraftsIMAPAccount(); got.Name != "Personal" { + t.Fatalf("sentDraftsIMAPAccount().Name = %q, want %q", got.Name, "Personal") } } -func TestSentDraftsIMAPClient_FollowsSendingAccountWhenEnabled(t *testing.T) { +func TestSentDraftsIMAPAccount_FollowsSendingAccountWhenEnabled(t *testing.T) { cfg := &config.Config{ Accounts: []config.AccountConfig{ {Name: "Personal", From: "me@example.com"}, @@ -282,8 +282,8 @@ func TestSentDraftsIMAPClient_FollowsSendingAccountWhenEnabled(t *testing.T) { presendFromI: 1, // sending as Work } - if got := m.sentDraftsIMAPClient(); got != work { - t.Fatal("sentDraftsIMAPClient() should follow the selected sending account when enabled") + if got := m.sentDraftsIMAPAccount(); got.Name != "Work" { + t.Fatalf("sentDraftsIMAPAccount().Name = %q, want %q", got.Name, "Work") } } From f4faef3b996d77e3fce71f423d4d6f25a9295919 Mon Sep 17 00:00:00 2001 From: Jesus Rodriguez Date: Sun, 17 May 2026 01:04:17 +0200 Subject: [PATCH 05/17] Routed per-account folder operations through Config.ResolveFolders. Spam/Trash moves, screener classifications, batch delete, and folder-comparison checks now use the active account's folder names so per-account overrides take effect. Made activeAccount() safe against zero-account Models so the new per-account lookups don't panic in unit tests that hand-build minimal models. --- internal/ui/model.go | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/internal/ui/model.go b/internal/ui/model.go index 98adaeb..176cb6b 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -723,12 +723,17 @@ func (m Model) tokenSourceFor(accountName string) func() (string, error) { return nil } -// activeAccount returns the currently selected AccountConfig. +// activeAccount returns the currently selected AccountConfig, or the zero +// value when no accounts are configured (only reachable in unit tests that +// hand-build a Model; production config validation rejects empty accounts). func (m Model) activeAccount() config.AccountConfig { if m.accountI < len(m.accounts) { return m.accounts[m.accountI] } - return m.accounts[0] + if len(m.accounts) > 0 { + return m.accounts[0] + } + return config.AccountConfig{} } // presendFroms returns all available From addresses: all accounts first (in @@ -1290,7 +1295,7 @@ func (m Model) batchScreenerCmd(emails []imap.Email, action string) tea.Cmd { case "P": dst = cfg.Folders.PaperTrail case "$": - dst = cfg.Folders.Spam + dst = cfg.ResolveFolders(m.activeAccount()).Spam } ops = append(ops, op{e.From, e.Folder, e.UID, dst}) } @@ -1329,7 +1334,7 @@ func (m Model) batchScreenerCmd(emails []imap.Email, action string) tea.Cmd { case "P": dst = cfg.Folders.PaperTrail case "$": - dst = cfg.Folders.Spam + dst = cfg.ResolveFolders(m.activeAccount()).Spam } expandedOps = append(expandedOps, op{e.From, e.Folder, e.UID, dst}) } @@ -1690,7 +1695,7 @@ func (m Model) deleteAllSearchCmd() tea.Cmd { // emptyTrashSearchCmd is like deleteAllSearchCmd but always targets Trash. func (m Model) emptyTrashSearchCmd() tea.Cmd { - folder := m.cfg.Folders.Trash + folder := m.cfg.ResolveFolders(m.activeAccount()).Trash return func() tea.Msg { uids, err := m.imapCli().SearchUIDs(nil, folder) if err != nil { @@ -1887,7 +1892,7 @@ func (m Model) screenerCmd(e *imap.Email, action string) tea.Cmd { dst = m.cfg.Folders.PaperTrail case "$": addErr = m.screener.MarkSpam(e.From) - dst = m.cfg.Folders.Spam + dst = m.cfg.ResolveFolders(m.activeAccount()).Spam } if addErr != nil { return errMsg{addErr} @@ -2381,7 +2386,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var dst string switch cat { case screener.CategorySpam: - dst = m.cfg.Folders.Spam + dst = m.cfg.ResolveFolders(m.activeAccount()).Spam case screener.CategoryScreenedOut: dst = m.cfg.Folders.ScreenedOut case screener.CategoryFeed: @@ -2817,10 +2822,10 @@ func (m Model) updateInbox(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } m.loading = true m.bulkProgress = m.newBulkOp("Deleting", len(targets)) - return m, tea.Batch(m.spinner.Tick, m.batchMoveCmd(targets, m.cfg.Folders.Trash)) + return m, tea.Batch(m.spinner.Tick, m.batchMoveCmd(targets, m.cfg.ResolveFolders(m.activeAccount()).Trash)) case "X": // permanent delete (marked or cursor) — only in Trash - if m.activeFolder() != m.cfg.Folders.Trash { + if m.activeFolder() != m.cfg.ResolveFolders(m.activeAccount()).Trash { m.status = "X only works in Trash. Use x to move to Trash first." m.isError = true return m, nil @@ -2834,7 +2839,7 @@ func (m Model) updateInbox(msg tea.KeyMsg) (tea.Model, tea.Cmd) { uids = append(uids, e.UID) } m.loading = true - return m, tea.Batch(m.spinner.Tick, m.deleteAllExecCmd(m.cfg.Folders.Trash, uids)) + return m, tea.Batch(m.spinner.Tick, m.deleteAllExecCmd(m.cfg.ResolveFolders(m.activeAccount()).Trash, uids)) case "ctrl+u": // clear all marks m.markedUIDs = make(map[uint32]bool) @@ -3336,7 +3341,7 @@ func (m *Model) applyFilter() tea.Cmd { query := strings.ToLower(m.filterText) // In Sent folder, search To/CC/BCC instead of From — From is always us. var hay string - if len(m.folders) > 0 && m.activeFolder() == m.cfg.Folders.Sent { + if len(m.folders) > 0 && m.activeFolder() == m.cfg.ResolveFolders(m.activeAccount()).Sent { hay = strings.ToLower(e.To + " " + e.CC + " " + e.BCC + " " + e.Subject) } else { hay = strings.ToLower(e.From + " " + e.Subject) @@ -3354,7 +3359,7 @@ func (m *Model) applyFilter() tea.Cmd { filtered = m.emails } - noThread := len(m.folders) > 0 && m.activeFolder() == m.cfg.Folders.Sent + noThread := len(m.folders) > 0 && m.activeFolder() == m.cfg.ResolveFolders(m.activeAccount()).Sent return setEmails(&m.inbox, filtered, m.markedUIDs, m.spyPixelKeys, m.shouldPrefixFolderInSubject(), m.sortField, m.sortReverse, noThread) } @@ -5589,7 +5594,7 @@ func (m Model) viewReader() string { } else { b.WriteString(m.reader.View()) } - isDraft := m.openEmail != nil && m.openEmail.Folder == m.cfg.Folders.Drafts + isDraft := m.openEmail != nil && m.openEmail.Folder == m.cfg.ResolveFolders(m.activeAccount()).Drafts if m.status != "" { b.WriteString("\n" + statusBar(m.status, m.isError)) } else { From 02e5600dbadd27ddd63c54ea8ecd76b2ff9a098c Mon Sep 17 00:00:00 2001 From: Jesus Rodriguez Date: Sun, 17 May 2026 01:20:27 +0200 Subject: [PATCH 06/17] =?UTF-8?q?Routed=20active-account=20UI=20sites=20th?= =?UTF-8?q?rough=20Config.ResolveFolders.=20activeFolder()=20now=20returns?= =?UTF-8?q?=20the=20per-account=20IMAP=20mailbox=20name=20for=20the=20acti?= =?UTF-8?q?ve=20account=20so=20the=20existing=20Sent/Trash/isDraft=20compa?= =?UTF-8?q?risons=20resolve=20correctly=20under=20per-account=20overrides.?= =?UTF-8?q?=20Migrated=20the=20M-chord=20bulk=20move=20map,=20gS/gd=20jump?= =?UTF-8?q?=20chords,=20the=20:go-spam=20cmdline=20command,=20and=20the=20?= =?UTF-8?q?newInboxList=20constructor's=20Sent/Drafts=20strings.=20Added?= =?UTF-8?q?=20a=20test=20pinning=20activeFolder()=20per-account=20override?= =?UTF-8?q?=20behavior.=20Inbox=20list=20delegate=20still=20bakes=20Sent/D?= =?UTF-8?q?rafts=20at=20construction=20time=20and=20is=20not=20refreshed?= =?UTF-8?q?=20on=20account=20switch=20=E2=80=94=20pending=20follow-up.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/ui/cmdline.go | 2 +- internal/ui/model.go | 68 +++++++++++++++++++++------------------ internal/ui/model_test.go | 42 ++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 33 deletions(-) diff --git a/internal/ui/cmdline.go b/internal/ui/cmdline.go index 85508ed..968ca75 100644 --- a/internal/ui/cmdline.go +++ b/internal/ui/cmdline.go @@ -171,7 +171,7 @@ func init() { run: func(m *Model) (tea.Model, tea.Cmd) { m.loading = true m.status = "Spam folder — press R to reload, tab to leave" - return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.cfg.Folders.Spam)) + return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.cfg.ResolveFolders(m.activeAccount()).Spam)) }, }, { diff --git a/internal/ui/model.go b/internal/ui/model.go index 176cb6b..0eb2f8a 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -850,41 +850,43 @@ func (m Model) Init() tea.Cmd { return tea.Batch(cmds...) } -// activeFolder maps the active tab label to an IMAP mailbox name. +// activeFolder maps the active tab label to an IMAP mailbox name, +// honouring per-account folder overrides for the active account. func (m Model) activeFolder() string { + f := m.cfg.ResolveFolders(m.activeAccount()) switch m.offTabFolder { case "Drafts": - return m.cfg.Folders.Drafts + return f.Drafts case "Spam": - return m.cfg.Folders.Spam + return f.Spam } switch m.folders[m.activeFolderI] { case "ToScreen": - return m.cfg.Folders.ToScreen + return f.ToScreen case "Feed": - return m.cfg.Folders.Feed + return f.Feed case "PaperTrail": - return m.cfg.Folders.PaperTrail + return f.PaperTrail case "Sent": - return m.cfg.Folders.Sent + return f.Sent case "Trash": - return m.cfg.Folders.Trash + return f.Trash case "Archive": - return m.cfg.Folders.Archive + return f.Archive case "Waiting": - return m.cfg.Folders.Waiting + return f.Waiting case "Scheduled": - return m.cfg.Folders.Scheduled + return f.Scheduled case "Someday": - return m.cfg.Folders.Someday + return f.Someday case "ScreenedOut": - return m.cfg.Folders.ScreenedOut + return f.ScreenedOut case "Spam": - return m.cfg.Folders.Spam + return f.Spam case "Work": - return m.cfg.Folders.Work + return f.Work default: - return m.cfg.Folders.Inbox + return f.Inbox } } @@ -1943,7 +1945,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { listH = 5 } if m.inbox.Width() == 0 { - m.inbox = newInboxList(msg.Width, listH, m.cfg.Folders.Sent, m.cfg.Folders.Drafts) + f := m.cfg.ResolveFolders(m.activeAccount()) + m.inbox = newInboxList(msg.Width, listH, f.Sent, f.Drafts) } else { m.inbox.SetSize(msg.Width, listH) } @@ -3408,14 +3411,14 @@ func (m Model) handleChord(prefix, key string) (tea.Model, tea.Cmd) { m.offTabFolder = "Spam" m.imapSearchText = "" m.status = "Spam folder — press R to reload, tab to leave" - return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.cfg.Folders.Spam)) + return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.cfg.ResolveFolders(m.activeAccount()).Spam)) } if key == "d" { // gd — go to Drafts (not in tab rotation) m.loading = true m.offTabFolder = "Drafts" m.imapSearchText = "" m.status = "Drafts folder — press R to reload, tab to leave" - return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.cfg.Folders.Drafts)) + return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.cfg.ResolveFolders(m.activeAccount()).Drafts)) } if key == "e" { // ge — Everything: latest emails across all folders m.loading = true @@ -3456,22 +3459,23 @@ func (m Model) handleChord(prefix, key string) (tea.Model, tea.Cmd) { if len(targets) == 0 { return m, nil } + f := m.cfg.ResolveFolders(m.activeAccount()) dstMap := map[string]string{ - "i": m.cfg.Folders.Inbox, - "a": m.cfg.Folders.Archive, - "f": m.cfg.Folders.Feed, - "p": m.cfg.Folders.PaperTrail, - "t": m.cfg.Folders.Trash, - "s": m.cfg.Folders.Sent, - "o": m.cfg.Folders.ScreenedOut, - "w": m.cfg.Folders.Waiting, - "c": m.cfg.Folders.Scheduled, - "m": m.cfg.Folders.Someday, - "k": m.cfg.Folders.ToScreen, + "i": f.Inbox, + "a": f.Archive, + "f": f.Feed, + "p": f.PaperTrail, + "t": f.Trash, + "s": f.Sent, + "o": f.ScreenedOut, + "w": f.Waiting, + "c": f.Scheduled, + "m": f.Someday, + "k": f.ToScreen, } // Only add Work folder if configured - if m.cfg.Folders.Work != "" { - dstMap["b"] = m.cfg.Folders.Work + if f.Work != "" { + dstMap["b"] = f.Work } if dst, ok := dstMap[key]; ok { m.loading = true diff --git a/internal/ui/model_test.go b/internal/ui/model_test.go index 66faa2c..3ece43f 100644 --- a/internal/ui/model_test.go +++ b/internal/ui/model_test.go @@ -396,6 +396,48 @@ func TestActiveFolderUsesOffTabFolder(t *testing.T) { } } +func TestActiveFolderHonorsPerAccountOverride(t *testing.T) { + cfg := &config.Config{ + Folders: config.FoldersConfig{ + Inbox: "INBOX", Sent: "Sent", Trash: "Trash", + Drafts: "Drafts", Spam: "Spam", + }, + Accounts: []config.AccountConfig{ + {Name: "Personal"}, // no override → globals + {Name: "Work", Folders: config.AccountFoldersConfig{ + Sent: "[Gmail]/Sent Mail", + Drafts: "[Gmail]/Drafts", + Trash: "[Gmail]/Trash", + Spam: "[Gmail]/Spam", + }}, + }, + } + + m := Model{ + cfg: cfg, + accounts: cfg.ActiveAccounts(), + accountI: 1, // Work + folders: []string{"Inbox", "Sent", "Trash"}, + activeFolderI: 1, // Sent tab + } + + if got := m.activeFolder(); got != "[Gmail]/Sent Mail" { + t.Errorf("Work account, Sent tab: activeFolder() = %q, want %q", got, "[Gmail]/Sent Mail") + } + + m.offTabFolder = "Drafts" + if got := m.activeFolder(); got != "[Gmail]/Drafts" { + t.Errorf("Work account, off-tab Drafts: activeFolder() = %q, want %q", got, "[Gmail]/Drafts") + } + + // Switch to Personal — no override, should use globals. + m.accountI = 0 + m.offTabFolder = "" + if got := m.activeFolder(); got != "Sent" { + t.Errorf("Personal account, Sent tab: activeFolder() = %q, want %q", got, "Sent") + } +} + func TestUpdateInboxEscClearsCommittedFilter(t *testing.T) { m := Model{ filterText: "invoice", From e1200fc75e9e539fb1bd62acec4b3540a12d8dd4 Mon Sep 17 00:00:00 2001 From: Jesus Rodriguez Date: Sun, 17 May 2026 01:27:31 +0200 Subject: [PATCH 07/17] Refreshed the inbox list delegate on account switch so per-account Sent/Drafts folder names take effect. Added emailDelegateForActiveAccount() that builds the row-rendering delegate from the active account's resolved folder names, and refreshInboxDelegate() that swaps it into the list. Wired the refresh into the ctrl+a handler. Without this, viewing Sent/Drafts on a non-default account showed the sender instead of the recipient because the delegate still held the initial account's folder names. Added a unit test pinning the delegate-construction logic. --- internal/ui/model.go | 18 ++++++++++++++++++ internal/ui/model_test.go | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/internal/ui/model.go b/internal/ui/model.go index 0eb2f8a..5f9b972 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -823,6 +823,23 @@ func (m Model) sentDraftsIMAPAccount() config.AccountConfig { return m.accounts[0] } +// emailDelegateForActiveAccount builds the inbox row-rendering delegate +// using the active account's resolved Sent/Drafts folder names. +func (m *Model) emailDelegateForActiveAccount() emailDelegate { + f := m.cfg.ResolveFolders(m.activeAccount()) + return emailDelegate{sentFolder: f.Sent, draftFolder: f.Drafts} +} + +// refreshInboxDelegate replaces the inbox list's display delegate so that +// the active account's Sent/Drafts folder names drive the "→ To:" row +// rendering. Call this after any change to m.accountI; otherwise the +// delegate keeps the folder names it was built with and rows from a +// different account's Sent/Drafts mailbox display the sender instead of +// the recipient. +func (m *Model) refreshInboxDelegate() { + m.inbox.SetDelegate(m.emailDelegateForActiveAccount()) +} + func (m *Model) applyEditedFrom(from string) { if idx := m.matchFromAddress(from); idx >= 0 { m.presendFromI = idx @@ -3080,6 +3097,7 @@ func (m Model) updateInbox(msg tea.KeyMsg) (tea.Model, tea.Cmd) { break } } + m.refreshInboxDelegate() m.activeFolderI = 0 m.loading = true return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.activeFolder())) diff --git a/internal/ui/model_test.go b/internal/ui/model_test.go index 3ece43f..60c7a4d 100644 --- a/internal/ui/model_test.go +++ b/internal/ui/model_test.go @@ -438,6 +438,42 @@ func TestActiveFolderHonorsPerAccountOverride(t *testing.T) { } } +func TestEmailDelegateForActiveAccount(t *testing.T) { + cfg := &config.Config{ + Folders: config.FoldersConfig{Sent: "Sent", Drafts: "Drafts"}, + Accounts: []config.AccountConfig{ + {Name: "Personal"}, + {Name: "Work", Folders: config.AccountFoldersConfig{ + Sent: "[Gmail]/Sent Mail", + Drafts: "[Gmail]/Drafts", + }}, + }, + } + m := Model{ + cfg: cfg, + accounts: cfg.ActiveAccounts(), + accountI: 1, // Work + } + + d := m.emailDelegateForActiveAccount() + if d.sentFolder != "[Gmail]/Sent Mail" { + t.Errorf("Work sentFolder = %q, want %q", d.sentFolder, "[Gmail]/Sent Mail") + } + if d.draftFolder != "[Gmail]/Drafts" { + t.Errorf("Work draftFolder = %q, want %q", d.draftFolder, "[Gmail]/Drafts") + } + + // Switch to Personal — no override, should fall back to globals. + m.accountI = 0 + d = m.emailDelegateForActiveAccount() + if d.sentFolder != "Sent" { + t.Errorf("Personal sentFolder = %q, want %q", d.sentFolder, "Sent") + } + if d.draftFolder != "Drafts" { + t.Errorf("Personal draftFolder = %q, want %q", d.draftFolder, "Drafts") + } +} + func TestUpdateInboxEscClearsCommittedFilter(t *testing.T) { m := Model{ filterText: "invoice", From 5bd8f2ec3828cf60724500f5b9abbcbc592385b8 Mon Sep 17 00:00:00 2001 From: Jesus Rodriguez Date: Sun, 17 May 2026 16:12:58 +0200 Subject: [PATCH 08/17] fix: Added Drafts case to activeFolder() tab-label, it was missing --- internal/ui/model.go | 2 ++ internal/ui/model_test.go | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/internal/ui/model.go b/internal/ui/model.go index 5f9b972..575a505 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -886,6 +886,8 @@ func (m Model) activeFolder() string { return f.PaperTrail case "Sent": return f.Sent + case "Drafts": + return f.Drafts case "Trash": return f.Trash case "Archive": diff --git a/internal/ui/model_test.go b/internal/ui/model_test.go index 60c7a4d..8facabc 100644 --- a/internal/ui/model_test.go +++ b/internal/ui/model_test.go @@ -396,6 +396,24 @@ func TestActiveFolderUsesOffTabFolder(t *testing.T) { } } +// Drafts can appear in tab_order (default order includes it), so the +// regular tab switch must resolve it — not just the off-tab path used by gd. +func TestActiveFolderResolvesDraftsTab(t *testing.T) { + m := Model{ + cfg: &config.Config{ + Folders: config.FoldersConfig{ + Inbox: "INBOX", + Drafts: "Drafts", + }, + }, + folders: []string{"Inbox", "Drafts"}, + activeFolderI: 1, // Drafts tab + } + if got := m.activeFolder(); got != "Drafts" { + t.Fatalf("activeFolder() on Drafts tab = %q, want %q", got, "Drafts") + } +} + func TestActiveFolderHonorsPerAccountOverride(t *testing.T) { cfg := &config.Config{ Folders: config.FoldersConfig{ From f318742ef1507ea319abce93d959f71906ec78f0 Mon Sep 17 00:00:00 2001 From: Jesus Rodriguez Date: Sat, 16 May 2026 23:32:19 +0200 Subject: [PATCH 09/17] Added SignatureConfig to the AccountConfig. Added a method to Config to resolve the Signature either from the account or the UI. Replaced the calls to the signature in model.go --- internal/config/config.go | 54 ++++++++++++++++--------------- internal/ui/model.go | 68 ++++++++++++++++++++++++--------------- 2 files changed, 70 insertions(+), 52 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 487de82..2d5961a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -46,6 +46,9 @@ 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 + + // Signature + Signature SignatureConfig `toml:"signature_block"` // Per account Signature } // IsOAuth2 reports whether this account uses OAuth2 instead of password auth. @@ -264,20 +267,6 @@ type UIConfig struct { MarkAsReadAfterSecs int `toml:"mark_as_read_after_secs"` // seconds in reader before marking as read (0 = immediate, default 7) } -// TextSignature returns the text/markdown signature for editor and text/plain part. -// Prefers signature_block.text, falls back to legacy signature field. -func (u UIConfig) TextSignature() string { - if u.SignatureBlock.Text != "" { - return u.SignatureBlock.Text - } - return u.Signature -} - -// HTMLSignature returns the HTML signature for text/html part, or empty if not configured. -func (u UIConfig) HTMLSignature() string { - return u.SignatureBlock.HTML -} - // DraftBackups returns the max number of rolling draft backups (default 20, -1 = disabled). func (u UIConfig) DraftBackups() int { if u.DraftBackupCount == 0 { @@ -402,6 +391,19 @@ func (c *Config) ActiveAccounts() []AccountConfig { return nil } +// Signature resolves what signature to use and returns a SignatureConfig. +// It's up to the caller to decide to use either HTML or Text. The function +// resolves first to dedicated account signature if defined, else the UI signature +func (c *Config) Signature(a AccountConfig) SignatureConfig { + if a.Signature.Text != "" || a.Signature.HTML != "" { + return a.Signature + } + if c.UI.SignatureBlock.Text != "" || c.UI.SignatureBlock.HTML != "" { + return c.UI.SignatureBlock + } + return SignatureConfig{Text: c.UI.Signature} +} + // DefaultPath returns ~/.config/neomd/config.toml. func DefaultPath() string { home, _ := os.UserHomeDir() @@ -418,7 +420,7 @@ var cacheDirName = "neomd" func HistoryPath() string { if dir, err := os.UserCacheDir(); err == nil { p := filepath.Join(dir, cacheDirName) - _ = os.MkdirAll(p, 0700) + _ = os.MkdirAll(p, 0o700) return filepath.Join(p, "cmd_history") } return filepath.Join(os.TempDir(), fmt.Sprintf("neomd_%d_cmd_history", os.Getuid())) @@ -428,11 +430,11 @@ func HistoryPath() string { func DraftsBackupDir() string { if dir, err := os.UserCacheDir(); err == nil { p := filepath.Join(dir, cacheDirName, "drafts") - _ = os.MkdirAll(p, 0700) + _ = os.MkdirAll(p, 0o700) return p } p := filepath.Join(os.TempDir(), fmt.Sprintf("neomd_%d_drafts", os.Getuid())) - _ = os.MkdirAll(p, 0700) + _ = os.MkdirAll(p, 0o700) return p } @@ -440,7 +442,7 @@ func DraftsBackupDir() string { func CrashLogPath() string { if dir, err := os.UserCacheDir(); err == nil { p := filepath.Join(dir, cacheDirName) - _ = os.MkdirAll(p, 0700) + _ = os.MkdirAll(p, 0o700) return filepath.Join(p, "crash.log") } return filepath.Join(os.TempDir(), fmt.Sprintf("neomd_%d_crash.log", os.Getuid())) @@ -450,7 +452,7 @@ func CrashLogPath() string { func SpyPixelCachePath() string { if dir, err := os.UserCacheDir(); err == nil { p := filepath.Join(dir, cacheDirName) - _ = os.MkdirAll(p, 0700) + _ = os.MkdirAll(p, 0o700) return filepath.Join(p, "spy_pixels") } return filepath.Join(os.TempDir(), fmt.Sprintf("neomd_%d_spy_pixels", os.Getuid())) @@ -461,7 +463,7 @@ func SpyPixelCachePath() string { func NotifyStatePath() string { if dir, err := os.UserCacheDir(); err == nil { p := filepath.Join(dir, cacheDirName) - _ = os.MkdirAll(p, 0700) + _ = os.MkdirAll(p, 0o700) return filepath.Join(p, "notify_state.json") } return filepath.Join(os.TempDir(), fmt.Sprintf("neomd_%d_notify_state.json", os.Getuid())) @@ -484,8 +486,8 @@ func IsFirstRun() bool { // MarkWelcomeShown creates the marker so IsFirstRun returns false next time. func MarkWelcomeShown() { p := welcomePath() - _ = os.MkdirAll(filepath.Dir(p), 0700) - _ = os.WriteFile(p, []byte("1"), 0600) + _ = os.MkdirAll(filepath.Dir(p), 0o700) + _ = os.WriteFile(p, []byte("1"), 0o600) } // Load reads config from path (or default location if path is empty). @@ -528,9 +530,9 @@ func Load(path string) (*Config, error) { cfg.Screener.Notify, } { if p != "" { - _ = os.MkdirAll(filepath.Dir(p), 0700) + _ = os.MkdirAll(filepath.Dir(p), 0o700) if _, err := os.Stat(p); os.IsNotExist(err) { - _ = os.WriteFile(p, nil, 0600) + _ = os.WriteFile(p, nil, 0o600) } } } @@ -691,10 +693,10 @@ func defaults() *Config { } func writeDefault(path string, cfg *Config) error { - if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return err } - f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0600) + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0o600) if err != nil { return err } diff --git a/internal/ui/model.go b/internal/ui/model.go index 56b7c49..db7b6e1 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -216,7 +216,7 @@ type MailtoParams struct { // a draft after a crash) and avoids cluttering /tmp/. func neomdTempDir() string { dir := filepath.Join(os.TempDir(), "neomd") - os.MkdirAll(dir, 0700) //nolint + os.MkdirAll(dir, 0o700) //nolint return dir } @@ -277,7 +277,7 @@ func backupDraft(tmpPath string, maxBackups int) { if err != nil || len(src) == 0 { return } - _ = os.WriteFile(dst, src, 0600) + _ = os.WriteFile(dst, src, 0o600) // Prune oldest if over limit. files := listBackupsByAge(dir) @@ -367,11 +367,19 @@ func (m Model) writeDebugReport() tea.Cmd { b.WriteString("\n## Folder Mapping\n\n") f := m.cfg.Folders folders := [][2]string{ - {"Inbox", f.Inbox}, {"Sent", f.Sent}, {"Trash", f.Trash}, - {"Drafts", f.Drafts}, {"ToScreen", f.ToScreen}, {"Feed", f.Feed}, - {"PaperTrail", f.PaperTrail}, {"ScreenedOut", f.ScreenedOut}, - {"Archive", f.Archive}, {"Waiting", f.Waiting}, - {"Scheduled", f.Scheduled}, {"Someday", f.Someday}, {"Spam", f.Spam}, + {"Inbox", f.Inbox}, + {"Sent", f.Sent}, + {"Trash", f.Trash}, + {"Drafts", f.Drafts}, + {"ToScreen", f.ToScreen}, + {"Feed", f.Feed}, + {"PaperTrail", f.PaperTrail}, + {"ScreenedOut", f.ScreenedOut}, + {"Archive", f.Archive}, + {"Waiting", f.Waiting}, + {"Scheduled", f.Scheduled}, + {"Someday", f.Someday}, + {"Spam", f.Spam}, {"Work", f.Work}, } for _, kv := range folders { @@ -390,8 +398,11 @@ func (m Model) writeDebugReport() tea.Cmd { b.WriteString("\n## Screener Lists\n\n") sc := m.cfg.Screener lists := [][2]string{ - {"screened_in", sc.ScreenedIn}, {"screened_out", sc.ScreenedOut}, - {"feed", sc.Feed}, {"papertrail", sc.PaperTrail}, {"spam", sc.Spam}, + {"screened_in", sc.ScreenedIn}, + {"screened_out", sc.ScreenedOut}, + {"feed", sc.Feed}, + {"papertrail", sc.PaperTrail}, + {"spam", sc.Spam}, {"notify", sc.Notify}, } for _, kv := range lists { @@ -450,7 +461,7 @@ func (m Model) writeDebugReport() tea.Cmd { // Write to file path := filepath.Join(neomdTempDir(), "debug.log") - if err := os.WriteFile(path, []byte(b.String()), 0600); err != nil { + if err := os.WriteFile(path, []byte(b.String()), 0o600); err != nil { return errMsg{fmt.Errorf("write debug report: %w", err)} } @@ -903,7 +914,7 @@ func (m Model) sendEmailCmd(smtpAcct config.AccountConfig, from, to, cc, bcc, su replyCli := m.imapCliForAccount(replyToAccount) htmlSignature := "" if includeHTMLSig { - htmlSignature = m.cfg.UI.HTMLSignature() + htmlSignature = m.cfg.Signature(smtpAcct).HTML } return func() tea.Msg { // Build raw MIME once — reused for both SMTP delivery and Sent copy. @@ -1591,8 +1602,10 @@ func (m Model) spyScanCmd() tea.Cmd { for i, uid := range uids { spy, err := cli.ScanSpyPixels(nil, folder, uid) if err != nil { - return spyScanProgressMsg{err: err, scanned: i, total: total, found: found, - spyKeys: spyFound, scannedKeys: allScanned} + return spyScanProgressMsg{ + err: err, scanned: i, total: total, found: found, + spyKeys: spyFound, scannedKeys: allScanned, + } } key := spyPixelKey(folder, uid) allScanned = append(allScanned, key) @@ -3219,7 +3232,7 @@ func loadCmdHistory(path string) []string { // Called in a goroutine — errors are silently ignored. func saveCmdHistory(path string, history []string) { content := strings.Join(history, "\n") + "\n" - _ = os.WriteFile(path, []byte(content), 0600) + _ = os.WriteFile(path, []byte(content), 0o600) } // loadSpyPixelCache reads the spy pixel cache from disk. @@ -3258,7 +3271,7 @@ func saveSpyPixelCache(spyKeys, scannedKeys map[string]bool) { lines = append(lines, "-"+k) } } - _ = os.WriteFile(config.SpyPixelCachePath(), []byte(strings.Join(lines, "\n")+"\n"), 0600) + _ = os.WriteFile(config.SpyPixelCachePath(), []byte(strings.Join(lines, "\n")+"\n"), 0o600) } // safeGo runs fn in a goroutine with panic recovery. If the goroutine panics, @@ -3279,7 +3292,7 @@ func safeGo(fn func()) { // writeCrashLog appends a panic record to the crash log file. func writeCrashLog(r interface{}, stack []byte) { path := config.CrashLogPath() - f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600) + f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) if err != nil { return } @@ -3837,7 +3850,7 @@ func (m Model) openInBrowser() (tea.Model, tea.Cmd) { continue } - if err := os.WriteFile(imgPath, a.Data, 0600); err != nil { + if err := os.WriteFile(imgPath, a.Data, 0o600); err != nil { continue } tmpImages = append(tmpImages, imgPath) @@ -3990,7 +4003,7 @@ func (m Model) downloadOpenAttachmentCmd(a imap.Attachment) tea.Cmd { return attachOpenDoneMsg{err: err} } dir := filepath.Join(home, "Downloads") - if err := os.MkdirAll(dir, 0755); err != nil { + if err := os.MkdirAll(dir, 0o755); err != nil { return attachOpenDoneMsg{err: fmt.Errorf("create Downloads: %w", err)} } // Avoid overwriting existing files by appending a counter before the extension. @@ -4011,7 +4024,7 @@ func (m Model) downloadOpenAttachmentCmd(a imap.Attachment) tea.Cmd { } } } - if err := os.WriteFile(dst, a.Data, 0644); err != nil { + if err := os.WriteFile(dst, a.Data, 0o644); err != nil { return attachOpenDoneMsg{err: fmt.Errorf("save attachment: %w", err)} } ext := strings.ToLower(filepath.Ext(base)) @@ -4054,7 +4067,7 @@ func (m Model) downloadEMLCmd() tea.Cmd { return emlDownloadedMsg{err: err} } dir := filepath.Join(home, "Downloads") - if err := os.MkdirAll(dir, 0755); err != nil { + if err := os.MkdirAll(dir, 0o755); err != nil { return emlDownloadedMsg{err: fmt.Errorf("create Downloads: %w", err)} } // Sanitize subject for filename @@ -4081,7 +4094,7 @@ func (m Model) downloadEMLCmd() tea.Cmd { } } } - if err := os.WriteFile(dst, raw, 0644); err != nil { + if err := os.WriteFile(dst, raw, 0o644); err != nil { return emlDownloadedMsg{err: fmt.Errorf("save EML: %w", err)} } return emlDownloadedMsg{path: dst} @@ -4237,7 +4250,7 @@ func (m Model) openICSCmd() tea.Cmd { return nil } dir := filepath.Join(home, ".cache", "neomd", "ical") - if err := os.MkdirAll(dir, 0700); err != nil { + if err := os.MkdirAll(dir, 0o700); err != nil { m.status = "open .ics: " + err.Error() m.isError = true return nil @@ -4252,7 +4265,7 @@ func (m Model) openICSCmd() tea.Cmd { name = fmt.Sprintf("invite-%d.ics", time.Now().Unix()) } dst := filepath.Join(dir, name) - if err := os.WriteFile(dst, att.Data, 0600); err != nil { + if err := os.WriteFile(dst, att.Data, 0o600); err != nil { m.status = "open .ics: " + err.Error() m.isError = true return nil @@ -4636,7 +4649,8 @@ func (m Model) launchSpellCheckCmd(ps *pendingSendData) (tea.Model, tea.Cmd) { // Open nvim with spell on and jump to first misspelled word. // VimEnter + defer_fn ensures spell activates AFTER all plugins load. - cmd := exec.Command("nvim", + cmd := exec.Command( + "nvim", "-c", `autocmd VimEnter * ++once lua vim.defer_fn(function() vim.wo.spell = true; vim.bo.spelllang = "en_us,de"; vim.cmd("normal! gg]s") end, 100)`, tmpPath, ) @@ -4781,7 +4795,8 @@ func (m Model) previewInBrowser() (tea.Model, tea.Cmd) { // Inject HTML signature before tag if enabled (matching send path) if includeHTMLSig { - htmlSig := m.cfg.UI.HTMLSignature() + acct := m.presendSMTPAccount() + htmlSig := m.cfg.Signature(acct).HTML if htmlSig != "" { idx := strings.LastIndex(htmlBody, "") if idx >= 0 { @@ -4845,7 +4860,8 @@ func (m Model) launchEditorCmd() (tea.Model, tea.Cmd) { // When a mailto body is present, insert it before the signature so // the signature always appears at the bottom of the composed message. - sig := m.cfg.UI.TextSignature() + acct := m.presendSMTPAccount() + sig := m.cfg.Signature(acct).Text var prelude string if mailtoBody != "" { // Build headers without signature, append body, then signature. From 8d70b20a6ca74f11dd02bc5a3cdf69dc3a189c68 Mon Sep 17 00:00:00 2001 From: Jesus Rodriguez Date: Sat, 16 May 2026 23:33:06 +0200 Subject: [PATCH 10/17] Added dedicated test for the signature blocks --- internal/config/config_test.go | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 94f7670..a45901c 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -179,6 +179,38 @@ func TestBulkThreshold(t *testing.T) { } } +func TestSignature(t *testing.T) { + accountWithBoth := AccountConfig{Signature: SignatureConfig{Text: "acct-text", HTML: "

acct-html

"}} + accountTextOnly := AccountConfig{Signature: SignatureConfig{Text: "acct-text"}} + accountHTMLOnly := AccountConfig{Signature: SignatureConfig{HTML: "

acct-html

"}} + accountEmpty := AccountConfig{} + + uiWithBlock := UIConfig{SignatureBlock: SignatureConfig{Text: "ui-text", HTML: "

ui-html

"}} + uiLegacyOnly := UIConfig{Signature: "legacy-sig"} + uiEmpty := UIConfig{} + + tests := []struct { + name string + cfg *Config + acct AccountConfig + want SignatureConfig + }{ + {"account block wins when populated", &Config{UI: uiWithBlock}, accountWithBoth, SignatureConfig{Text: "acct-text", HTML: "

acct-html

"}}, + {"account text-only does not leak ui html", &Config{UI: uiWithBlock}, accountTextOnly, SignatureConfig{Text: "acct-text"}}, + {"account html-only does not leak ui text", &Config{UI: uiWithBlock}, accountHTMLOnly, SignatureConfig{HTML: "

acct-html

"}}, + {"falls back to ui block when account empty", &Config{UI: uiWithBlock}, accountEmpty, SignatureConfig{Text: "ui-text", HTML: "

ui-html

"}}, + {"falls back to legacy ui.signature", &Config{UI: uiLegacyOnly}, accountEmpty, SignatureConfig{Text: "legacy-sig"}}, + {"all empty returns zero value", &Config{UI: uiEmpty}, accountEmpty, SignatureConfig{}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.cfg.Signature(tt.acct); got != tt.want { + t.Errorf("Signature() = %+v, want %+v", got, tt.want) + } + }) + } +} + func TestLabelFor(t *testing.T) { // Custom IMAP folder names (e.g. HEY-style labels). fc := FoldersConfig{ From 0aa52f43112e14c2676f9b634d9ac516aeb74f30 Mon Sep 17 00:00:00 2001 From: Jesus Rodriguez Date: Sun, 17 May 2026 00:47:13 +0200 Subject: [PATCH 11/17] Added AccountFoldersConfig to allow per-account overrides of Sent/Trash/Drafts/Spam. Added Config.ResolveFolders to merge the account block onto the global folders field-by-field. Added unit tests pinning the field-by-field semantics. --- internal/config/config.go | 32 ++++++++++++++ internal/config/config_test.go | 80 ++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+) diff --git a/internal/config/config.go b/internal/config/config.go index 2d5961a..e1902e5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -49,6 +49,9 @@ type AccountConfig struct { // Signature Signature SignatureConfig `toml:"signature_block"` // Per account Signature + + // Folders + Folders AccountFoldersConfig `toml:"folders"` // Per account folders } // IsOAuth2 reports whether this account uses OAuth2 instead of password auth. @@ -169,6 +172,17 @@ type FoldersConfig struct { TabOrder []string `toml:"tab_order"` } +// AccountFoldersConfig is the subset of folder names that may be overridden +// per account. The other folders in FoldersConfig (Inbox, ToScreen, Feed, +// PaperTrail, Archive, Waiting, Scheduled, Someday, Work, TabOrder) are +// global concepts and not configurable per account. +type AccountFoldersConfig struct { + Sent string `toml:"sent"` + Trash string `toml:"trash"` + Drafts string `toml:"drafts"` + Spam string `toml:"spam"` +} + // defaultTabOrder is the built-in tab order when tab_order is not configured. var defaultTabOrder = []string{"inbox", "to_screen", "feed", "papertrail", "waiting", "someday", "scheduled", "sent", "archive", "screened_out", "drafts", "trash"} @@ -404,6 +418,24 @@ func (c *Config) Signature(a AccountConfig) SignatureConfig { return SignatureConfig{Text: c.UI.Signature} } +// ResolveFolders returns the global folders with any per account override applied. +func (c *Config) ResolveFolders(a AccountConfig) FoldersConfig { + out := c.Folders + if a.Folders.Sent != "" { + out.Sent = a.Folders.Sent + } + if a.Folders.Trash != "" { + out.Trash = a.Folders.Trash + } + if a.Folders.Drafts != "" { + out.Drafts = a.Folders.Drafts + } + if a.Folders.Spam != "" { + out.Spam = a.Folders.Spam + } + return out +} + // DefaultPath returns ~/.config/neomd/config.toml. func DefaultPath() string { home, _ := os.UserHomeDir() diff --git a/internal/config/config_test.go b/internal/config/config_test.go index a45901c..4fb9179 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -3,6 +3,7 @@ package config import ( "os" "path/filepath" + "reflect" "strings" "testing" @@ -211,6 +212,85 @@ func TestSignature(t *testing.T) { } } +func TestResolveFolders(t *testing.T) { + globalFolders := FoldersConfig{ + Inbox: "INBOX", + Sent: "Sent", + Trash: "Trash", + Drafts: "Drafts", + ToScreen: "ToScreen", + Feed: "Feed", + PaperTrail: "PaperTrail", + ScreenedOut: "ScreenedOut", + Archive: "Archive", + Waiting: "Waiting", + Scheduled: "Scheduled", + Someday: "Someday", + Spam: "Spam", + Work: "Work", + TabOrder: []string{"inbox", "sent"}, + } + + tests := []struct { + name string + cfg *Config + acct AccountConfig + want FoldersConfig + }{ + { + name: "empty account folders inherit global as-is", + cfg: &Config{Folders: globalFolders}, + acct: AccountConfig{}, + want: globalFolders, + }, + { + name: "sparse override only changes that field", + cfg: &Config{Folders: globalFolders}, + acct: AccountConfig{Folders: AccountFoldersConfig{Sent: "[Gmail]/Sent Mail"}}, + want: func() FoldersConfig { + f := globalFolders + f.Sent = "[Gmail]/Sent Mail" + return f + }(), + }, + { + name: "full override of all four account-level fields, non-overridable stay global", + cfg: &Config{Folders: globalFolders}, + acct: AccountConfig{Folders: AccountFoldersConfig{ + Sent: "[Gmail]/Sent Mail", + Trash: "[Gmail]/Trash", + Drafts: "[Gmail]/Drafts", + Spam: "[Gmail]/Spam", + }}, + want: FoldersConfig{ + Inbox: "INBOX", + Sent: "[Gmail]/Sent Mail", + Trash: "[Gmail]/Trash", + Drafts: "[Gmail]/Drafts", + ToScreen: "ToScreen", + Feed: "Feed", + PaperTrail: "PaperTrail", + ScreenedOut: "ScreenedOut", + Archive: "Archive", + Waiting: "Waiting", + Scheduled: "Scheduled", + Someday: "Someday", + Spam: "[Gmail]/Spam", + Work: "Work", + TabOrder: []string{"inbox", "sent"}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.cfg.ResolveFolders(tt.acct) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("ResolveFolders() = %+v, want %+v", got, tt.want) + } + }) + } +} + func TestLabelFor(t *testing.T) { // Custom IMAP folder names (e.g. HEY-style labels). fc := FoldersConfig{ From bf1561cea1d461298992340fd35e8783a038ead0 Mon Sep 17 00:00:00 2001 From: Jesus Rodriguez Date: Sun, 17 May 2026 00:48:20 +0200 Subject: [PATCH 12/17] Routed Sent/Drafts IMAP saves through per-account folder resolution. Added sentDraftsIMAPAccount() as a single resolver for the account whose Sent/Drafts mailboxes receive a send or draft save, so the IMAP client and the folder name can never drift apart under per-account folder configs. Removed the now-unused sentDraftsIMAPClient and presendIMAPClient wrappers. --- internal/ui/model.go | 44 +++++++++++++++++++++++++-------------- internal/ui/model_test.go | 12 +++++------ 2 files changed, 34 insertions(+), 22 deletions(-) diff --git a/internal/ui/model.go b/internal/ui/model.go index db7b6e1..e249ce0 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -799,15 +799,23 @@ func (m Model) primaryIMAPClient() *imap.Client { return nil } -func (m Model) sentDraftsIMAPClient() *imap.Client { +// sentDraftsIMAPAccount returns the account whose Sent/Drafts IMAP folders +// should receive a sent message or saved draft. When +// StoreSentDraftsInSendingAccount is true the sending account is used; +// otherwise the first account with a live IMAP client (matching +// primaryIMAPClient's "first non-nil" logic). Use this so both the IMAP +// client and the resolved folder name come from the same account — +// preventing "no such mailbox" mismatches under per-account folder configs. +func (m Model) sentDraftsIMAPAccount() config.AccountConfig { if m.cfg != nil && m.cfg.StoreSentDraftsInSendingAccount { - return m.imapCliForAccount(m.presendSMTPAccount().Name) + return m.presendSMTPAccount() } - return m.primaryIMAPClient() -} - -func (m Model) presendIMAPClient() *imap.Client { - return m.sentDraftsIMAPClient() + for i, c := range m.clients { + if c != nil { + return m.accounts[i] + } + } + return m.accounts[0] } func (m *Model) applyEditedFrom(from string) { @@ -909,8 +917,9 @@ func (m Model) sendEmailCmd(smtpAcct config.AccountConfig, from, to, cc, bcc, su TLSCertFile: smtpAcct.TLSCertFile, TokenSource: m.tokenSourceFor(smtpAcct.Name), } - cli := m.sentDraftsIMAPClient() - sentFolder := m.cfg.Folders.Sent + sentAcct := m.sentDraftsIMAPAccount() + cli := m.imapCliForAccount(sentAcct.Name) + sentFolder := m.cfg.ResolveFolders(sentAcct).Sent replyCli := m.imapCliForAccount(replyToAccount) htmlSignature := "" if includeHTMLSig { @@ -1038,8 +1047,9 @@ func (m Model) sendReactionCmd(smtpAcct config.AccountConfig, from, to, subject, TLSCertFile: smtpAcct.TLSCertFile, TokenSource: m.tokenSourceFor(smtpAcct.Name), } - cli := m.sentDraftsIMAPClient() - sentFolder := m.cfg.Folders.Sent + sentAcct := m.sentDraftsIMAPAccount() + cli := m.imapCliForAccount(sentAcct.Name) + sentFolder := m.cfg.ResolveFolders(sentAcct).Sent replyCli := m.imapCli() return func() tea.Msg { @@ -4201,8 +4211,9 @@ func (m Model) sendRSVPCmd(status calendar.Status) tea.Cmd { } to := ev.Organizer - sentCli := m.sentDraftsIMAPClient() - sentFolder := m.cfg.Folders.Sent + sentAcct := m.sentDraftsIMAPAccount() + sentCli := m.imapCliForAccount(sentAcct.Name) + sentFolder := m.cfg.ResolveFolders(sentAcct).Sent openEmail := m.openEmail replyCli := m.imapCliForAccount(acct.Name) return func() tea.Msg { @@ -4601,7 +4612,7 @@ func (m Model) updatePresend(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil case "d": // Save to Drafts without sending. - return m, m.saveDraftCmd(m.presendIMAPClient(), m.presendFrom(), ps.to, ps.cc, ps.bcc, ps.subject, ps.body, m.attachments) + return m, m.saveDraftCmd(m.sentDraftsIMAPAccount(), m.presendFrom(), ps.to, ps.cc, ps.bcc, ps.subject, ps.body, m.attachments) case "ctrl+b": // Toggle CC/BCC fields — show input prompts to add/edit them. m.compose.extraVisible = !m.compose.extraVisible @@ -4837,8 +4848,9 @@ 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 { - folder := m.cfg.Folders.Drafts +func (m Model) saveDraftCmd(acct config.AccountConfig, from, to, cc, bcc, subject, body string, attachments []string) tea.Cmd { + imapCli := m.imapCliForAccount(acct.Name) + folder := m.cfg.ResolveFolders(acct).Drafts return func() tea.Msg { raw, err := smtp.BuildDraftMessage(from, to, cc, bcc, subject, body, attachments) if err != nil { diff --git a/internal/ui/model_test.go b/internal/ui/model_test.go index 03f38e9..596fa18 100644 --- a/internal/ui/model_test.go +++ b/internal/ui/model_test.go @@ -245,7 +245,7 @@ func TestReactionAutoSelectsCorrectFromAndSMTP(t *testing.T) { } } -func TestSentDraftsIMAPClient_DefaultsToPrimaryAccount(t *testing.T) { +func TestSentDraftsIMAPAccount_DefaultsToPrimaryAccount(t *testing.T) { cfg := &config.Config{ Accounts: []config.AccountConfig{ {Name: "Personal", From: "me@example.com"}, @@ -261,12 +261,12 @@ func TestSentDraftsIMAPClient_DefaultsToPrimaryAccount(t *testing.T) { presendFromI: 1, // sending as Work } - if got := m.sentDraftsIMAPClient(); got != personal { - t.Fatal("sentDraftsIMAPClient() should default to the primary IMAP account") + if got := m.sentDraftsIMAPAccount(); got.Name != "Personal" { + t.Fatalf("sentDraftsIMAPAccount().Name = %q, want %q", got.Name, "Personal") } } -func TestSentDraftsIMAPClient_FollowsSendingAccountWhenEnabled(t *testing.T) { +func TestSentDraftsIMAPAccount_FollowsSendingAccountWhenEnabled(t *testing.T) { cfg := &config.Config{ Accounts: []config.AccountConfig{ {Name: "Personal", From: "me@example.com"}, @@ -283,8 +283,8 @@ func TestSentDraftsIMAPClient_FollowsSendingAccountWhenEnabled(t *testing.T) { presendFromI: 1, // sending as Work } - if got := m.sentDraftsIMAPClient(); got != work { - t.Fatal("sentDraftsIMAPClient() should follow the selected sending account when enabled") + if got := m.sentDraftsIMAPAccount(); got.Name != "Work" { + t.Fatalf("sentDraftsIMAPAccount().Name = %q, want %q", got.Name, "Work") } } From d0905d980c8d95a4e00ae8076b9ea78500afc8a4 Mon Sep 17 00:00:00 2001 From: Jesus Rodriguez Date: Sun, 17 May 2026 01:04:17 +0200 Subject: [PATCH 13/17] Routed per-account folder operations through Config.ResolveFolders. Spam/Trash moves, screener classifications, batch delete, and folder-comparison checks now use the active account's folder names so per-account overrides take effect. Made activeAccount() safe against zero-account Models so the new per-account lookups don't panic in unit tests that hand-build minimal models. --- internal/ui/model.go | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/internal/ui/model.go b/internal/ui/model.go index e249ce0..ba67806 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -723,12 +723,17 @@ func (m Model) tokenSourceFor(accountName string) func() (string, error) { return nil } -// activeAccount returns the currently selected AccountConfig. +// activeAccount returns the currently selected AccountConfig, or the zero +// value when no accounts are configured (only reachable in unit tests that +// hand-build a Model; production config validation rejects empty accounts). func (m Model) activeAccount() config.AccountConfig { if m.accountI < len(m.accounts) { return m.accounts[m.accountI] } - return m.accounts[0] + if len(m.accounts) > 0 { + return m.accounts[0] + } + return config.AccountConfig{} } // presendFroms returns all available From addresses: all accounts first (in @@ -1290,7 +1295,7 @@ func (m Model) batchScreenerCmd(emails []imap.Email, action string) tea.Cmd { case "P": dst = cfg.Folders.PaperTrail case "$": - dst = cfg.Folders.Spam + dst = cfg.ResolveFolders(m.activeAccount()).Spam } ops = append(ops, op{e.From, e.Folder, e.UID, dst}) } @@ -1329,7 +1334,7 @@ func (m Model) batchScreenerCmd(emails []imap.Email, action string) tea.Cmd { case "P": dst = cfg.Folders.PaperTrail case "$": - dst = cfg.Folders.Spam + dst = cfg.ResolveFolders(m.activeAccount()).Spam } expandedOps = append(expandedOps, op{e.From, e.Folder, e.UID, dst}) } @@ -1690,7 +1695,7 @@ func (m Model) deleteAllSearchCmd() tea.Cmd { // emptyTrashSearchCmd is like deleteAllSearchCmd but always targets Trash. func (m Model) emptyTrashSearchCmd() tea.Cmd { - folder := m.cfg.Folders.Trash + folder := m.cfg.ResolveFolders(m.activeAccount()).Trash return func() tea.Msg { uids, err := m.imapCli().SearchUIDs(nil, folder) if err != nil { @@ -1887,7 +1892,7 @@ func (m Model) screenerCmd(e *imap.Email, action string) tea.Cmd { dst = m.cfg.Folders.PaperTrail case "$": addErr = m.screener.MarkSpam(e.From) - dst = m.cfg.Folders.Spam + dst = m.cfg.ResolveFolders(m.activeAccount()).Spam } if addErr != nil { return errMsg{addErr} @@ -2389,7 +2394,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var dst string switch cat { case screener.CategorySpam: - dst = m.cfg.Folders.Spam + dst = m.cfg.ResolveFolders(m.activeAccount()).Spam case screener.CategoryScreenedOut: dst = m.cfg.Folders.ScreenedOut case screener.CategoryFeed: @@ -2845,10 +2850,10 @@ func (m Model) updateInbox(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } m.loading = true m.bulkProgress = m.newBulkOp("Deleting", len(targets)) - return m, tea.Batch(m.spinner.Tick, m.batchMoveCmd(targets, m.cfg.Folders.Trash)) + return m, tea.Batch(m.spinner.Tick, m.batchMoveCmd(targets, m.cfg.ResolveFolders(m.activeAccount()).Trash)) case "X": // permanent delete (marked or cursor) — only in Trash - if m.activeFolder() != m.cfg.Folders.Trash { + if m.activeFolder() != m.cfg.ResolveFolders(m.activeAccount()).Trash { m.status = "X only works in Trash. Use x to move to Trash first." m.isError = true return m, nil @@ -2862,7 +2867,7 @@ func (m Model) updateInbox(msg tea.KeyMsg) (tea.Model, tea.Cmd) { uids = append(uids, e.UID) } m.loading = true - return m, tea.Batch(m.spinner.Tick, m.deleteAllExecCmd(m.cfg.Folders.Trash, uids)) + return m, tea.Batch(m.spinner.Tick, m.deleteAllExecCmd(m.cfg.ResolveFolders(m.activeAccount()).Trash, uids)) case "ctrl+u": // clear all marks m.markedUIDs = make(map[uint32]bool) @@ -3364,7 +3369,7 @@ func (m *Model) applyFilter() tea.Cmd { query := strings.ToLower(m.filterText) // In Sent folder, search To/CC/BCC instead of From — From is always us. var hay string - if len(m.folders) > 0 && m.activeFolder() == m.cfg.Folders.Sent { + if len(m.folders) > 0 && m.activeFolder() == m.cfg.ResolveFolders(m.activeAccount()).Sent { hay = strings.ToLower(e.To + " " + e.CC + " " + e.BCC + " " + e.Subject) } else { hay = strings.ToLower(e.From + " " + e.Subject) @@ -3382,7 +3387,7 @@ func (m *Model) applyFilter() tea.Cmd { filtered = m.emails } - noThread := len(m.folders) > 0 && m.activeFolder() == m.cfg.Folders.Sent + noThread := len(m.folders) > 0 && m.activeFolder() == m.cfg.ResolveFolders(m.activeAccount()).Sent return setEmails(&m.inbox, filtered, m.markedUIDs, m.spyPixelKeys, m.shouldPrefixFolderInSubject(), m.sortField, m.sortReverse, noThread) } @@ -5654,7 +5659,7 @@ func (m Model) viewReader() string { } else { b.WriteString(m.reader.View()) } - isDraft := m.openEmail != nil && m.openEmail.Folder == m.cfg.Folders.Drafts + isDraft := m.openEmail != nil && m.openEmail.Folder == m.cfg.ResolveFolders(m.activeAccount()).Drafts if m.status != "" { b.WriteString("\n" + statusBar(m.status, m.isError)) } else { From df76a27c6dd8bd9813cb4baa5778babe718b8e52 Mon Sep 17 00:00:00 2001 From: Jesus Rodriguez Date: Sun, 17 May 2026 01:20:27 +0200 Subject: [PATCH 14/17] =?UTF-8?q?Routed=20active-account=20UI=20sites=20th?= =?UTF-8?q?rough=20Config.ResolveFolders.=20activeFolder()=20now=20returns?= =?UTF-8?q?=20the=20per-account=20IMAP=20mailbox=20name=20for=20the=20acti?= =?UTF-8?q?ve=20account=20so=20the=20existing=20Sent/Trash/isDraft=20compa?= =?UTF-8?q?risons=20resolve=20correctly=20under=20per-account=20overrides.?= =?UTF-8?q?=20Migrated=20the=20M-chord=20bulk=20move=20map,=20gS/gd=20jump?= =?UTF-8?q?=20chords,=20the=20:go-spam=20cmdline=20command,=20and=20the=20?= =?UTF-8?q?newInboxList=20constructor's=20Sent/Drafts=20strings.=20Added?= =?UTF-8?q?=20a=20test=20pinning=20activeFolder()=20per-account=20override?= =?UTF-8?q?=20behavior.=20Inbox=20list=20delegate=20still=20bakes=20Sent/D?= =?UTF-8?q?rafts=20at=20construction=20time=20and=20is=20not=20refreshed?= =?UTF-8?q?=20on=20account=20switch=20=E2=80=94=20pending=20follow-up.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/ui/cmdline.go | 2 +- internal/ui/model.go | 68 +++++++++++++++++++++------------------ internal/ui/model_test.go | 42 ++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 33 deletions(-) diff --git a/internal/ui/cmdline.go b/internal/ui/cmdline.go index 85508ed..968ca75 100644 --- a/internal/ui/cmdline.go +++ b/internal/ui/cmdline.go @@ -171,7 +171,7 @@ func init() { run: func(m *Model) (tea.Model, tea.Cmd) { m.loading = true m.status = "Spam folder — press R to reload, tab to leave" - return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.cfg.Folders.Spam)) + return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.cfg.ResolveFolders(m.activeAccount()).Spam)) }, }, { diff --git a/internal/ui/model.go b/internal/ui/model.go index ba67806..239be56 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -850,41 +850,43 @@ func (m Model) Init() tea.Cmd { return tea.Batch(cmds...) } -// activeFolder maps the active tab label to an IMAP mailbox name. +// activeFolder maps the active tab label to an IMAP mailbox name, +// honouring per-account folder overrides for the active account. func (m Model) activeFolder() string { + f := m.cfg.ResolveFolders(m.activeAccount()) switch m.offTabFolder { case "Drafts": - return m.cfg.Folders.Drafts + return f.Drafts case "Spam": - return m.cfg.Folders.Spam + return f.Spam } switch m.folders[m.activeFolderI] { case "ToScreen": - return m.cfg.Folders.ToScreen + return f.ToScreen case "Feed": - return m.cfg.Folders.Feed + return f.Feed case "PaperTrail": - return m.cfg.Folders.PaperTrail + return f.PaperTrail case "Sent": - return m.cfg.Folders.Sent + return f.Sent case "Trash": - return m.cfg.Folders.Trash + return f.Trash case "Archive": - return m.cfg.Folders.Archive + return f.Archive case "Waiting": - return m.cfg.Folders.Waiting + return f.Waiting case "Scheduled": - return m.cfg.Folders.Scheduled + return f.Scheduled case "Someday": - return m.cfg.Folders.Someday + return f.Someday case "ScreenedOut": - return m.cfg.Folders.ScreenedOut + return f.ScreenedOut case "Spam": - return m.cfg.Folders.Spam + return f.Spam case "Work": - return m.cfg.Folders.Work + return f.Work default: - return m.cfg.Folders.Inbox + return f.Inbox } } @@ -1943,7 +1945,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { listH = 5 } if m.inbox.Width() == 0 { - m.inbox = newInboxList(msg.Width, listH, m.cfg.Folders.Sent, m.cfg.Folders.Drafts) + f := m.cfg.ResolveFolders(m.activeAccount()) + m.inbox = newInboxList(msg.Width, listH, f.Sent, f.Drafts) } else { m.inbox.SetSize(msg.Width, listH) } @@ -3436,14 +3439,14 @@ func (m Model) handleChord(prefix, key string) (tea.Model, tea.Cmd) { m.offTabFolder = "Spam" m.imapSearchText = "" m.status = "Spam folder — press R to reload, tab to leave" - return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.cfg.Folders.Spam)) + return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.cfg.ResolveFolders(m.activeAccount()).Spam)) } if key == "d" { // gd — go to Drafts (not in tab rotation) m.loading = true m.offTabFolder = "Drafts" m.imapSearchText = "" m.status = "Drafts folder — press R to reload, tab to leave" - return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.cfg.Folders.Drafts)) + return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.cfg.ResolveFolders(m.activeAccount()).Drafts)) } if key == "e" { // ge — Everything: latest emails across all folders m.loading = true @@ -3484,22 +3487,23 @@ func (m Model) handleChord(prefix, key string) (tea.Model, tea.Cmd) { if len(targets) == 0 { return m, nil } + f := m.cfg.ResolveFolders(m.activeAccount()) dstMap := map[string]string{ - "i": m.cfg.Folders.Inbox, - "a": m.cfg.Folders.Archive, - "f": m.cfg.Folders.Feed, - "p": m.cfg.Folders.PaperTrail, - "t": m.cfg.Folders.Trash, - "s": m.cfg.Folders.Sent, - "o": m.cfg.Folders.ScreenedOut, - "w": m.cfg.Folders.Waiting, - "c": m.cfg.Folders.Scheduled, - "m": m.cfg.Folders.Someday, - "k": m.cfg.Folders.ToScreen, + "i": f.Inbox, + "a": f.Archive, + "f": f.Feed, + "p": f.PaperTrail, + "t": f.Trash, + "s": f.Sent, + "o": f.ScreenedOut, + "w": f.Waiting, + "c": f.Scheduled, + "m": f.Someday, + "k": f.ToScreen, } // Only add Work folder if configured - if m.cfg.Folders.Work != "" { - dstMap["b"] = m.cfg.Folders.Work + if f.Work != "" { + dstMap["b"] = f.Work } if dst, ok := dstMap[key]; ok { m.loading = true diff --git a/internal/ui/model_test.go b/internal/ui/model_test.go index 596fa18..62d92bc 100644 --- a/internal/ui/model_test.go +++ b/internal/ui/model_test.go @@ -397,6 +397,48 @@ func TestActiveFolderUsesOffTabFolder(t *testing.T) { } } +func TestActiveFolderHonorsPerAccountOverride(t *testing.T) { + cfg := &config.Config{ + Folders: config.FoldersConfig{ + Inbox: "INBOX", Sent: "Sent", Trash: "Trash", + Drafts: "Drafts", Spam: "Spam", + }, + Accounts: []config.AccountConfig{ + {Name: "Personal"}, // no override → globals + {Name: "Work", Folders: config.AccountFoldersConfig{ + Sent: "[Gmail]/Sent Mail", + Drafts: "[Gmail]/Drafts", + Trash: "[Gmail]/Trash", + Spam: "[Gmail]/Spam", + }}, + }, + } + + m := Model{ + cfg: cfg, + accounts: cfg.ActiveAccounts(), + accountI: 1, // Work + folders: []string{"Inbox", "Sent", "Trash"}, + activeFolderI: 1, // Sent tab + } + + if got := m.activeFolder(); got != "[Gmail]/Sent Mail" { + t.Errorf("Work account, Sent tab: activeFolder() = %q, want %q", got, "[Gmail]/Sent Mail") + } + + m.offTabFolder = "Drafts" + if got := m.activeFolder(); got != "[Gmail]/Drafts" { + t.Errorf("Work account, off-tab Drafts: activeFolder() = %q, want %q", got, "[Gmail]/Drafts") + } + + // Switch to Personal — no override, should use globals. + m.accountI = 0 + m.offTabFolder = "" + if got := m.activeFolder(); got != "Sent" { + t.Errorf("Personal account, Sent tab: activeFolder() = %q, want %q", got, "Sent") + } +} + func TestUpdateInboxEscClearsCommittedFilter(t *testing.T) { m := Model{ filterText: "invoice", From 44da36cf6ac69b766a6423a44a8dd61022e610d5 Mon Sep 17 00:00:00 2001 From: Jesus Rodriguez Date: Sun, 17 May 2026 01:27:31 +0200 Subject: [PATCH 15/17] Refreshed the inbox list delegate on account switch so per-account Sent/Drafts folder names take effect. Added emailDelegateForActiveAccount() that builds the row-rendering delegate from the active account's resolved folder names, and refreshInboxDelegate() that swaps it into the list. Wired the refresh into the ctrl+a handler. Without this, viewing Sent/Drafts on a non-default account showed the sender instead of the recipient because the delegate still held the initial account's folder names. Added a unit test pinning the delegate-construction logic. --- internal/ui/model.go | 18 ++++++++++++++++++ internal/ui/model_test.go | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/internal/ui/model.go b/internal/ui/model.go index 239be56..55a4244 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -823,6 +823,23 @@ func (m Model) sentDraftsIMAPAccount() config.AccountConfig { return m.accounts[0] } +// emailDelegateForActiveAccount builds the inbox row-rendering delegate +// using the active account's resolved Sent/Drafts folder names. +func (m *Model) emailDelegateForActiveAccount() emailDelegate { + f := m.cfg.ResolveFolders(m.activeAccount()) + return emailDelegate{sentFolder: f.Sent, draftFolder: f.Drafts} +} + +// refreshInboxDelegate replaces the inbox list's display delegate so that +// the active account's Sent/Drafts folder names drive the "→ To:" row +// rendering. Call this after any change to m.accountI; otherwise the +// delegate keeps the folder names it was built with and rows from a +// different account's Sent/Drafts mailbox display the sender instead of +// the recipient. +func (m *Model) refreshInboxDelegate() { + m.inbox.SetDelegate(m.emailDelegateForActiveAccount()) +} + func (m *Model) applyEditedFrom(from string) { if idx := m.matchFromAddress(from); idx >= 0 { m.presendFromI = idx @@ -3108,6 +3125,7 @@ func (m Model) updateInbox(msg tea.KeyMsg) (tea.Model, tea.Cmd) { break } } + m.refreshInboxDelegate() m.activeFolderI = 0 m.loading = true return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.activeFolder())) diff --git a/internal/ui/model_test.go b/internal/ui/model_test.go index 62d92bc..4c36139 100644 --- a/internal/ui/model_test.go +++ b/internal/ui/model_test.go @@ -439,6 +439,42 @@ func TestActiveFolderHonorsPerAccountOverride(t *testing.T) { } } +func TestEmailDelegateForActiveAccount(t *testing.T) { + cfg := &config.Config{ + Folders: config.FoldersConfig{Sent: "Sent", Drafts: "Drafts"}, + Accounts: []config.AccountConfig{ + {Name: "Personal"}, + {Name: "Work", Folders: config.AccountFoldersConfig{ + Sent: "[Gmail]/Sent Mail", + Drafts: "[Gmail]/Drafts", + }}, + }, + } + m := Model{ + cfg: cfg, + accounts: cfg.ActiveAccounts(), + accountI: 1, // Work + } + + d := m.emailDelegateForActiveAccount() + if d.sentFolder != "[Gmail]/Sent Mail" { + t.Errorf("Work sentFolder = %q, want %q", d.sentFolder, "[Gmail]/Sent Mail") + } + if d.draftFolder != "[Gmail]/Drafts" { + t.Errorf("Work draftFolder = %q, want %q", d.draftFolder, "[Gmail]/Drafts") + } + + // Switch to Personal — no override, should fall back to globals. + m.accountI = 0 + d = m.emailDelegateForActiveAccount() + if d.sentFolder != "Sent" { + t.Errorf("Personal sentFolder = %q, want %q", d.sentFolder, "Sent") + } + if d.draftFolder != "Drafts" { + t.Errorf("Personal draftFolder = %q, want %q", d.draftFolder, "Drafts") + } +} + func TestUpdateInboxEscClearsCommittedFilter(t *testing.T) { m := Model{ filterText: "invoice", From a79add1f17e08c95a1aa1ef744bb8005ab471166 Mon Sep 17 00:00:00 2001 From: Jesus Rodriguez Date: Sun, 17 May 2026 16:12:58 +0200 Subject: [PATCH 16/17] fix: Added Drafts case to activeFolder() tab-label, it was missing --- internal/ui/model.go | 2 ++ internal/ui/model_test.go | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/internal/ui/model.go b/internal/ui/model.go index 55a4244..6a349fb 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -886,6 +886,8 @@ func (m Model) activeFolder() string { return f.PaperTrail case "Sent": return f.Sent + case "Drafts": + return f.Drafts case "Trash": return f.Trash case "Archive": diff --git a/internal/ui/model_test.go b/internal/ui/model_test.go index 4c36139..64f654f 100644 --- a/internal/ui/model_test.go +++ b/internal/ui/model_test.go @@ -397,6 +397,24 @@ func TestActiveFolderUsesOffTabFolder(t *testing.T) { } } +// Drafts can appear in tab_order (default order includes it), so the +// regular tab switch must resolve it — not just the off-tab path used by gd. +func TestActiveFolderResolvesDraftsTab(t *testing.T) { + m := Model{ + cfg: &config.Config{ + Folders: config.FoldersConfig{ + Inbox: "INBOX", + Drafts: "Drafts", + }, + }, + folders: []string{"Inbox", "Drafts"}, + activeFolderI: 1, // Drafts tab + } + if got := m.activeFolder(); got != "Drafts" { + t.Fatalf("activeFolder() on Drafts tab = %q, want %q", got, "Drafts") + } +} + func TestActiveFolderHonorsPerAccountOverride(t *testing.T) { cfg := &config.Config{ Folders: config.FoldersConfig{ From 62bab6da2150c6c80eead1f2a06f26aa9effe8bd Mon Sep 17 00:00:00 2001 From: Jesus Rodriguez Date: Sat, 30 May 2026 00:35:26 +0200 Subject: [PATCH 17/17] Added documentation for the account signatures and folders Adding configuration details in the documentation about how to create per account folders and signatures. --- docs/content/docs/configuration/_index.md | 64 +++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/docs/content/docs/configuration/_index.md b/docs/content/docs/configuration/_index.md index b6e2e7a..510e50a 100644 --- a/docs/content/docs/configuration/_index.md +++ b/docs/content/docs/configuration/_index.md @@ -322,6 +322,70 @@ BR Simon - The `text` field is backward compatible: if empty, neomd falls back to the legacy `signature` field - The `--` separator is added automatically before the text signature +## Account signatures + +Dedicated account signatures are supported. To configure a signature only associated to certain account, create a `[accounts.signature_block]` under the account configuration. If an account signature is not created, NeoMD will default to the `[ui.signature_block]` + +```toml +[[accounts]] +name = "Personal" +# ... +[accounts.signature_block] +text = "*Personal signature*" +``` + +Both text and HTML signatures are supported. + +## Account folders + +Some IMAP providers have different names for the some of the folders used by neomd. Because these folder names might conflict between providers, dedicated folders per account are supported for the following: +- Sent +- Drafts +- Spam +- Trash + +For example, Gmail use a label in front of these, like `[Gmail]Sent` while Microsoft uses `Sent Items` for Office365 accounts. + +The folder configuration per account override the default folder names for the account specified. Below is an example how to configure this: + +```toml +[[accounts]] + name = "Personal" + auth_type = "plain" + imap = "smtp.gmail.com:993" + smtp = "smtp.gmail.com:587" + user = "example@gmail.com" + # ... +[accounts.folders] + sent = "[Gmail]/Sent" + trash = "[Gmail]/Trash" + drafts = "[Gmail]/Drafts" + spam = "[Gmail]/Spam" + +[[accounts]] + name = "Work" + auth_type = "oauth2" + imap = "outlook.office365.com:993" + smtp = "smtp.office365.com:587" + #... +[accounts.folders] + sent = "Sent Items" + +[folders] + inbox = "INBOX" + sent = "Sent" + trash = "Trash" + drafts = "Drafts" + to_screen = "ToScreen" + feed = "Feed" + papertrail = "PaperTrail" + screened_out = "ScreenedOut" + archive = "Archive" + waiting = "Waiting" + scheduled = "Scheduled" + someday = "Someday" + spam = "Spam" +``` ## Theming