···11+name: test
22+on: [pull_request]
33+jobs:
44+ test:
55+ runs-on: ubuntu-latest
66+ steps:
77+ - uses: actions/checkout@v4
88+ - uses: actions/setup-go@v5
99+ with:
1010+ go-version: '1.24'
1111+ - run: go test ./...
1212+ - run: go vet ./...
+2-1
CHANGELOG.md
···11# Changelog
2233-# 2026-04-06
33+# 2026-04-08
44- **Fix: pre-send `e` losing email body** — pressing `e` in the pre-send review to re-edit now correctly reopens the editor with the existing body; previously it opened a blank compose with only the signature, silently discarding the email content (including reply history)
55- **Draft backups** — every compose session is automatically backed up to `~/.cache/neomd/drafts/` before the temp file is deleted; keeps a rolling 20 backups (configurable via `draft_backup_count` in `[ui]`, set to `-1` to disable); no more lost emails after crashes or accidental closes
66- **`:recover` / `:rec` command** — reopens the most recent draft backup as a compose session; To/Cc/Bcc/Subject are parsed from the backup and pre-filled automatically
77- **Screener docs: "screening happens once"** — documented that auto-screening only runs on the Inbox folder; emails moved to ToScreen by another device are not re-classified; use `:reset-toscreen` to move them back for re-screening
88+- **Test suite** — 147 tests across 8 packages covering screener classification, MIME message building, editor parsing, config loading, IMAP search, OAuth2 token handling, rendering, and security invariants (file permissions, BCC privacy, credential leak prevention); CI workflow runs `go test` + `go vet` on every PR
89910# 2026-04-05
1011- **OAuth2 authentication** ([#3](https://github.com/ssp-data/neomd/pull/3), thanks [@notthatjesus](https://github.com/notthatjesus)) — accounts can set `auth_type = "oauth2"` with `oauth2_client_id`, `oauth2_client_secret`, `oauth2_issuer_url`, and `oauth2_scopes` instead of a password; on first launch neomd opens the browser for the authorization code flow, persists the token to `~/.config/neomd/tokens/<account>.json`, and refreshes it automatically; works with Gmail, Office365, and any OIDC-discoverable provider via XOAUTH2 over IMAP and SMTP; password auth paths unchanged for existing accounts
+240
internal/config/config_test.go
···11+package config
22+33+import (
44+ "os"
55+ "path/filepath"
66+ "strings"
77+ "testing"
88+)
99+1010+func TestExpandEnv(t *testing.T) {
1111+ tests := []struct {
1212+ name string
1313+ input string
1414+ envKey string
1515+ envVal string
1616+ want string
1717+ }{
1818+ {"bare $VAR", "$MY_PASS", "MY_PASS", "secret", "secret"},
1919+ {"braced ${VAR}", "${MY_PASS}", "MY_PASS", "secret", "secret"},
2020+ {"embedded dollar", "literal$value", "", "", "literal$value"},
2121+ {"multiple dollars", "pa$$word", "", "", "pa$$word"},
2222+ {"empty string", "", "", "", ""},
2323+ }
2424+ for _, tt := range tests {
2525+ t.Run(tt.name, func(t *testing.T) {
2626+ if tt.envKey != "" {
2727+ t.Setenv(tt.envKey, tt.envVal)
2828+ }
2929+ got := expandEnv(tt.input)
3030+ if got != tt.want {
3131+ t.Errorf("expandEnv(%q) = %q, want %q", tt.input, got, tt.want)
3232+ }
3333+ })
3434+ }
3535+}
3636+3737+func TestExpandPath(t *testing.T) {
3838+ home, err := os.UserHomeDir()
3939+ if err != nil {
4040+ t.Fatalf("cannot determine home dir: %v", err)
4141+ }
4242+ tests := []struct {
4343+ name string
4444+ input string
4545+ want string
4646+ }{
4747+ {"tilde prefix", "~/mail", filepath.Join(home, "mail")},
4848+ {"bare tilde", "~", home},
4949+ {"absolute path", "/absolute/path", "/absolute/path"},
5050+ {"empty", "", ""},
5151+ }
5252+ for _, tt := range tests {
5353+ t.Run(tt.name, func(t *testing.T) {
5454+ got := expandPath(tt.input)
5555+ if got != tt.want {
5656+ t.Errorf("expandPath(%q) = %q, want %q", tt.input, got, tt.want)
5757+ }
5858+ })
5959+ }
6060+}
6161+6262+func TestTabLabels(t *testing.T) {
6363+ tests := []struct {
6464+ name string
6565+ tabOrder []string
6666+ wantFirst []string // check these labels appear at the start
6767+ }{
6868+ {
6969+ "empty tab_order returns defaults",
7070+ nil,
7171+ []string{"Inbox", "ToScreen", "Feed"},
7272+ },
7373+ {
7474+ "custom order",
7575+ []string{"inbox", "feed"},
7676+ []string{"Inbox", "Feed"},
7777+ },
7878+ {
7979+ "unknown key skipped",
8080+ []string{"inbox", "nonexistent", "feed"},
8181+ []string{"Inbox", "Feed"},
8282+ },
8383+ }
8484+ for _, tt := range tests {
8585+ t.Run(tt.name, func(t *testing.T) {
8686+ f := FoldersConfig{TabOrder: tt.tabOrder}
8787+ got := f.TabLabels()
8888+ if len(got) < len(tt.wantFirst) {
8989+ t.Fatalf("got %d labels, want at least %d", len(got), len(tt.wantFirst))
9090+ }
9191+ for i, want := range tt.wantFirst {
9292+ if got[i] != want {
9393+ t.Errorf("TabLabels()[%d] = %q, want %q", i, got[i], want)
9494+ }
9595+ }
9696+ // For the custom order cases the length should match exactly.
9797+ if tt.tabOrder != nil {
9898+ wantLen := 0
9999+ for _, k := range tt.tabOrder {
100100+ if _, ok := keyToLabel[k]; ok {
101101+ wantLen++
102102+ }
103103+ }
104104+ if len(got) != wantLen {
105105+ t.Errorf("TabLabels() returned %d labels, want %d", len(got), wantLen)
106106+ }
107107+ }
108108+ })
109109+ }
110110+}
111111+112112+func TestAutoScreen(t *testing.T) {
113113+ boolPtr := func(b bool) *bool { return &b }
114114+ tests := []struct {
115115+ name string
116116+ val *bool
117117+ want bool
118118+ }{
119119+ {"nil defaults to true", nil, true},
120120+ {"explicit false", boolPtr(false), false},
121121+ {"explicit true", boolPtr(true), true},
122122+ }
123123+ for _, tt := range tests {
124124+ t.Run(tt.name, func(t *testing.T) {
125125+ u := UIConfig{AutoScreenOnLoad: tt.val}
126126+ if got := u.AutoScreen(); got != tt.want {
127127+ t.Errorf("AutoScreen() = %v, want %v", got, tt.want)
128128+ }
129129+ })
130130+ }
131131+}
132132+133133+func TestDraftBackups(t *testing.T) {
134134+ tests := []struct {
135135+ name string
136136+ count int
137137+ want int
138138+ }{
139139+ {"zero defaults to 20", 0, 20},
140140+ {"explicit value", 5, 5},
141141+ {"negative disables", -1, -1},
142142+ }
143143+ for _, tt := range tests {
144144+ t.Run(tt.name, func(t *testing.T) {
145145+ u := UIConfig{DraftBackupCount: tt.count}
146146+ if got := u.DraftBackups(); got != tt.want {
147147+ t.Errorf("DraftBackups() = %d, want %d", got, tt.want)
148148+ }
149149+ })
150150+ }
151151+}
152152+153153+func TestBulkThreshold(t *testing.T) {
154154+ tests := []struct {
155155+ name string
156156+ val int
157157+ want int
158158+ }{
159159+ {"zero defaults to 10", 0, 10},
160160+ {"explicit value", 5, 5},
161161+ }
162162+ for _, tt := range tests {
163163+ t.Run(tt.name, func(t *testing.T) {
164164+ u := UIConfig{BulkProgressThreshold: tt.val}
165165+ if got := u.BulkThreshold(); got != tt.want {
166166+ t.Errorf("BulkThreshold() = %d, want %d", got, tt.want)
167167+ }
168168+ })
169169+ }
170170+}
171171+172172+func TestLoad_MissingConfigCreatesDefault(t *testing.T) {
173173+ dir := t.TempDir()
174174+ path := filepath.Join(dir, "neomd", "config.toml")
175175+176176+ _, err := Load(path)
177177+ if err == nil {
178178+ t.Fatal("expected error from Load with missing config")
179179+ }
180180+ if !strings.Contains(err.Error(), "please fill in") {
181181+ t.Errorf("error = %q, want it to contain %q", err.Error(), "please fill in")
182182+ }
183183+ if _, statErr := os.Stat(path); os.IsNotExist(statErr) {
184184+ t.Errorf("expected default config to be created at %s", path)
185185+ }
186186+}
187187+188188+func TestWriteDefault_FilePermissions(t *testing.T) {
189189+ dir := t.TempDir()
190190+ path := filepath.Join(dir, "config.toml")
191191+192192+ cfg := defaults()
193193+ if err := writeDefault(path, cfg); err != nil {
194194+ t.Fatalf("writeDefault() error: %v", err)
195195+ }
196196+197197+ info, err := os.Stat(path)
198198+ if err != nil {
199199+ t.Fatalf("stat error: %v", err)
200200+ }
201201+ mode := info.Mode().Perm()
202202+ if mode != 0600 {
203203+ t.Errorf("file mode = %04o, want 0600", mode)
204204+ }
205205+}
206206+207207+func TestExpandEnv_PasswordSafety(t *testing.T) {
208208+ // Passwords containing dollar signs must never be mangled.
209209+ dangerous := []string{"pa$$word", "s3cr3t$", "$not$a$var"}
210210+ for _, pw := range dangerous {
211211+ got := expandEnv(pw)
212212+ if got != pw {
213213+ t.Errorf("expandEnv(%q) = %q — password was mangled!", pw, got)
214214+ }
215215+ }
216216+}
217217+218218+func TestLoad_ErrorMessageNoPassword(t *testing.T) {
219219+ dir := t.TempDir()
220220+ path := filepath.Join(dir, "config.toml")
221221+222222+ // Write syntactically invalid TOML that also contains a password-like string.
223223+ password := "SuperSecret123!"
224224+ content := `[account]
225225+user = "test@example.com"
226226+password = "` + password + `"
227227+invalid toml here !!!
228228+`
229229+ if err := os.WriteFile(path, []byte(content), 0600); err != nil {
230230+ t.Fatalf("write test config: %v", err)
231231+ }
232232+233233+ _, err := Load(path)
234234+ if err == nil {
235235+ t.Fatal("expected error from Load with invalid TOML")
236236+ }
237237+ if strings.Contains(err.Error(), password) {
238238+ t.Errorf("error message contains password %q — potential leak", password)
239239+ }
240240+}
+266
internal/editor/editor_test.go
···11+package editor
22+33+import (
44+ "strings"
55+ "testing"
66+)
77+88+func TestParseHeaders(t *testing.T) {
99+ tests := []struct {
1010+ name string
1111+ input string
1212+ wantTo, wantCC, wantBCC, wantSub string
1313+ wantBodyContains string // substring the body must contain
1414+ wantBodyNotContains string // substring the body must NOT contain
1515+ }{
1616+ {
1717+ name: "all fields present",
1818+ input: "# [neomd: to: alice@example.com]\n" +
1919+ "# [neomd: cc: bob@example.com]\n" +
2020+ "# [neomd: bcc: secret@example.com]\n" +
2121+ "# [neomd: subject: Hello World]\n" +
2222+ "\n" +
2323+ "Body text here.\n",
2424+ wantTo: "alice@example.com",
2525+ wantCC: "bob@example.com",
2626+ wantBCC: "secret@example.com",
2727+ wantSub: "Hello World",
2828+ wantBodyContains: "Body text here.",
2929+ wantBodyNotContains: "neomd:",
3030+ },
3131+ {
3232+ name: "missing cc and bcc",
3333+ input: "# [neomd: to: alice@example.com]\n" +
3434+ "# [neomd: subject: Only To]\n" +
3535+ "\n" +
3636+ "Some body.\n",
3737+ wantTo: "alice@example.com",
3838+ wantCC: "",
3939+ wantBCC: "",
4040+ wantSub: "Only To",
4141+ },
4242+ {
4343+ name: "body preserved with newlines and markdown",
4444+ input: "# [neomd: to: x@y.com]\n" +
4545+ "# [neomd: subject: MD test]\n" +
4646+ "\n" +
4747+ "## Heading\n" +
4848+ "\n" +
4949+ "- bullet one\n" +
5050+ "- bullet two\n" +
5151+ "\n" +
5252+ "Paragraph with **bold**.\n",
5353+ wantTo: "x@y.com",
5454+ wantSub: "MD test",
5555+ wantBodyContains: "## Heading",
5656+ },
5757+ {
5858+ name: "no headers at all",
5959+ input: "Just plain text\nwith multiple lines.\n",
6060+ wantTo: "",
6161+ wantCC: "",
6262+ wantBCC: "",
6363+ wantSub: "",
6464+ wantBodyContains: "Just plain text",
6565+ wantBodyNotContains: "",
6666+ },
6767+ {
6868+ name: "case insensitive keys",
6969+ input: "# [neomd: To: upper@example.com]\n" +
7070+ "# [neomd: Subject: Case Test]\n" +
7171+ "\n" +
7272+ "body\n",
7373+ wantTo: "upper@example.com",
7474+ wantSub: "Case Test",
7575+ },
7676+ }
7777+7878+ for _, tt := range tests {
7979+ t.Run(tt.name, func(t *testing.T) {
8080+ to, cc, bcc, subject, body := ParseHeaders(tt.input)
8181+ if to != tt.wantTo {
8282+ t.Errorf("to = %q, want %q", to, tt.wantTo)
8383+ }
8484+ if cc != tt.wantCC {
8585+ t.Errorf("cc = %q, want %q", cc, tt.wantCC)
8686+ }
8787+ if bcc != tt.wantBCC {
8888+ t.Errorf("bcc = %q, want %q", bcc, tt.wantBCC)
8989+ }
9090+ if subject != tt.wantSub {
9191+ t.Errorf("subject = %q, want %q", subject, tt.wantSub)
9292+ }
9393+ if tt.wantBodyContains != "" && !strings.Contains(body, tt.wantBodyContains) {
9494+ t.Errorf("body missing %q, got:\n%s", tt.wantBodyContains, body)
9595+ }
9696+ if tt.wantBodyNotContains != "" && strings.Contains(body, tt.wantBodyNotContains) {
9797+ t.Errorf("body should not contain %q, got:\n%s", tt.wantBodyNotContains, body)
9898+ }
9999+ })
100100+ }
101101+}
102102+103103+func TestPrelude(t *testing.T) {
104104+ tests := []struct {
105105+ name string
106106+ to, cc string
107107+ subject string
108108+ signature string
109109+ wantHas []string // substrings that must appear
110110+ wantNot []string // substrings that must NOT appear
111111+ }{
112112+ {
113113+ name: "basic without cc or sig",
114114+ to: "alice@example.com",
115115+ subject: "Greetings",
116116+ wantHas: []string{
117117+ "# [neomd: to: alice@example.com]",
118118+ "# [neomd: subject: Greetings]",
119119+ },
120120+ wantNot: []string{"# [neomd: cc:", "-- \n"},
121121+ },
122122+ {
123123+ name: "with cc",
124124+ to: "alice@example.com",
125125+ cc: "bob@example.com",
126126+ subject: "Team",
127127+ wantHas: []string{
128128+ "# [neomd: to: alice@example.com]",
129129+ "# [neomd: cc: bob@example.com]",
130130+ "# [neomd: subject: Team]",
131131+ },
132132+ },
133133+ {
134134+ name: "with signature",
135135+ to: "a@b.com",
136136+ subject: "Sig test",
137137+ signature: "Best,\nAlice",
138138+ wantHas: []string{"-- \n", "Best,\nAlice"},
139139+ },
140140+ {
141141+ name: "without signature",
142142+ to: "a@b.com",
143143+ subject: "No sig",
144144+ wantNot: []string{"-- \n"},
145145+ },
146146+ }
147147+148148+ for _, tt := range tests {
149149+ t.Run(tt.name, func(t *testing.T) {
150150+ got := Prelude(tt.to, tt.cc, tt.subject, tt.signature)
151151+ for _, want := range tt.wantHas {
152152+ if !strings.Contains(got, want) {
153153+ t.Errorf("Prelude missing %q, got:\n%s", want, got)
154154+ }
155155+ }
156156+ for _, notWant := range tt.wantNot {
157157+ if strings.Contains(got, notWant) {
158158+ t.Errorf("Prelude should not contain %q, got:\n%s", notWant, got)
159159+ }
160160+ }
161161+ })
162162+ }
163163+}
164164+165165+func TestReplyPrelude(t *testing.T) {
166166+ result := ReplyPrelude(
167167+ "alice@example.com",
168168+ "",
169169+ "Re: Hello",
170170+ "",
171171+ "Bob Smith",
172172+ "Line one\nLine two",
173173+ )
174174+175175+ // Each original body line must be quoted with "> " prefix.
176176+ if !strings.Contains(result, "> Line one") {
177177+ t.Errorf("missing quoted line one, got:\n%s", result)
178178+ }
179179+ if !strings.Contains(result, "> Line two") {
180180+ t.Errorf("missing quoted line two, got:\n%s", result)
181181+ }
182182+183183+ // Attribution line includes original sender name.
184184+ if !strings.Contains(result, "**Bob Smith** wrote:") {
185185+ t.Errorf("missing attribution line, got:\n%s", result)
186186+ }
187187+}
188188+189189+func TestForwardPrelude(t *testing.T) {
190190+ t.Run("adds Fwd prefix", func(t *testing.T) {
191191+ result := ForwardPrelude("Hello", "", "Alice", "2024-01-01", "bob@x.com", "body")
192192+ if !strings.Contains(result, "Fwd: Hello") {
193193+ t.Errorf("expected Fwd: prefix, got:\n%s", result)
194194+ }
195195+ })
196196+197197+ t.Run("no double Fwd prefix", func(t *testing.T) {
198198+ result := ForwardPrelude("Fwd: Hello", "", "Alice", "2024-01-01", "bob@x.com", "body")
199199+ if strings.Contains(result, "Fwd: Fwd:") {
200200+ t.Errorf("got double Fwd: prefix:\n%s", result)
201201+ }
202202+ })
203203+204204+ t.Run("case insensitive fwd check", func(t *testing.T) {
205205+ result := ForwardPrelude("fwd: Hello", "", "Alice", "2024-01-01", "bob@x.com", "body")
206206+ if strings.Contains(strings.ToLower(result), "fwd: fwd:") {
207207+ t.Errorf("got double fwd: prefix:\n%s", result)
208208+ }
209209+ })
210210+211211+ t.Run("to field empty", func(t *testing.T) {
212212+ result := ForwardPrelude("Hello", "", "Alice", "2024-01-01", "bob@x.com", "body")
213213+ if !strings.Contains(result, "# [neomd: to: ]") {
214214+ t.Errorf("to field should be empty, got:\n%s", result)
215215+ }
216216+ })
217217+218218+ t.Run("includes forward header block and body", func(t *testing.T) {
219219+ result := ForwardPrelude("Hello", "", "Alice", "2024-01-01", "bob@x.com", "original text")
220220+ if !strings.Contains(result, "---------- Forwarded message ----------") {
221221+ t.Errorf("missing forward header block, got:\n%s", result)
222222+ }
223223+ if !strings.Contains(result, "From: Alice") {
224224+ t.Errorf("missing From in forward header, got:\n%s", result)
225225+ }
226226+ if !strings.Contains(result, "> original text") {
227227+ t.Errorf("missing quoted original body, got:\n%s", result)
228228+ }
229229+ })
230230+}
231231+232232+func TestPreludeParseHeadersRoundTrip(t *testing.T) {
233233+ tests := []struct {
234234+ name string
235235+ to, cc string
236236+ subject string
237237+ }{
238238+ {
239239+ name: "to and subject only",
240240+ to: "alice@example.com",
241241+ subject: "Round trip test",
242242+ },
243243+ {
244244+ name: "to cc and subject",
245245+ to: "alice@example.com",
246246+ cc: "bob@example.com",
247247+ subject: "With CC",
248248+ },
249249+ }
250250+251251+ for _, tt := range tests {
252252+ t.Run(tt.name, func(t *testing.T) {
253253+ prelude := Prelude(tt.to, tt.cc, tt.subject, "")
254254+ gotTo, gotCC, _, gotSubject, _ := ParseHeaders(prelude)
255255+ if gotTo != tt.to {
256256+ t.Errorf("round-trip to = %q, want %q", gotTo, tt.to)
257257+ }
258258+ if gotCC != tt.cc {
259259+ t.Errorf("round-trip cc = %q, want %q", gotCC, tt.cc)
260260+ }
261261+ if gotSubject != tt.subject {
262262+ t.Errorf("round-trip subject = %q, want %q", gotSubject, tt.subject)
263263+ }
264264+ })
265265+ }
266266+}