Skip to content

Commit 54d6ae4

Browse files
authored
Merge pull request #13 from ssp-data/feature/keyring
Feature/keyring
2 parents 22be014 + 155ec85 commit 54d6ae4

12 files changed

Lines changed: 773 additions & 17 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ Folder operations prefer RFC 6851 MOVE; `u` undo uses UIDPLUS destination UIDs c
5757

5858
**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.
5959

60+
**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`.
61+
6062
**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.
6163

6264
**Package structure:**

cmd/neomd/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ func main() {
102102
Scopes: acc.OAuth2Scopes,
103103
RedirectPort: acc.OAuth2RedirectPort,
104104
TokenFile: tokenFile,
105+
AccountName: acc.Name, // enables keyring storage; TokenFile remains as headless fallback
105106
})
106107
if err != nil {
107108
fmt.Fprintf(os.Stderr, "neomd: account %q: oauth2: %v\n", acc.Name, err)

docs/content/docs/configuration/_index.md

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ sidebar:
77

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

10+
1011
## Full example
1112

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

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

100-
## Environment Variables
101+
102+
103+
## Passwords: Env and Keyring
104+
105+
106+
### Environment Variables
101107

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

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

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

119+
### Storing passwords in the OS keyring (Linux)
120+
121+
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`.
122+
123+
```toml
124+
[[accounts]]
125+
name = "Personal"
126+
password = "keyring" # resolved at startup; see below for setup
127+
# ...rest of the account
128+
```
129+
130+
**Setup before first launch (Linux, using `secret-tool` from `libsecret`):**
131+
132+
`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**.
133+
134+
```sh
135+
# Add the entry (you'll be prompted for the password on stdin):
136+
secret-tool store --label "neomd Personal" service neomd username account/Personal/password
137+
138+
# Verify it was stored (read-only):
139+
secret-tool lookup service neomd username account/Personal/password
140+
141+
# Audit every neomd entry currently in your keyring:
142+
secret-tool search --all service neomd
143+
144+
# Remove a specific entry if you want to start over:
145+
secret-tool clear service neomd username account/Personal/password
146+
```
147+
148+
> [!IMPORTANT]
149+
> **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.
150+
151+
```sh
152+
secret-tool store --label "neomd Personal" service neomd username account/Personal/password
153+
secret-tool store --label "neomd Work" service neomd username account/Work/password
154+
secret-tool store --label "neomd WorkInfo" service neomd username account/WorkInfo/password
155+
```
156+
157+
> **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"`.
158+
159+
`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.
160+
161+
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.
162+
163+
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).
164+
113165
## TLS and STARTTLS Configuration
114166

115167
Neomd automatically determines the correct encryption method based on the port and the optional `starttls` config field:

go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ require (
1515
github.com/fsnotify/fsnotify v1.9.0
1616
github.com/sspaeti/goldmark-obsidian-callout-for-neomd v0.1.1
1717
github.com/yuin/goldmark v1.7.8
18+
github.com/zalando/go-keyring v0.2.8
1819
golang.org/x/oauth2 v0.35.0
1920
)
2021

@@ -32,8 +33,10 @@ require (
3233
github.com/clipperhouse/displaywidth v0.9.0 // indirect
3334
github.com/clipperhouse/stringish v0.1.1 // indirect
3435
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
36+
github.com/danieljoos/wincred v1.2.3 // indirect
3537
github.com/dlclark/regexp2 v1.11.0 // indirect
3638
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
39+
github.com/godbus/dbus/v5 v5.2.2 // indirect
3740
github.com/gorilla/css v1.0.1 // indirect
3841
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
3942
github.com/mattn/go-isatty v0.0.20 // indirect

go.sum

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,10 @@ github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfa
4444
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
4545
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
4646
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
47+
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
48+
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
4749
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
50+
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
4851
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
4952
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
5053
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
@@ -58,6 +61,8 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6
5861
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
5962
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
6063
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
64+
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
65+
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
6166
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
6267
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
6368
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
@@ -103,8 +108,12 @@ github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NF
103108
github.com/sspaeti/goldmark-obsidian-callout-for-neomd v0.1.1 h1:obWsWMJZGW4w9Qj1/WKLDweR+olq23r24UPS86XTbQU=
104109
github.com/sspaeti/goldmark-obsidian-callout-for-neomd v0.1.1/go.mod h1:p/gHio8liSHKd6Zcig2LFSU8Ym1yV9L+Dt1eYP9F6+I=
105110
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
111+
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
112+
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
106113
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
107114
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
115+
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
116+
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
108117
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
109118
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
110119
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
@@ -113,6 +122,8 @@ github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
113122
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
114123
github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
115124
github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
125+
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
126+
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
116127
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
117128
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
118129
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
@@ -182,3 +193,5 @@ gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8
182193
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
183194
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
184195
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
196+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
197+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

internal/config/config.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"strings"
1212

1313
"github.com/BurntSushi/toml"
14+
"github.com/sspaeti/neomd/internal/keyring"
1415
)
1516

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

56+
// keyringSentinel is the literal value users put in `password = "keyring"`
57+
// to signal "fetch from OS keyring at startup."
58+
const keyringSentinel = "keyring"
59+
60+
// UseKeyring reports whether this account stores its password in the OS
61+
// keyring. After config.Load() resolves the keyring entry, Password holds
62+
// the actual password and this method returns false. The sentinel only
63+
// remains if keyring lookup failed (no entry yet, or service unavailable),
64+
// in which case downstream code can prompt the user via :set-password.
65+
func (a AccountConfig) UseKeyring() bool {
66+
return a.Password == keyringSentinel
67+
}
68+
5569
// ScreenerConfig points to the allowlist/blocklist files.
5670
type ScreenerConfig struct {
5771
ScreenedIn string `toml:"screened_in"`
@@ -462,10 +476,12 @@ func Load(path string) (*Config, error) {
462476
cfg.Accounts[i].Password = expandEnv(cfg.Accounts[i].Password)
463477
cfg.Accounts[i].User = expandEnv(cfg.Accounts[i].User)
464478
cfg.Accounts[i].TLSCertFile = expandPath(expandEnv(cfg.Accounts[i].TLSCertFile))
479+
cfg.Accounts[i].Password = resolveKeyringPassword(cfg.Accounts[i].Name, cfg.Accounts[i].Password)
465480
}
466481
cfg.Account.Password = expandEnv(cfg.Account.Password)
467482
cfg.Account.User = expandEnv(cfg.Account.User)
468483
cfg.Account.TLSCertFile = expandPath(expandEnv(cfg.Account.TLSCertFile))
484+
cfg.Account.Password = resolveKeyringPassword(cfg.Account.Name, cfg.Account.Password)
469485

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

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

626+
// resolveKeyringPassword turns the "keyring" sentinel into the actual password
627+
// stored in the OS keyring under the given account name. Anything else is
628+
// returned unchanged. If the keyring lookup fails (no entry yet, or service
629+
// unavailable) the sentinel is preserved so downstream code can detect it and
630+
// prompt the user; a one-line warning is written to stderr.
631+
//
632+
// This step runs in Load() so every consumer (IMAP at boot, SMTP at send,
633+
// senders aliasing this account) sees the resolved value.
634+
func resolveKeyringPassword(accountName, password string) string {
635+
if password != keyringSentinel || accountName == "" {
636+
return password
637+
}
638+
resolved, err := keyring.GetPassword(accountName)
639+
if err == nil {
640+
return resolved
641+
}
642+
if err == keyring.ErrNotFound {
643+
fmt.Fprintf(os.Stderr, "neomd: account %q: keyring entry not set — run :set-password %s\n", accountName, accountName)
644+
} else {
645+
fmt.Fprintf(os.Stderr, "neomd: account %q: keyring unavailable: %v\n", accountName, err)
646+
}
647+
return password // leave sentinel for downstream
648+
}
649+
610650
// expandEnv resolves a value that is entirely a single env var reference
611651
// ($VAR or ${VAR}). If the value contains other text or multiple $ signs
612652
// it is returned as-is, so passwords containing $ are never mangled.

internal/config/config_test.go

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ import (
55
"path/filepath"
66
"strings"
77
"testing"
8+
9+
zalandokeyring "github.com/zalando/go-keyring"
10+
"github.com/sspaeti/neomd/internal/keyring"
811
)
912

1013
func TestExpandEnv(t *testing.T) {
@@ -368,3 +371,110 @@ func TestValidate_NegativeUIValues(t *testing.T) {
368371
t.Error("expected error for negative inbox_count")
369372
}
370373
}
374+
375+
func TestUseKeyring(t *testing.T) {
376+
tests := []struct {
377+
password string
378+
want bool
379+
}{
380+
{"keyring", true},
381+
{"actual-password", false},
382+
{"", false},
383+
{"$ENV_VAR", false},
384+
{"keyring-like", false},
385+
}
386+
for _, tt := range tests {
387+
acc := AccountConfig{Password: tt.password}
388+
if got := acc.UseKeyring(); got != tt.want {
389+
t.Errorf("UseKeyring(%q) = %v, want %v", tt.password, got, tt.want)
390+
}
391+
}
392+
}
393+
394+
func TestResolveKeyringPassword(t *testing.T) {
395+
// Use the in-memory mock provided by zalando/go-keyring so tests don't
396+
// touch the real OS keyring.
397+
zalandokeyring.MockInit()
398+
399+
t.Run("resolved when entry exists", func(t *testing.T) {
400+
const acct = "TestAcctResolved"
401+
if err := keyring.SetPassword(acct, "real-secret"); err != nil {
402+
t.Fatalf("SetPassword: %v", err)
403+
}
404+
got := resolveKeyringPassword(acct, "keyring")
405+
if got != "real-secret" {
406+
t.Errorf("got %q, want resolved password", got)
407+
}
408+
_ = keyring.DeletePassword(acct)
409+
})
410+
411+
t.Run("sentinel preserved when entry missing", func(t *testing.T) {
412+
got := resolveKeyringPassword("MissingAcct", "keyring")
413+
if got != "keyring" {
414+
t.Errorf("got %q, want sentinel preserved", got)
415+
}
416+
})
417+
418+
t.Run("non-sentinel passthrough", func(t *testing.T) {
419+
got := resolveKeyringPassword("any", "literal-password")
420+
if got != "literal-password" {
421+
t.Errorf("got %q, want passthrough", got)
422+
}
423+
})
424+
425+
t.Run("empty account name passthrough", func(t *testing.T) {
426+
// Anonymous accounts (empty Name) should not trigger a keyring lookup.
427+
got := resolveKeyringPassword("", "keyring")
428+
if got != "keyring" {
429+
t.Errorf("got %q, want passthrough for empty account", got)
430+
}
431+
})
432+
}
433+
434+
// TestLoad_KeyringResolvesAcrossSenders verifies the senders-gap fix:
435+
// PR #5 resolved keyring at IMAP construction time, leaving SMTP send paths
436+
// reading the literal "keyring" sentinel. By moving resolution into Load(),
437+
// every consumer (IMAP, SMTP, [[senders]] aliases) sees the resolved value.
438+
func TestLoad_KeyringResolvesAcrossSenders(t *testing.T) {
439+
zalandokeyring.MockInit()
440+
const acctName = "Personal"
441+
const realPw = "the-real-password"
442+
if err := keyring.SetPassword(acctName, realPw); err != nil {
443+
t.Fatalf("SetPassword: %v", err)
444+
}
445+
defer keyring.DeletePassword(acctName)
446+
447+
dir := t.TempDir()
448+
cfgPath := filepath.Join(dir, "config.toml")
449+
cfgBody := `
450+
[[accounts]]
451+
name = "Personal"
452+
imap = "imap.example.com:993"
453+
smtp = "smtp.example.com:587"
454+
user = "me@example.com"
455+
password = "keyring"
456+
from = "Me <me@example.com>"
457+
458+
[[senders]]
459+
name = "Alias"
460+
from = "Alias <alias@example.com>"
461+
account = "Personal"
462+
`
463+
if err := os.WriteFile(cfgPath, []byte(cfgBody), 0600); err != nil {
464+
t.Fatalf("write config: %v", err)
465+
}
466+
467+
cfg, err := Load(cfgPath)
468+
if err != nil {
469+
t.Fatalf("Load: %v", err)
470+
}
471+
if len(cfg.Accounts) != 1 {
472+
t.Fatalf("expected 1 account, got %d", len(cfg.Accounts))
473+
}
474+
if got := cfg.Accounts[0].Password; got != realPw {
475+
t.Errorf("Accounts[0].Password = %q, want resolved %q (the senders-gap fix)", got, realPw)
476+
}
477+
if cfg.Accounts[0].UseKeyring() {
478+
t.Error("UseKeyring() should be false after successful resolution")
479+
}
480+
}

0 commit comments

Comments
 (0)