Skip to content
Open
33 changes: 32 additions & 1 deletion docs/content/docs/configuration/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ 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)
### Storing passwords in the OS keyring (Linux/MacOS)

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`.

Expand All @@ -127,22 +127,37 @@ password = "keyring" # resolved at startup; see below for setup
# ...rest of the account
```

If you use OAuth2 authentication, set `oauth2_client_secret = "keyring"` instead of the password field.

```toml
[[accounts]]
name = "Personal"
oauth2_client_secret = "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
# or
secret-tool store --label "neomd Personal" service neomd username account/Personal/oauth2_client_secret

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

# 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
# or
secret-tool clear service neomd username account/Personal/oauth2_client_secret
```

> [!IMPORTANT]
Expand All @@ -162,6 +177,22 @@ If the keyring entry is missing or the keyring service is unavailable, neomd pri

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).

**Setup before first launch (MacOS, using `security`)**

```sh
# Add the entry (add-generic-password does not prompt by default so we use read instead)
read -rsp "Password: " pw && eho && security add-generic-passowrd -s neomd -a account/Personal/password -w "$pw"

# Verify it was stored (read-only):
security find-generic-password -s neomd -a account/Personal/password -w

# Remove a specific entry if you want to start over:
security delete-generic-passowrd -s neomd -a account/Personal/password

```

The rest of details are the same as for Linux.

## TLS and STARTTLS Configuration

Neomd automatically determines the correct encryption method based on the port and the optional `starttls` config field:
Expand Down
20 changes: 20 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -540,11 +540,13 @@ func Load(path string) (*Config, error) {
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.Accounts[i].OAuth2ClientSecret = resolveKeyringClientSecret(cfg.Accounts[i].Name, cfg.Accounts[i].OAuth2ClientSecret)
}
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.Account.OAuth2ClientSecret = resolveKeyringClientSecret(cfg.Account.Name, cfg.Account.OAuth2ClientSecret)

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

Expand Down Expand Up @@ -726,6 +728,24 @@ func resolveKeyringPassword(accountName, password string) string {
return password // leave sentinel for downstream
}

// resolveKeyringClientSecret turns the "keyring" sentinel into the actual
// OAuth2 client secret stored in the OS keyring. Mirrors resolveKeyringPassword.
func resolveKeyringClientSecret(accountName, secret string) string {
if secret != keyringSentinel || accountName == "" {
return secret
}
resolved, err := keyring.GetClientSecret(accountName)
if err == nil {
return resolved
}
if err == keyring.ErrNotFound {
fmt.Fprintf(os.Stderr, "neomd: account %q: oauth2 client secret not in keyring — run :set-oauth2-secret %s\n", accountName, accountName)
} else {
fmt.Fprintf(os.Stderr, "neomd: account %q: keyring unavailable: %v\n", accountName, err)
}
return secret // 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
76 changes: 76 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -565,6 +565,82 @@ account = "Personal"
}
}

func TestResolveKeyringClientSecret(t *testing.T) {
zalandokeyring.MockInit()

t.Run("resolved when entry exists", func(t *testing.T) {
const acct = "TestAcctOAuth"
if err := keyring.SetClientSecret(acct, "the-client-secret"); err != nil {
t.Fatalf("SetClientSecret: %v", err)
}
got := resolveKeyringClientSecret(acct, "keyring")
if got != "the-client-secret" {
t.Errorf("got %q, want resolved client secret", got)
}
_ = keyring.DeleteClientSecret(acct)
})

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

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

t.Run("empty account name passthrough", func(t *testing.T) {
got := resolveKeyringClientSecret("", "keyring")
if got != "keyring" {
t.Errorf("got %q, want passthrough for empty account", got)
}
})
}

