Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
f227c58
Added SignatureConfig to the AccountConfig. Added a method to Config …
notthatjesus May 16, 2026
11c8c1f
Added dedicated test for the signature blocks
notthatjesus May 16, 2026
d744fd5
Added AccountFoldersConfig to allow per-account overrides of Sent/Tra…
notthatjesus May 16, 2026
f41bcc7
Routed Sent/Drafts IMAP saves through per-account folder resolution. …
notthatjesus May 16, 2026
f4faef3
Routed per-account folder operations through Config.ResolveFolders. S…
notthatjesus May 16, 2026
02e5600
Routed active-account UI sites through Config.ResolveFolders. activeF…
notthatjesus May 16, 2026
e1200fc
Refreshed the inbox list delegate on account switch so per-account Se…
notthatjesus May 16, 2026
5bd8f2e
fix: Added Drafts case to activeFolder() tab-label, it was missing
notthatjesus May 17, 2026
f318742
Added SignatureConfig to the AccountConfig. Added a method to Config …
notthatjesus May 16, 2026
8d70b20
Added dedicated test for the signature blocks
notthatjesus May 16, 2026
0aa52f4
Added AccountFoldersConfig to allow per-account overrides of Sent/Tra…
notthatjesus May 16, 2026
bf1561c
Routed Sent/Drafts IMAP saves through per-account folder resolution. …
notthatjesus May 16, 2026
d0905d9
Routed per-account folder operations through Config.ResolveFolders. S…
notthatjesus May 16, 2026
df76a27
Routed active-account UI sites through Config.ResolveFolders. activeF…
notthatjesus May 16, 2026
44da36c
Refreshed the inbox list delegate on account switch so per-account Se…
notthatjesus May 16, 2026
a79add1
fix: Added Drafts case to activeFolder() tab-label, it was missing
notthatjesus May 17, 2026
62bab6d
Added documentation for the account signatures and folders
notthatjesus May 29, 2026
776a82e
Merge branch 'feature/signatures-and-folders-per-account' of https://…
notthatjesus May 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions docs/content/docs/configuration/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
86 changes: 60 additions & 26 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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"}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
Expand All @@ -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()))
Expand All @@ -428,19 +462,19 @@ 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
}

// CrashLogPath returns the path for the crash log file.
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()))
Expand All @@ -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()))
Expand All @@ -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()))
Expand All @@ -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).
Expand Down Expand Up @@ -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)
}
}
}
Expand Down Expand Up @@ -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
}
Expand Down
112 changes: 112 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package config
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"

Expand Down Expand Up @@ -179,6 +180,117 @@ func TestBulkThreshold(t *testing.T) {
}
}

func TestSignature(t *testing.T) {
accountWithBoth := AccountConfig{Signature: SignatureConfig{Text: "acct-text", HTML: "<p>acct-html</p>"}}
accountTextOnly := AccountConfig{Signature: SignatureConfig{Text: "acct-text"}}
accountHTMLOnly := AccountConfig{Signature: SignatureConfig{HTML: "<p>acct-html</p>"}}
accountEmpty := AccountConfig{}

uiWithBlock := UIConfig{SignatureBlock: SignatureConfig{Text: "ui-text", HTML: "<p>ui-html</p>"}}
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: "<p>acct-html</p>"}},
{"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: "<p>acct-html</p>"}},
{"falls back to ui block when account empty", &Config{UI: uiWithBlock}, accountEmpty, SignatureConfig{Text: "ui-text", HTML: "<p>ui-html</p>"}},
{"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{
Expand Down
2 changes: 1 addition & 1 deletion internal/ui/cmdline.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
},
},
{
Expand Down
Loading
Loading