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 diff --git a/internal/config/config.go b/internal/config/config.go index 487de82..e1902e5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -46,6 +46,12 @@ 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 + + // Folders + Folders AccountFoldersConfig `toml:"folders"` // Per account folders } // IsOAuth2 reports whether this account uses OAuth2 instead of password auth. @@ -166,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"} @@ -264,20 +281,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 +405,37 @@ 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} +} + +// 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() @@ -418,7 +452,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 +462,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 +474,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 +484,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 +495,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 +518,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 +562,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 +725,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/config/config_test.go b/internal/config/config_test.go index 94f7670..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" @@ -179,6 +180,117 @@ 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 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{ 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 56b7c49..6a349fb 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)} } @@ -712,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 @@ -788,15 +804,40 @@ 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() + for i, c := range m.clients { + if c != nil { + return m.accounts[i] + } + } + 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} } -func (m Model) presendIMAPClient() *imap.Client { - return m.sentDraftsIMAPClient() +// 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) { @@ -826,41 +867,45 @@ 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 "Drafts": + return f.Drafts 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 } } @@ -898,12 +943,13 @@ 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 { - 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. @@ -1027,8 +1073,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 { @@ -1269,7 +1316,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}) } @@ -1308,7 +1355,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}) } @@ -1591,8 +1638,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) @@ -1667,7 +1716,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 { @@ -1864,7 +1913,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} @@ -1915,7 +1964,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) } @@ -2366,7 +2416,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: @@ -2822,10 +2872,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 @@ -2839,7 +2889,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) @@ -3077,6 +3127,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())) @@ -3219,7 +3270,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 +3309,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 +3330,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 } @@ -3341,7 +3392,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) @@ -3359,7 +3410,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) } @@ -3408,14 +3459,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 +3507,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 @@ -3837,7 +3889,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 +4042,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 +4063,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 +4106,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 +4133,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} @@ -4188,8 +4240,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 { @@ -4237,7 +4290,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 +4305,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 @@ -4588,7 +4641,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 @@ -4636,7 +4689,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 +4835,8 @@ func (m Model) previewInBrowser() (tea.Model, tea.Cmd) { // Inject HTML signature before