func TestLoad_KeyringResolvesClientSecret(t *testing.T) {
zalandokeyring.MockInit()
const acctName = "GMail"
const realSecret = "the-real-client-secret"
if err := keyring.SetClientSecret(acctName, realSecret); err != nil {
t.Fatalf("SetClientSecret: %v", err)
}
defer keyring.DeleteClientSecret(acctName)

dir := t.TempDir()
cfgPath := filepath.Join(dir, "config.toml")
cfgBody := `
[[accounts]]
name = "GMail"
imap = "imap.gmail.com:993"
smtp = "smtp.gmail.com:587"
user = "me@gmail.com"
from = "Me <me@gmail.com>"
oauth2_client_id = "client-id.apps.googleusercontent.com"
oauth2_client_secret = "keyring"
oauth2_issuer_url = "https://accounts.google.com"
oauth2_scopes = ["https://mail.google.com/"]
`
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].OAuth2ClientSecret; got != realSecret {
t.Errorf("Accounts[0].OAuth2ClientSecret = %q, want resolved %q", got, realSecret)
}
}

func TestLoad_AIConfigDefaultUsesInteractiveClaude(t *testing.T) {
// Regression for two prior bugs:
// 1. Defaults must NOT include `-p` (claude's print mode bills against
Expand Down
58 changes: 57 additions & 1 deletion internal/keyring/keyring.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package keyring
import (
"encoding/json"
"fmt"
"runtime"

"github.com/zalando/go-keyring"
"golang.org/x/oauth2"
Expand Down Expand Up @@ -49,15 +50,70 @@ func DeletePassword(accountName string) error {
return err
}

// clientSecretKey returns the keyring key for an OAuth2 client secret.
func clientSecretKey(accountName string) string {
return fmt.Sprintf("account/%s/oauth2_client_secret", accountName)
}

// SetClientSecret stores an OAuth2 client secret in the OS keyring.
func SetClientSecret(accountName, secret string) error {
return keyring.Set(serviceName, clientSecretKey(accountName), secret)
}

// GetClientSecret retrieves an OAuth2 client secret from the OS keyring.
// Returns ErrNotFound if no entry exists for this account.
func GetClientSecret(accountName string) (string, error) {
secret, err := keyring.Get(serviceName, clientSecretKey(accountName))
if err == keyring.ErrNotFound {
return "", ErrNotFound
}
if err != nil {
return "", fmt.Errorf("keyring get client secret: %w", err)
}
return secret, nil
}

// DeleteClientSecret removes an OAuth2 client secret from the OS keyring.
func DeleteClientSecret(accountName string) error {
err := keyring.Delete(serviceName, clientSecretKey(accountName))
if err == keyring.ErrNotFound {
return nil
}
return err
}

// SetOAuth2Token stores an OAuth2 token in the OS keyring as JSON.
//
// On macOS, only the refresh-relevant fields (RefreshToken, TokenType,
// Expiry) are persisted; the access_token is dropped. Google access tokens
// can exceed 2 KB which, once base64-encoded and wrapped in the `security`
// CLI command zalando/go-keyring uses on macOS, breaches its 4096-byte
// command-length limit. With AccessToken empty, oauth2.Token.Valid() returns
// false, so the library refreshes on first use using the stored
// RefreshToken. Other platforms store the full token unchanged.
func SetOAuth2Token(accountName string, token *oauth2.Token) error {
data, err := json.Marshal(token)
data, err := json.Marshal(prepareTokenForStorage(token, runtime.GOOS))
if err != nil {
return fmt.Errorf("marshal oauth2 token: %w", err)
}
return keyring.Set(serviceName, oauth2Key(accountName), string(data))
}

// prepareTokenForStorage returns the token shape to persist for a given OS.
// On darwin, AccessToken is dropped to stay under zalando/go-keyring's
// 4096-byte `security` command-length limit. Other platforms keep the token
// unchanged.
func prepareTokenForStorage(token *oauth2.Token, goos string) *oauth2.Token {
if goos != "darwin" {
return token
}
return &oauth2.Token{
TokenType: token.TokenType,
RefreshToken: token.RefreshToken,
Expiry: token.Expiry,
}
}

// GetOAuth2Token retrieves an OAuth2 token from the OS keyring.
// Returns ErrNotFound if no token exists for this account.
func GetOAuth2Token(accountName string) (*oauth2.Token, error) {
Expand Down
51 changes: 51 additions & 0 deletions internal/keyring/keyring_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,57 @@ func TestKeyFormats(t *testing.T) {
}
}

func TestPrepareTokenForStorage_DarwinStripsAccessToken(t *testing.T) {
tok := &oauth2.Token{
AccessToken: "ya29." + string(make([]byte, 2500)),
RefreshToken: "1//0gRefresh",
TokenType: "Bearer",
Expiry: time.Date(2026, 12, 1, 0, 0, 0, 0, time.UTC),
}

got := prepareTokenForStorage(tok, "darwin")
if got.AccessToken != "" {
t.Errorf("AccessToken should be stripped on darwin, got len=%d", len(got.AccessToken))
}
if got.RefreshToken != tok.RefreshToken {
t.Errorf("RefreshToken = %q, want %q", got.RefreshToken, tok.RefreshToken)
}
if got.TokenType != tok.TokenType {
t.Errorf("TokenType = %q, want %q", got.TokenType, tok.TokenType)
}
if !got.Expiry.Equal(tok.Expiry) {
t.Errorf("Expiry = %v, want %v", got.Expiry, tok.Expiry)
}

// Marshaled size must fit comfortably under the 4096-byte security CLI limit
// (after base64 expansion ~4/3 and ~80 bytes of command wrapping).
data, _ := json.Marshal(got)
if len(data) > 1024 {
t.Errorf("stripped token JSON unexpectedly large: %d bytes", len(data))
}
}

func TestPrepareTokenForStorage_OtherOSPreservesToken(t *testing.T) {
tok := &oauth2.Token{
AccessToken: "access123",
RefreshToken: "refresh456",
TokenType: "Bearer",
Expiry: time.Date(2026, 12, 1, 0, 0, 0, 0, time.UTC),
}

for _, goos := range []string{"linux", "windows", "freebsd"} {
t.Run(goos, func(t *testing.T) {
got := prepareTokenForStorage(tok, goos)
if got.AccessToken != tok.AccessToken {
t.Errorf("AccessToken = %q, want %q (no stripping on %s)", got.AccessToken, tok.AccessToken, goos)
}
if got.RefreshToken != tok.RefreshToken {
t.Errorf("RefreshToken = %q, want %q", got.RefreshToken, tok.RefreshToken)
}
})
}
}

func TestClear(t *testing.T) {
mock := NewMockBackend()

Expand Down
14 changes: 10 additions & 4 deletions internal/oauth2/oauth2.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
_ "embed"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net"
Expand Down Expand Up @@ -169,7 +170,9 @@ func TokenSource(ctx context.Context, cfg Config) (func() (string, error), error
if err != nil {
return "", err
}
_ = storage.Save(t)
if err := storage.Save(t); err != nil {
fmt.Fprintf(os.Stderr, "neomd: account %q: oauth2 token refresh save failed: %v\n", cfg.AccountName, err)
}
return t.AccessToken, nil
}, nil
}
Expand Down Expand Up @@ -303,7 +306,9 @@ func (s *tokenStorage) Load() (*oauth2.Token, error) {
if err == nil {
return tok, nil
}
// Any keyring error (including ErrNotFound) — try file fallback.
if !errors.Is(err, keyring.ErrNotFound) {
fmt.Fprintf(os.Stderr, "neomd: account %q: oauth2 keyring load failed, falling back to file: %v\n", s.account, err)
}
}
if s.path == "" {
return nil, fmt.Errorf("oauth2: no token storage available (no account name and no token file path)")
Expand All @@ -313,10 +318,11 @@ func (s *tokenStorage) Load() (*oauth2.Token, error) {

func (s *tokenStorage) Save(tok *oauth2.Token) error {
if s.account != "" {
if err := keyring.SetOAuth2Token(s.account, tok); err == nil {
err := keyring.SetOAuth2Token(s.account, tok)
if err == nil {
return nil
}
// Keyring failed — fall back to file.
fmt.Fprintf(os.Stderr, "neomd: account %q: oauth2 keyring save failed, falling back to file: %v\n", s.account, err)
}
if s.path == "" {
return fmt.Errorf("oauth2: no token storage available")
Expand Down
Loading
Loading