Skip to content

Commit e454d60

Browse files
committed
merge main branch - resolve merge conflict
2 parents 7c2a6d8 + 54d6ae4 commit e454d60

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

@@ -33,8 +34,10 @@ require (
3334
github.com/clipperhouse/displaywidth v0.9.0 // indirect
3435
github.com/clipperhouse/stringish v0.1.1 // indirect
3536
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
37+
github.com/danieljoos/wincred v1.2.3 // indirect
3638
github.com/dlclark/regexp2 v1.11.0 // indirect
3739
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
40+
github.com/godbus/dbus/v5 v5.2.2 // indirect
3841
github.com/gorilla/css v1.0.1 // indirect
3942
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
4043
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
@@ -46,7 +46,10 @@ github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfa
4646
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
4747
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
4848
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
49+
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
50+
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
4951
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
52+
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
5053
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
5154
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
5255
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
@@ -60,6 +63,8 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6
6063
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
6164
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
6265
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
66+
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
67+
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
6368
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
6469
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
6570
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
@@ -105,8 +110,12 @@ github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NF
105110
github.com/sspaeti/goldmark-obsidian-callout-for-neomd v0.1.1 h1:obWsWMJZGW4w9Qj1/WKLDweR+olq23r24UPS86XTbQU=
106111
github.com/sspaeti/goldmark-obsidian-callout-for-neomd v0.1.1/go.mod h1:p/gHio8liSHKd6Zcig2LFSU8Ym1yV9L+Dt1eYP9F6+I=
107112
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
113+
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
114+
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
108115
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
109116
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
117+
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
118+
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
110119
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
111120
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
112121
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
@@ -115,6 +124,8 @@ github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
115124
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
116125
github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk=
117126
github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
127+
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
128+
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
118129
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
119130
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
120131
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
@@ -184,3 +195,5 @@ gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8
184195
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
185196
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
186197
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
198+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
199+
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"`
@@ -517,10 +531,12 @@ func Load(path string) (*Config, error) {
517531
cfg.Accounts[i].Password = expandEnv(cfg.Accounts[i].Password)
518532
cfg.Accounts[i].User = expandEnv(cfg.Accounts[i].User)
519533
cfg.Accounts[i].TLSCertFile = expandPath(expandEnv(cfg.Accounts[i].TLSCertFile))
534+
cfg.Accounts[i].Password = resolveKeyringPassword(cfg.Accounts[i].Name, cfg.Accounts[i].Password)
520535
}
521536
cfg.Account.Password = expandEnv(cfg.Account.Password)
522537
cfg.Account.User = expandEnv(cfg.Account.User)
523538
cfg.Account.TLSCertFile = expandPath(expandEnv(cfg.Account.TLSCertFile))
539+
cfg.Account.Password = resolveKeyringPassword(cfg.Account.Name, cfg.Account.Password)
524540

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

@@ -669,6 +685,30 @@ func writeDefault(path string, cfg *Config) error {
669685
return toml.NewEncoder(f).Encode(cfg)
670686
}
671687

688+
// resolveKeyringPassword turns the "keyring" sentinel into the actual password
689+
// stored in the OS keyring under the given account name. Anything else is
690+
// returned unchanged. If the keyring lookup fails (no entry yet, or service
691+
// unavailable) the sentinel is preserved so downstream code can detect it and
692+
// prompt the user; a one-line warning is written to stderr.
693+
//
694+
// This step runs in Load() so every consumer (IMAP at boot, SMTP at send,
695+
// senders aliasing this account) sees the resolved value.
696+
func resolveKeyringPassword(accountName, password string) string {
697+
if password != keyringSentinel || accountName == "" {
698+
return password
699+
}
700+
resolved, err := keyring.GetPassword(accountName)
701+
if err == nil {
702+
return resolved
703+
}
704+
if err == keyring.ErrNotFound {
705+
fmt.Fprintf(os.Stderr, "neomd: account %q: keyring entry not set — run :set-password %s\n", accountName, accountName)
706+
} else {
707+
fmt.Fprintf(os.Stderr, "neomd: account %q: keyring unavailable: %v\n", accountName, err)
708+
}
709+
return password // leave sentinel for downstream
710+
}
711+
672712
// expandEnv resolves a value that is entirely a single env var reference
673713
// ($VAR or ${VAR}). If the value contains other text or multiple $ signs
674714
// 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) {
@@ -454,3 +457,110 @@ command = ""
454457
t.Errorf("AI.Command = %q, want empty (explicit disable)", cfg.AI.Command)
455458
}
456459
}
460+
461+
func TestUseKeyring(t *testing.T) {
462+
tests := []struct {
463+
password string
464+
want bool
465+
}{
466+
{"keyring", true},
467+
{"actual-password", false},
468+
{"", false},
469+
{"$ENV_VAR", false},
470+
{"keyring-like", false},
471+
}
472+
for _, tt := range tests {
473+
acc := AccountConfig{Password: tt.password}
474+
if got := acc.UseKeyring(); got != tt.want {
475+
t.Errorf("UseKeyring(%q) = %v, want %v", tt.password, got, tt.want)
476+
}
477+
}
478+
}
479+
480+
func TestResolveKeyringPassword(t *testing.T) {
481+
// Use the in-memory mock provided by zalando/go-keyring so tests don't
482+
// touch the real OS keyring.
483+
zalandokeyring.MockInit()
484+
485+
t.Run("resolved when entry exists", func(t *testing.T) {
486+
const acct = "TestAcctResolved"
487+
if err := keyring.SetPassword(acct, "real-secret"); err != nil {
488+
t.Fatalf("SetPassword: %v", err)
489+
}
490+
got := resolveKeyringPassword(acct, "keyring")
491+
if got != "real-secret" {
492+
t.Errorf("got %q, want resolved password", got)
493+
}
494+
_ = keyring.DeletePassword(acct)
495+
})
496+
497+
t.Run("sentinel preserved when entry missing", func(t *testing.T) {
498+
got := resolveKeyringPassword("MissingAcct", "keyring")
499+
if got != "keyring" {
500+
t.Errorf("got %q, want sentinel preserved", got)
501+
}
502+
})
503+
504+
t.Run("non-sentinel passthrough", func(t *testing.T) {
505+
got := resolveKeyringPassword("any", "literal-password")
506+
if got != "literal-password" {
507+
t.Errorf("got %q, want passthrough", got)
508+
}
509+
})
510+
511+
t.Run("empty account name passthrough", func(t *testing.T) {
512+
// Anonymous accounts (empty Name) should not trigger a keyring lookup.
513+
got := resolveKeyringPassword("", "keyring")
514+
if got != "keyring" {
515+
t.Errorf("got %q, want passthrough for empty account", got)
516+
}
517+
})
518+
}
519+
520+
// TestLoad_KeyringResolvesAcrossSenders verifies the senders-gap fix:
521+
// PR #5 resolved keyring at IMAP construction time, leaving SMTP send paths
522+
// reading the literal "keyring" sentinel. By moving resolution into Load(),
523+
// every consumer (IMAP, SMTP, [[senders]] aliases) sees the resolved value.
524+
func TestLoad_KeyringResolvesAcrossSenders(t *testing.T) {
525+
zalandokeyring.MockInit()
526+
const acctName = "Personal"
527+
const realPw = "the-real-password"
528+
if err := keyring.SetPassword(acctName, realPw); err != nil {
529+
t.Fatalf("SetPassword: %v", err)
530+
}
531+
defer keyring.DeletePassword(acctName)
532+
533+
dir := t.TempDir()
534+
cfgPath := filepath.Join(dir, "config.toml")
535+
cfgBody := `
536+
[[accounts]]
537+
name = "Personal"
538+
imap = "imap.example.com:993"
539+
smtp = "smtp.example.com:587"
540+
user = "me@example.com"
541+
password = "keyring"
542+
from = "Me <me@example.com>"
543+
544+
[[senders]]
545+
name = "Alias"
546+
from = "Alias <alias@example.com>"
547+
account = "Personal"
548+
`
549+
if err := os.WriteFile(cfgPath, []byte(cfgBody), 0600); err != nil {
550+
t.Fatalf("write config: %v", err)
551+
}
552+
553+
cfg, err := Load(cfgPath)
554+
if err != nil {
555+
t.Fatalf("Load: %v", err)
556+
}
557+
if len(cfg.Accounts) != 1 {
558+
t.Fatalf("expected 1 account, got %d", len(cfg.Accounts))
559+
}
560+
if got := cfg.Accounts[0].Password; got != realPw {
561+
t.Errorf("Accounts[0].Password = %q, want resolved %q (the senders-gap fix)", got, realPw)
562+
}
563+
if cfg.Accounts[0].UseKeyring() {
564+
t.Error("UseKeyring() should be false after successful resolution")
565+
}
566+
}

0 commit comments

Comments
 (0)