Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ Folder operations prefer RFC 6851 MOVE; `u` undo uses UIDPLUS destination UIDs c

**Config** (`internal/config/`) — TOML at `~/.config/neomd/config.toml`, auto-created with placeholders. Supports multiple `[[accounts]]` and SMTP-only `[[senders]]` aliases (cycled with `ctrl+f` in compose/pre-send). OAuth2 authentication supported via `oauth2_client_id`, `oauth2_client_secret`, `oauth2_issuer_url`, `oauth2_scopes` fields. `-config PATH` flag overrides location.

**Keyring credentials** (`internal/keyring/`) — accounts may set `password = "keyring"` to fetch the password from the OS keyring (zalando/go-keyring; macOS Keychain, Secret Service on Linux, Credential Manager on Windows). Resolution happens in `config.Load()` so all consumers (IMAP at boot, SMTP at send, `[[senders]]` aliases) see the resolved value. If the keyring entry is missing or the service is unavailable, the `"keyring"` sentinel is preserved and a warning is logged. Service name: `neomd`; key format: `account/<name>/password`.

**Documentation** — Hugo site in `docs/` served at https://neomd.ssp.sh/. README.md is synced to `docs/content/overview.md` via `scripts/sync-readme-to-docs.sh`. Keybindings are auto-generated from `internal/ui/keys.go` via `cmd/docs/main.go` — never hand-edit the markdown tables.

**Package structure:**
Expand Down
1 change: 1 addition & 0 deletions cmd/neomd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ func main() {
Scopes: acc.OAuth2Scopes,
RedirectPort: acc.OAuth2RedirectPort,
TokenFile: tokenFile,
AccountName: acc.Name, // enables keyring storage; TokenFile remains as headless fallback
})
if err != nil {
fmt.Fprintf(os.Stderr, "neomd: account %q: oauth2: %v\n", acc.Name, err)
Expand Down
54 changes: 53 additions & 1 deletion docs/content/docs/configuration/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ sidebar:

On first run, neomd creates `~/.config/neomd/config.toml` with placeholders.


## Full example

```toml
Expand Down Expand Up @@ -97,7 +98,12 @@ Use an app-specific password (Gmail, Fastmail, Hostpoint, etc.) rather than your

`inbox_count` is a fetch cap for normal folder loads and startup auto-screening. If you want to re-screen the entire Inbox on the IMAP server, use `:screen-all` from inside neomd; that scans every Inbox email, not just the loaded subset, and can take a while on large mailboxes.

## Environment Variables


## Passwords: Env and Keyring


### Environment Variables

The `password` and `user` fields support environment variable expansion. If the entire value is a single env var reference, neomd resolves it at startup:

Expand All @@ -110,6 +116,52 @@ Values containing other text or multiple `$` signs are left as-is, so passwords

Credentials are stored only in `~/.config/neomd/config.toml` (mode 0600) and never written elsewhere; all IMAP connections use TLS (port 993) or STARTTLS (port 143).

### Storing passwords in the OS keyring (Linux)

Set `password = "keyring"` to fetch the password from your OS keyring (macOS Keychain, GNOME Keyring / KDE Wallet via Secret Service on Linux, Windows Credential Manager) at startup. The lookup uses the `[[accounts]].name` as the account identifier under service `neomd`.

```toml
[[accounts]]
name = "Personal"
password = "keyring" # resolved at startup; see below for setup
# ...rest of the account
```

**Setup before first launch (Linux, using `secret-tool` from `libsecret`):**

`zalando/go-keyring` writes Secret Service entries with two attributes — `service` (always `neomd`) and `username` (`account/<name>/password`, where `<name>` is the `[[accounts]].name` from your config). The `--label` is display-only; entries are identified by their **attributes**.

```sh
# Add the entry (you'll be prompted for the password on stdin):
secret-tool store --label "neomd Personal" service neomd username account/Personal/password

# Verify it was stored (read-only):
secret-tool lookup service neomd username account/Personal/password

# Audit every neomd entry currently in your keyring:
secret-tool search --all service neomd

# Remove a specific entry if you want to start over:
secret-tool clear service neomd username account/Personal/password
```

> [!IMPORTANT]
> **Multiple accounts → multiple distinct `username` values.** Same `service+username` overwrites, regardless of label. For three accounts named `Personal`, `Work`, `WorkInfo` in your config, run **three** commands with **three different** `username=account/<name>/password` values — labels are not enough.

```sh
secret-tool store --label "neomd Personal" service neomd username account/Personal/password
secret-tool store --label "neomd Work" service neomd username account/Work/password
secret-tool store --label "neomd WorkInfo" service neomd username account/WorkInfo/password
```

> **Recommended: avoid spaces in `name = "..."`** (use `WorkInfo` rather than `"Work Info"`). Easier to type in `secret-tool` commands without quoting; the keyring username is just `account/WorkInfo/password`. If you do use a space, quote the whole username arg every time: `username "account/Work Info/password"`.

`secret-tool` only touches entries that match the **exact** attribute set you pass. It cannot read, modify, or delete other applications' keyring entries — your Firefox / GNOME Online Accounts / SSH Agent / browser passwords stay isolated under their own `service=` namespaces. Worst case if you typo: a stale entry sits unused, removable with `secret-tool clear` above.

If the keyring entry is missing or the keyring service is unavailable, neomd prints a warning and the literal sentinel `"keyring"` is used as the password — IMAP/SMTP authentication will then fail with a clear error. `[[senders]]` aliases that reference an account inherit the resolved keyring password automatically.

OAuth2 tokens are also stored in the keyring under `username = account/<name>/oauth2` with the same `service = neomd`, falling back to `~/.config/neomd/tokens/<account>.json` (mode `0600`) when no keyring is available (headless / SSH systems).

## TLS and STARTTLS Configuration

Neomd automatically determines the correct encryption method based on the port and the optional `starttls` config field:
Expand Down
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ require (
github.com/fsnotify/fsnotify v1.9.0
github.com/sspaeti/goldmark-obsidian-callout-for-neomd v0.1.1
github.com/yuin/goldmark v1.7.8
github.com/zalando/go-keyring v0.2.8
golang.org/x/oauth2 v0.35.0
)

Expand All @@ -32,8 +33,10 @@ require (
github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/danieljoos/wincred v1.2.3 // indirect
github.com/dlclark/regexp2 v1.11.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
Expand Down
13 changes: 13 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,10 @@ github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfa
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
Expand All @@ -58,6 +61,8 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
Expand Down Expand Up @@ -103,8 +108,12 @@ github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NF
github.com/sspaeti/goldmark-obsidian-callout-for-neomd v0.1.1 h1:obWsWMJZGW4w9Qj1/WKLDweR+olq23r24UPS86XTbQU=
github.com/sspaeti/goldmark-obsidian-callout-for-neomd v0.1.1/go.mod h1:p/gHio8liSHKd6Zcig2LFSU8Ym1yV9L+Dt1eYP9F6+I=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
Expand All @@ -113,6 +122,8 @@ github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
Expand Down Expand Up @@ -182,3 +193,5 @@ gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
40 changes: 40 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"strings"

"github.com/BurntSushi/toml"
"github.com/sspaeti/neomd/internal/keyring"
)

// SenderConfig holds a named "From" alias used only for sending.
Expand Down Expand Up @@ -52,6 +53,19 @@ func (a AccountConfig) IsOAuth2() bool {
return strings.EqualFold(a.AuthType, "oauth2")
}

// keyringSentinel is the literal value users put in `password = "keyring"`
// to signal "fetch from OS keyring at startup."
const keyringSentinel = "keyring"

// UseKeyring reports whether this account stores its password in the OS
// keyring. After config.Load() resolves the keyring entry, Password holds
// the actual password and this method returns false. The sentinel only
// remains if keyring lookup failed (no entry yet, or service unavailable),
// in which case downstream code can prompt the user via :set-password.
func (a AccountConfig) UseKeyring() bool {
return a.Password == keyringSentinel
}

// ScreenerConfig points to the allowlist/blocklist files.
type ScreenerConfig struct {
ScreenedIn string `toml:"screened_in"`
Expand Down Expand Up @@ -462,10 +476,12 @@ func Load(path string) (*Config, error) {
cfg.Accounts[i].Password = expandEnv(cfg.Accounts[i].Password)
cfg.Accounts[i].User = expandEnv(cfg.Accounts[i].User)
cfg.Accounts[i].TLSCertFile = expandPath(expandEnv(cfg.Accounts[i].TLSCertFile))
cfg.Accounts[i].Password = resolveKeyringPassword(cfg.Accounts[i].Name, cfg.Accounts[i].Password)
}
cfg.Account.Password = expandEnv(cfg.Account.Password)
cfg.Account.User = expandEnv(cfg.Account.User)
cfg.Account.TLSCertFile = expandPath(expandEnv(cfg.Account.TLSCertFile))
cfg.Account.Password = resolveKeyringPassword(cfg.Account.Name, cfg.Account.Password)

cfg.Listmonk.APIToken = expandEnv(cfg.Listmonk.APIToken)

Expand Down Expand Up @@ -607,6 +623,30 @@ func writeDefault(path string, cfg *Config) error {
return toml.NewEncoder(f).Encode(cfg)
}

// resolveKeyringPassword turns the "keyring" sentinel into the actual password
// stored in the OS keyring under the given account name. Anything else is
// returned unchanged. If the keyring lookup fails (no entry yet, or service
// unavailable) the sentinel is preserved so downstream code can detect it and
// prompt the user; a one-line warning is written to stderr.
//
// This step runs in Load() so every consumer (IMAP at boot, SMTP at send,
// senders aliasing this account) sees the resolved value.
func resolveKeyringPassword(accountName, password string) string {
if password != keyringSentinel || accountName == "" {
return password
}
resolved, err := keyring.GetPassword(accountName)
if err == nil {
return resolved
}
if err == keyring.ErrNotFound {
fmt.Fprintf(os.Stderr, "neomd: account %q: keyring entry not set — run :set-password %s\n", accountName, accountName)
} else {
fmt.Fprintf(os.Stderr, "neomd: account %q: keyring unavailable: %v\n", accountName, err)
}
return password // leave sentinel for downstream
}

// expandEnv resolves a value that is entirely a single env var reference
// ($VAR or ${VAR}). If the value contains other text or multiple $ signs
// it is returned as-is, so passwords containing $ are never mangled.
Expand Down
110 changes: 110 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import (
"path/filepath"
"strings"
"testing"

zalandokeyring "github.com/zalando/go-keyring"
"github.com/sspaeti/neomd/internal/keyring"
)

func TestExpandEnv(t *testing.T) {
Expand Down Expand Up @@ -368,3 +371,110 @@ func TestValidate_NegativeUIValues(t *testing.T) {
t.Error("expected error for negative inbox_count")
}
}

func TestUseKeyring(t *testing.T) {
tests := []struct {
password string
want bool
}{
{"keyring", true},
{"actual-password", false},
{"", false},
{"$ENV_VAR", false},
{"keyring-like", false},
}
for _, tt := range tests {
acc := AccountConfig{Password: tt.password}
if got := acc.UseKeyring(); got != tt.want {
t.Errorf("UseKeyring(%q) = %v, want %v", tt.password, got, tt.want)
}
}
}

func TestResolveKeyringPassword(t *testing.T) {
// Use the in-memory mock provided by zalando/go-keyring so tests don't
// touch the real OS keyring.
zalandokeyring.MockInit()

t.Run("resolved when entry exists", func(t *testing.T) {
const acct = "TestAcctResolved"
if err := keyring.SetPassword(acct, "real-secret"); err != nil {
t.Fatalf("SetPassword: %v", err)
}
got := resolveKeyringPassword(acct, "keyring")
if got != "real-secret" {
t.Errorf("got %q, want resolved password", got)
}
_ = keyring.DeletePassword(acct)
})

t.Run("sentinel preserved when entry missing", func(t *testing.T) {
got := resolveKeyringPassword("MissingAcct", "keyring")
if got != "keyring" {
t.Errorf("got %q, want sentinel preserved", got)
}
})

t.Run("non-sentinel passthrough", func(t *testing.T) {
got := resolveKeyringPassword("any", "literal-password")
if got != "literal-password" {
t.Errorf("got %q, want passthrough", got)
}
})

t.Run("empty account name passthrough", func(t *testing.T) {
// Anonymous accounts (empty Name) should not trigger a keyring lookup.
got := resolveKeyringPassword("", "keyring")
if got != "keyring" {
t.Errorf("got %q, want passthrough for empty account", got)
}
})
}

// TestLoad_KeyringResolvesAcrossSenders verifies the senders-gap fix:
// PR #5 resolved keyring at IMAP construction time, leaving SMTP send paths
// reading the literal "keyring" sentinel. By moving resolution into Load(),
// every consumer (IMAP, SMTP, [[senders]] aliases) sees the resolved value.
func TestLoad_KeyringResolvesAcrossSenders(t *testing.T) {
zalandokeyring.MockInit()
const acctName = "Personal"
const realPw = "the-real-password"
if err := keyring.SetPassword(acctName, realPw); err != nil {
t.Fatalf("SetPassword: %v", err)
}
defer keyring.DeletePassword(acctName)

dir := t.TempDir()
cfgPath := filepath.Join(dir, "config.toml")
cfgBody := `
[[accounts]]
name = "Personal"
imap = "imap.example.com:993"
smtp = "smtp.example.com:587"
user = "me@example.com"
password = "keyring"
from = "Me <me@example.com>"

[[senders]]
name = "Alias"
from = "Alias <alias@example.com>"
account = "Personal"
`
if err := os.WriteFile(cfgPath, []byte(cfgBody), 0600); err != nil {
t.Fatalf("write config: %v", err)
}

cfg, err := Load(cfgPath)
if err != nil {
t.Fatalf("Load: %v", err)
}
if len(cfg.Accounts) != 1 {
t.Fatalf("expected 1 account, got %d", len(cfg.Accounts))
}
if got := cfg.Accounts[0].Password; got != realPw {
t.Errorf("Accounts[0].Password = %q, want resolved %q (the senders-gap fix)", got, realPw)
}
if cfg.Accounts[0].UseKeyring() {
t.Error("UseKeyring() should be false after successful resolution")
}
}
Loading
Loading