A minimal email TUI where you read with Markdown and write in Neovim. neomd.ssp.sh/docs
email markdown neovim tui
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

adding tests to verify most important functions still work (no network tests such as IMAP/SMTP yet)

sspaeti 9b70e032 97877b45

+1998 -2
+2 -1
.claude/settings.local.json
··· 1 1 { 2 2 "permissions": { 3 3 "allow": [ 4 - "Bash(go build:*)" 4 + "Bash(go build:*)", 5 + "Bash(go test:*)" 5 6 ] 6 7 } 7 8 }
+12
.github/workflows/test.yml
··· 1 + name: test 2 + on: [pull_request] 3 + jobs: 4 + test: 5 + runs-on: ubuntu-latest 6 + steps: 7 + - uses: actions/checkout@v4 8 + - uses: actions/setup-go@v5 9 + with: 10 + go-version: '1.24' 11 + - run: go test ./... 12 + - run: go vet ./...
+2 -1
CHANGELOG.md
··· 1 1 # Changelog 2 2 3 - # 2026-04-06 3 + # 2026-04-08 4 4 - **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) 5 5 - **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 6 6 - **`: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 7 7 - **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 8 + - **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 8 9 9 10 # 2026-04-05 10 11 - **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
··· 1 + package config 2 + 3 + import ( 4 + "os" 5 + "path/filepath" 6 + "strings" 7 + "testing" 8 + ) 9 + 10 + func TestExpandEnv(t *testing.T) { 11 + tests := []struct { 12 + name string 13 + input string 14 + envKey string 15 + envVal string 16 + want string 17 + }{ 18 + {"bare $VAR", "$MY_PASS", "MY_PASS", "secret", "secret"}, 19 + {"braced ${VAR}", "${MY_PASS}", "MY_PASS", "secret", "secret"}, 20 + {"embedded dollar", "literal$value", "", "", "literal$value"}, 21 + {"multiple dollars", "pa$$word", "", "", "pa$$word"}, 22 + {"empty string", "", "", "", ""}, 23 + } 24 + for _, tt := range tests { 25 + t.Run(tt.name, func(t *testing.T) { 26 + if tt.envKey != "" { 27 + t.Setenv(tt.envKey, tt.envVal) 28 + } 29 + got := expandEnv(tt.input) 30 + if got != tt.want { 31 + t.Errorf("expandEnv(%q) = %q, want %q", tt.input, got, tt.want) 32 + } 33 + }) 34 + } 35 + } 36 + 37 + func TestExpandPath(t *testing.T) { 38 + home, err := os.UserHomeDir() 39 + if err != nil { 40 + t.Fatalf("cannot determine home dir: %v", err) 41 + } 42 + tests := []struct { 43 + name string 44 + input string 45 + want string 46 + }{ 47 + {"tilde prefix", "~/mail", filepath.Join(home, "mail")}, 48 + {"bare tilde", "~", home}, 49 + {"absolute path", "/absolute/path", "/absolute/path"}, 50 + {"empty", "", ""}, 51 + } 52 + for _, tt := range tests { 53 + t.Run(tt.name, func(t *testing.T) { 54 + got := expandPath(tt.input) 55 + if got != tt.want { 56 + t.Errorf("expandPath(%q) = %q, want %q", tt.input, got, tt.want) 57 + } 58 + }) 59 + } 60 + } 61 + 62 + func TestTabLabels(t *testing.T) { 63 + tests := []struct { 64 + name string 65 + tabOrder []string 66 + wantFirst []string // check these labels appear at the start 67 + }{ 68 + { 69 + "empty tab_order returns defaults", 70 + nil, 71 + []string{"Inbox", "ToScreen", "Feed"}, 72 + }, 73 + { 74 + "custom order", 75 + []string{"inbox", "feed"}, 76 + []string{"Inbox", "Feed"}, 77 + }, 78 + { 79 + "unknown key skipped", 80 + []string{"inbox", "nonexistent", "feed"}, 81 + []string{"Inbox", "Feed"}, 82 + }, 83 + } 84 + for _, tt := range tests { 85 + t.Run(tt.name, func(t *testing.T) { 86 + f := FoldersConfig{TabOrder: tt.tabOrder} 87 + got := f.TabLabels() 88 + if len(got) < len(tt.wantFirst) { 89 + t.Fatalf("got %d labels, want at least %d", len(got), len(tt.wantFirst)) 90 + } 91 + for i, want := range tt.wantFirst { 92 + if got[i] != want { 93 + t.Errorf("TabLabels()[%d] = %q, want %q", i, got[i], want) 94 + } 95 + } 96 + // For the custom order cases the length should match exactly. 97 + if tt.tabOrder != nil { 98 + wantLen := 0 99 + for _, k := range tt.tabOrder { 100 + if _, ok := keyToLabel[k]; ok { 101 + wantLen++ 102 + } 103 + } 104 + if len(got) != wantLen { 105 + t.Errorf("TabLabels() returned %d labels, want %d", len(got), wantLen) 106 + } 107 + } 108 + }) 109 + } 110 + } 111 + 112 + func TestAutoScreen(t *testing.T) { 113 + boolPtr := func(b bool) *bool { return &b } 114 + tests := []struct { 115 + name string 116 + val *bool 117 + want bool 118 + }{ 119 + {"nil defaults to true", nil, true}, 120 + {"explicit false", boolPtr(false), false}, 121 + {"explicit true", boolPtr(true), true}, 122 + } 123 + for _, tt := range tests { 124 + t.Run(tt.name, func(t *testing.T) { 125 + u := UIConfig{AutoScreenOnLoad: tt.val} 126 + if got := u.AutoScreen(); got != tt.want { 127 + t.Errorf("AutoScreen() = %v, want %v", got, tt.want) 128 + } 129 + }) 130 + } 131 + } 132 + 133 + func TestDraftBackups(t *testing.T) { 134 + tests := []struct { 135 + name string 136 + count int 137 + want int 138 + }{ 139 + {"zero defaults to 20", 0, 20}, 140 + {"explicit value", 5, 5}, 141 + {"negative disables", -1, -1}, 142 + } 143 + for _, tt := range tests { 144 + t.Run(tt.name, func(t *testing.T) { 145 + u := UIConfig{DraftBackupCount: tt.count} 146 + if got := u.DraftBackups(); got != tt.want { 147 + t.Errorf("DraftBackups() = %d, want %d", got, tt.want) 148 + } 149 + }) 150 + } 151 + } 152 + 153 + func TestBulkThreshold(t *testing.T) { 154 + tests := []struct { 155 + name string 156 + val int 157 + want int 158 + }{ 159 + {"zero defaults to 10", 0, 10}, 160 + {"explicit value", 5, 5}, 161 + } 162 + for _, tt := range tests { 163 + t.Run(tt.name, func(t *testing.T) { 164 + u := UIConfig{BulkProgressThreshold: tt.val} 165 + if got := u.BulkThreshold(); got != tt.want { 166 + t.Errorf("BulkThreshold() = %d, want %d", got, tt.want) 167 + } 168 + }) 169 + } 170 + } 171 + 172 + func TestLoad_MissingConfigCreatesDefault(t *testing.T) { 173 + dir := t.TempDir() 174 + path := filepath.Join(dir, "neomd", "config.toml") 175 + 176 + _, err := Load(path) 177 + if err == nil { 178 + t.Fatal("expected error from Load with missing config") 179 + } 180 + if !strings.Contains(err.Error(), "please fill in") { 181 + t.Errorf("error = %q, want it to contain %q", err.Error(), "please fill in") 182 + } 183 + if _, statErr := os.Stat(path); os.IsNotExist(statErr) { 184 + t.Errorf("expected default config to be created at %s", path) 185 + } 186 + } 187 + 188 + func TestWriteDefault_FilePermissions(t *testing.T) { 189 + dir := t.TempDir() 190 + path := filepath.Join(dir, "config.toml") 191 + 192 + cfg := defaults() 193 + if err := writeDefault(path, cfg); err != nil { 194 + t.Fatalf("writeDefault() error: %v", err) 195 + } 196 + 197 + info, err := os.Stat(path) 198 + if err != nil { 199 + t.Fatalf("stat error: %v", err) 200 + } 201 + mode := info.Mode().Perm() 202 + if mode != 0600 { 203 + t.Errorf("file mode = %04o, want 0600", mode) 204 + } 205 + } 206 + 207 + func TestExpandEnv_PasswordSafety(t *testing.T) { 208 + // Passwords containing dollar signs must never be mangled. 209 + dangerous := []string{"pa$$word", "s3cr3t$", "$not$a$var"} 210 + for _, pw := range dangerous { 211 + got := expandEnv(pw) 212 + if got != pw { 213 + t.Errorf("expandEnv(%q) = %q — password was mangled!", pw, got) 214 + } 215 + } 216 + } 217 + 218 + func TestLoad_ErrorMessageNoPassword(t *testing.T) { 219 + dir := t.TempDir() 220 + path := filepath.Join(dir, "config.toml") 221 + 222 + // Write syntactically invalid TOML that also contains a password-like string. 223 + password := "SuperSecret123!" 224 + content := `[account] 225 + user = "test@example.com" 226 + password = "` + password + `" 227 + invalid toml here !!! 228 + ` 229 + if err := os.WriteFile(path, []byte(content), 0600); err != nil { 230 + t.Fatalf("write test config: %v", err) 231 + } 232 + 233 + _, err := Load(path) 234 + if err == nil { 235 + t.Fatal("expected error from Load with invalid TOML") 236 + } 237 + if strings.Contains(err.Error(), password) { 238 + t.Errorf("error message contains password %q — potential leak", password) 239 + } 240 + }
+266
internal/editor/editor_test.go
··· 1 + package editor 2 + 3 + import ( 4 + "strings" 5 + "testing" 6 + ) 7 + 8 + func TestParseHeaders(t *testing.T) { 9 + tests := []struct { 10 + name string 11 + input string 12 + wantTo, wantCC, wantBCC, wantSub string 13 + wantBodyContains string // substring the body must contain 14 + wantBodyNotContains string // substring the body must NOT contain 15 + }{ 16 + { 17 + name: "all fields present", 18 + input: "# [neomd: to: alice@example.com]\n" + 19 + "# [neomd: cc: bob@example.com]\n" + 20 + "# [neomd: bcc: secret@example.com]\n" + 21 + "# [neomd: subject: Hello World]\n" + 22 + "\n" + 23 + "Body text here.\n", 24 + wantTo: "alice@example.com", 25 + wantCC: "bob@example.com", 26 + wantBCC: "secret@example.com", 27 + wantSub: "Hello World", 28 + wantBodyContains: "Body text here.", 29 + wantBodyNotContains: "neomd:", 30 + }, 31 + { 32 + name: "missing cc and bcc", 33 + input: "# [neomd: to: alice@example.com]\n" + 34 + "# [neomd: subject: Only To]\n" + 35 + "\n" + 36 + "Some body.\n", 37 + wantTo: "alice@example.com", 38 + wantCC: "", 39 + wantBCC: "", 40 + wantSub: "Only To", 41 + }, 42 + { 43 + name: "body preserved with newlines and markdown", 44 + input: "# [neomd: to: x@y.com]\n" + 45 + "# [neomd: subject: MD test]\n" + 46 + "\n" + 47 + "## Heading\n" + 48 + "\n" + 49 + "- bullet one\n" + 50 + "- bullet two\n" + 51 + "\n" + 52 + "Paragraph with **bold**.\n", 53 + wantTo: "x@y.com", 54 + wantSub: "MD test", 55 + wantBodyContains: "## Heading", 56 + }, 57 + { 58 + name: "no headers at all", 59 + input: "Just plain text\nwith multiple lines.\n", 60 + wantTo: "", 61 + wantCC: "", 62 + wantBCC: "", 63 + wantSub: "", 64 + wantBodyContains: "Just plain text", 65 + wantBodyNotContains: "", 66 + }, 67 + { 68 + name: "case insensitive keys", 69 + input: "# [neomd: To: upper@example.com]\n" + 70 + "# [neomd: Subject: Case Test]\n" + 71 + "\n" + 72 + "body\n", 73 + wantTo: "upper@example.com", 74 + wantSub: "Case Test", 75 + }, 76 + } 77 + 78 + for _, tt := range tests { 79 + t.Run(tt.name, func(t *testing.T) { 80 + to, cc, bcc, subject, body := ParseHeaders(tt.input) 81 + if to != tt.wantTo { 82 + t.Errorf("to = %q, want %q", to, tt.wantTo) 83 + } 84 + if cc != tt.wantCC { 85 + t.Errorf("cc = %q, want %q", cc, tt.wantCC) 86 + } 87 + if bcc != tt.wantBCC { 88 + t.Errorf("bcc = %q, want %q", bcc, tt.wantBCC) 89 + } 90 + if subject != tt.wantSub { 91 + t.Errorf("subject = %q, want %q", subject, tt.wantSub) 92 + } 93 + if tt.wantBodyContains != "" && !strings.Contains(body, tt.wantBodyContains) { 94 + t.Errorf("body missing %q, got:\n%s", tt.wantBodyContains, body) 95 + } 96 + if tt.wantBodyNotContains != "" && strings.Contains(body, tt.wantBodyNotContains) { 97 + t.Errorf("body should not contain %q, got:\n%s", tt.wantBodyNotContains, body) 98 + } 99 + }) 100 + } 101 + } 102 + 103 + func TestPrelude(t *testing.T) { 104 + tests := []struct { 105 + name string 106 + to, cc string 107 + subject string 108 + signature string 109 + wantHas []string // substrings that must appear 110 + wantNot []string // substrings that must NOT appear 111 + }{ 112 + { 113 + name: "basic without cc or sig", 114 + to: "alice@example.com", 115 + subject: "Greetings", 116 + wantHas: []string{ 117 + "# [neomd: to: alice@example.com]", 118 + "# [neomd: subject: Greetings]", 119 + }, 120 + wantNot: []string{"# [neomd: cc:", "-- \n"}, 121 + }, 122 + { 123 + name: "with cc", 124 + to: "alice@example.com", 125 + cc: "bob@example.com", 126 + subject: "Team", 127 + wantHas: []string{ 128 + "# [neomd: to: alice@example.com]", 129 + "# [neomd: cc: bob@example.com]", 130 + "# [neomd: subject: Team]", 131 + }, 132 + }, 133 + { 134 + name: "with signature", 135 + to: "a@b.com", 136 + subject: "Sig test", 137 + signature: "Best,\nAlice", 138 + wantHas: []string{"-- \n", "Best,\nAlice"}, 139 + }, 140 + { 141 + name: "without signature", 142 + to: "a@b.com", 143 + subject: "No sig", 144 + wantNot: []string{"-- \n"}, 145 + }, 146 + } 147 + 148 + for _, tt := range tests { 149 + t.Run(tt.name, func(t *testing.T) { 150 + got := Prelude(tt.to, tt.cc, tt.subject, tt.signature) 151 + for _, want := range tt.wantHas { 152 + if !strings.Contains(got, want) { 153 + t.Errorf("Prelude missing %q, got:\n%s", want, got) 154 + } 155 + } 156 + for _, notWant := range tt.wantNot { 157 + if strings.Contains(got, notWant) { 158 + t.Errorf("Prelude should not contain %q, got:\n%s", notWant, got) 159 + } 160 + } 161 + }) 162 + } 163 + } 164 + 165 + func TestReplyPrelude(t *testing.T) { 166 + result := ReplyPrelude( 167 + "alice@example.com", 168 + "", 169 + "Re: Hello", 170 + "", 171 + "Bob Smith", 172 + "Line one\nLine two", 173 + ) 174 + 175 + // Each original body line must be quoted with "> " prefix. 176 + if !strings.Contains(result, "> Line one") { 177 + t.Errorf("missing quoted line one, got:\n%s", result) 178 + } 179 + if !strings.Contains(result, "> Line two") { 180 + t.Errorf("missing quoted line two, got:\n%s", result) 181 + } 182 + 183 + // Attribution line includes original sender name. 184 + if !strings.Contains(result, "**Bob Smith** wrote:") { 185 + t.Errorf("missing attribution line, got:\n%s", result) 186 + } 187 + } 188 + 189 + func TestForwardPrelude(t *testing.T) { 190 + t.Run("adds Fwd prefix", func(t *testing.T) { 191 + result := ForwardPrelude("Hello", "", "Alice", "2024-01-01", "bob@x.com", "body") 192 + if !strings.Contains(result, "Fwd: Hello") { 193 + t.Errorf("expected Fwd: prefix, got:\n%s", result) 194 + } 195 + }) 196 + 197 + t.Run("no double Fwd prefix", func(t *testing.T) { 198 + result := ForwardPrelude("Fwd: Hello", "", "Alice", "2024-01-01", "bob@x.com", "body") 199 + if strings.Contains(result, "Fwd: Fwd:") { 200 + t.Errorf("got double Fwd: prefix:\n%s", result) 201 + } 202 + }) 203 + 204 + t.Run("case insensitive fwd check", func(t *testing.T) { 205 + result := ForwardPrelude("fwd: Hello", "", "Alice", "2024-01-01", "bob@x.com", "body") 206 + if strings.Contains(strings.ToLower(result), "fwd: fwd:") { 207 + t.Errorf("got double fwd: prefix:\n%s", result) 208 + } 209 + }) 210 + 211 + t.Run("to field empty", func(t *testing.T) { 212 + result := ForwardPrelude("Hello", "", "Alice", "2024-01-01", "bob@x.com", "body") 213 + if !strings.Contains(result, "# [neomd: to: ]") { 214 + t.Errorf("to field should be empty, got:\n%s", result) 215 + } 216 + }) 217 + 218 + t.Run("includes forward header block and body", func(t *testing.T) { 219 + result := ForwardPrelude("Hello", "", "Alice", "2024-01-01", "bob@x.com", "original text") 220 + if !strings.Contains(result, "---------- Forwarded message ----------") { 221 + t.Errorf("missing forward header block, got:\n%s", result) 222 + } 223 + if !strings.Contains(result, "From: Alice") { 224 + t.Errorf("missing From in forward header, got:\n%s", result) 225 + } 226 + if !strings.Contains(result, "> original text") { 227 + t.Errorf("missing quoted original body, got:\n%s", result) 228 + } 229 + }) 230 + } 231 + 232 + func TestPreludeParseHeadersRoundTrip(t *testing.T) { 233 + tests := []struct { 234 + name string 235 + to, cc string 236 + subject string 237 + }{ 238 + { 239 + name: "to and subject only", 240 + to: "alice@example.com", 241 + subject: "Round trip test", 242 + }, 243 + { 244 + name: "to cc and subject", 245 + to: "alice@example.com", 246 + cc: "bob@example.com", 247 + subject: "With CC", 248 + }, 249 + } 250 + 251 + for _, tt := range tests { 252 + t.Run(tt.name, func(t *testing.T) { 253 + prelude := Prelude(tt.to, tt.cc, tt.subject, "") 254 + gotTo, gotCC, _, gotSubject, _ := ParseHeaders(prelude) 255 + if gotTo != tt.to { 256 + t.Errorf("round-trip to = %q, want %q", gotTo, tt.to) 257 + } 258 + if gotCC != tt.cc { 259 + t.Errorf("round-trip cc = %q, want %q", gotCC, tt.cc) 260 + } 261 + if gotSubject != tt.subject { 262 + t.Errorf("round-trip subject = %q, want %q", gotSubject, tt.subject) 263 + } 264 + }) 265 + } 266 + }
+156
internal/imap/client_test.go
··· 1 + package imap 2 + 3 + import ( 4 + "context" 5 + "strings" 6 + "testing" 7 + 8 + imap "github.com/emersion/go-imap/v2" 9 + ) 10 + 11 + func TestBuildSearchCriteria(t *testing.T) { 12 + tests := []struct { 13 + name string 14 + query string 15 + wantKey string // expected Header[0].Key (empty means check Or) 16 + wantValue string // expected Header[0].Value 17 + wantOr bool // expect Or field to be non-empty 18 + }{ 19 + { 20 + name: "from prefix", 21 + query: "from:alice", 22 + wantKey: "From", 23 + wantValue: "alice", 24 + }, 25 + { 26 + name: "subject prefix", 27 + query: "subject:meeting", 28 + wantKey: "Subject", 29 + wantValue: "meeting", 30 + }, 31 + { 32 + name: "to prefix", 33 + query: "to:bob", 34 + wantKey: "To", 35 + wantValue: "bob", 36 + }, 37 + { 38 + name: "plain text uses OR", 39 + query: "hello world", 40 + wantOr: true, 41 + }, 42 + { 43 + name: "case-insensitive prefix preserves value case", 44 + query: "FROM:Alice", 45 + wantKey: "From", 46 + wantValue: "Alice", 47 + }, 48 + } 49 + 50 + for _, tt := range tests { 51 + t.Run(tt.name, func(t *testing.T) { 52 + c := buildSearchCriteria(tt.query) 53 + if tt.wantOr { 54 + if len(c.Or) == 0 { 55 + t.Fatalf("expected Or field to be non-empty for query %q", tt.query) 56 + } 57 + return 58 + } 59 + if len(c.Header) == 0 { 60 + t.Fatalf("expected Header to be non-empty for query %q", tt.query) 61 + } 62 + if c.Header[0].Key != tt.wantKey { 63 + t.Errorf("Header Key = %q, want %q", c.Header[0].Key, tt.wantKey) 64 + } 65 + if c.Header[0].Value != tt.wantValue { 66 + t.Errorf("Header Value = %q, want %q", c.Header[0].Value, tt.wantValue) 67 + } 68 + }) 69 + } 70 + } 71 + 72 + func TestHasAttachment(t *testing.T) { 73 + tests := []struct { 74 + name string 75 + bs imap.BodyStructure 76 + want bool 77 + }{ 78 + { 79 + name: "nil body structure", 80 + bs: nil, 81 + want: false, 82 + }, 83 + { 84 + name: "single part text/plain", 85 + bs: &imap.BodyStructureSinglePart{Type: "text", Subtype: "plain"}, 86 + want: false, 87 + }, 88 + { 89 + name: "single part image/png counts as attachment", 90 + bs: &imap.BodyStructureSinglePart{Type: "image", Subtype: "png"}, 91 + want: true, 92 + }, 93 + { 94 + name: "multipart text/plain + text/html only", 95 + bs: &imap.BodyStructureMultiPart{ 96 + Subtype: "alternative", 97 + Children: []imap.BodyStructure{ 98 + &imap.BodyStructureSinglePart{Type: "text", Subtype: "plain"}, 99 + &imap.BodyStructureSinglePart{Type: "text", Subtype: "html"}, 100 + }, 101 + }, 102 + want: false, 103 + }, 104 + { 105 + name: "multipart with nested image child", 106 + bs: &imap.BodyStructureMultiPart{ 107 + Subtype: "mixed", 108 + Children: []imap.BodyStructure{ 109 + &imap.BodyStructureSinglePart{Type: "text", Subtype: "plain"}, 110 + &imap.BodyStructureSinglePart{Type: "image", Subtype: "jpeg"}, 111 + }, 112 + }, 113 + want: true, 114 + }, 115 + { 116 + name: "single part with attachment disposition", 117 + bs: &imap.BodyStructureSinglePart{ 118 + Type: "application", 119 + Subtype: "pdf", 120 + Extended: &imap.BodyStructureSinglePartExt{ 121 + Disposition: &imap.BodyStructureDisposition{ 122 + Value: "attachment", 123 + }, 124 + }, 125 + }, 126 + want: true, 127 + }, 128 + } 129 + 130 + for _, tt := range tests { 131 + t.Run(tt.name, func(t *testing.T) { 132 + got := hasAttachment(tt.bs) 133 + if got != tt.want { 134 + t.Errorf("hasAttachment() = %v, want %v", got, tt.want) 135 + } 136 + }) 137 + } 138 + } 139 + 140 + func TestConnect_RefusesUnencrypted(t *testing.T) { 141 + c := &Client{ 142 + cfg: Config{ 143 + Host: "localhost", 144 + Port: "143", 145 + TLS: false, 146 + // STARTTLS defaults to false 147 + }, 148 + } 149 + err := c.connect(context.Background()) 150 + if err == nil { 151 + t.Fatal("expected error for unencrypted connection, got nil") 152 + } 153 + if !strings.Contains(err.Error(), "refusing unencrypted") { 154 + t.Errorf("error = %q, want it to contain 'refusing unencrypted'", err.Error()) 155 + } 156 + }
+283
internal/oauth2/oauth2_test.go
··· 1 + package oauth2 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "net/http" 7 + "net/http/httptest" 8 + "os" 9 + "path/filepath" 10 + "testing" 11 + "time" 12 + 13 + "golang.org/x/oauth2" 14 + ) 15 + 16 + // --- XOAUTH2 SASL --- 17 + 18 + func TestXOAuth2Client_Start(t *testing.T) { 19 + c := XOAuth2Client("user@example.com", "ya29.token123") 20 + mech, ir, err := c.Start() 21 + if err != nil { 22 + t.Fatal(err) 23 + } 24 + if mech != "XOAUTH2" { 25 + t.Errorf("mechanism = %q, want XOAUTH2", mech) 26 + } 27 + want := "user=user@example.com\x01auth=Bearer ya29.token123\x01\x01" 28 + if string(ir) != want { 29 + t.Errorf("initial response = %q, want %q", ir, want) 30 + } 31 + } 32 + 33 + func TestXOAuth2Client_Next(t *testing.T) { 34 + c := XOAuth2Client("u", "t") 35 + resp, err := c.(*xoauth2Client).Next([]byte("challenge")) 36 + if err != nil { 37 + t.Fatal(err) 38 + } 39 + if len(resp) != 0 { 40 + t.Errorf("Next should return empty response, got %q", resp) 41 + } 42 + } 43 + 44 + // --- Token persistence --- 45 + 46 + func TestSaveAndLoadToken(t *testing.T) { 47 + dir := t.TempDir() 48 + path := filepath.Join(dir, "tokens", "test.json") 49 + 50 + tok := &oauth2.Token{ 51 + AccessToken: "access123", 52 + RefreshToken: "refresh456", 53 + TokenType: "Bearer", 54 + Expiry: time.Date(2026, 12, 1, 0, 0, 0, 0, time.UTC), 55 + } 56 + 57 + if err := saveToken(path, tok); err != nil { 58 + t.Fatalf("saveToken: %v", err) 59 + } 60 + 61 + loaded, err := loadToken(path) 62 + if err != nil { 63 + t.Fatalf("loadToken: %v", err) 64 + } 65 + if loaded.AccessToken != tok.AccessToken { 66 + t.Errorf("AccessToken = %q, want %q", loaded.AccessToken, tok.AccessToken) 67 + } 68 + if loaded.RefreshToken != tok.RefreshToken { 69 + t.Errorf("RefreshToken = %q, want %q", loaded.RefreshToken, tok.RefreshToken) 70 + } 71 + } 72 + 73 + func TestSaveToken_FilePermissions(t *testing.T) { 74 + dir := t.TempDir() 75 + path := filepath.Join(dir, "tok.json") 76 + 77 + tok := &oauth2.Token{AccessToken: "secret"} 78 + if err := saveToken(path, tok); err != nil { 79 + t.Fatal(err) 80 + } 81 + 82 + info, err := os.Stat(path) 83 + if err != nil { 84 + t.Fatal(err) 85 + } 86 + if mode := info.Mode().Perm(); mode != 0600 { 87 + t.Errorf("token file mode = %o, want 0600", mode) 88 + } 89 + } 90 + 91 + func TestSaveToken_DirectoryPermissions(t *testing.T) { 92 + dir := t.TempDir() 93 + tokenDir := filepath.Join(dir, "newdir") 94 + path := filepath.Join(tokenDir, "tok.json") 95 + 96 + tok := &oauth2.Token{AccessToken: "secret"} 97 + if err := saveToken(path, tok); err != nil { 98 + t.Fatal(err) 99 + } 100 + 101 + info, err := os.Stat(tokenDir) 102 + if err != nil { 103 + t.Fatal(err) 104 + } 105 + if mode := info.Mode().Perm(); mode != 0700 { 106 + t.Errorf("token dir mode = %o, want 0700", mode) 107 + } 108 + } 109 + 110 + func TestLoadToken_MissingFile(t *testing.T) { 111 + _, err := loadToken("/nonexistent/path/token.json") 112 + if err == nil { 113 + t.Fatal("expected error for missing token file") 114 + } 115 + } 116 + 117 + func TestLoadToken_InvalidJSON(t *testing.T) { 118 + dir := t.TempDir() 119 + path := filepath.Join(dir, "bad.json") 120 + os.WriteFile(path, []byte("not json"), 0600) 121 + 122 + _, err := loadToken(path) 123 + if err == nil { 124 + t.Fatal("expected error for invalid JSON") 125 + } 126 + } 127 + 128 + // --- Config helpers --- 129 + 130 + func TestConfig_RedirectPort(t *testing.T) { 131 + tests := []struct { 132 + port int 133 + want int 134 + }{ 135 + {0, 8085}, // default 136 + {9090, 9090}, // explicit 137 + } 138 + for _, tt := range tests { 139 + c := Config{RedirectPort: tt.port} 140 + if got := c.redirectPort(); got != tt.want { 141 + t.Errorf("redirectPort(%d) = %d, want %d", tt.port, got, tt.want) 142 + } 143 + } 144 + } 145 + 146 + func TestConfig_RedirectURL(t *testing.T) { 147 + c := Config{RedirectPort: 8085} 148 + want := "http://localhost:8085/callback" 149 + if got := c.redirectURL(); got != want { 150 + t.Errorf("redirectURL = %q, want %q", got, want) 151 + } 152 + } 153 + 154 + func TestConfig_Timeouts(t *testing.T) { 155 + // Defaults 156 + c := Config{} 157 + if got := c.discoveryTimeout(); got != 10*time.Second { 158 + t.Errorf("default discoveryTimeout = %v, want 10s", got) 159 + } 160 + if got := c.authFlowTimeout(); got != 5*time.Minute { 161 + t.Errorf("default authFlowTimeout = %v, want 5m", got) 162 + } 163 + 164 + // Custom 165 + c = Config{DiscoveryTimeout: 30 * time.Second, AuthFlowTimeout: 10 * time.Minute} 166 + if got := c.discoveryTimeout(); got != 30*time.Second { 167 + t.Errorf("custom discoveryTimeout = %v, want 30s", got) 168 + } 169 + if got := c.authFlowTimeout(); got != 10*time.Minute { 170 + t.Errorf("custom authFlowTimeout = %v, want 10m", got) 171 + } 172 + } 173 + 174 + // --- Endpoint resolution --- 175 + 176 + func TestResolve_ManualURLs(t *testing.T) { 177 + c := Config{ 178 + AuthURL: "https://auth.example.com/authorize", 179 + TokenURL: "https://auth.example.com/token", 180 + } 181 + auth, tok, err := c.resolve(context.Background(), 5*time.Second) 182 + if err != nil { 183 + t.Fatal(err) 184 + } 185 + if auth != c.AuthURL { 186 + t.Errorf("authURL = %q, want %q", auth, c.AuthURL) 187 + } 188 + if tok != c.TokenURL { 189 + t.Errorf("tokenURL = %q, want %q", tok, c.TokenURL) 190 + } 191 + } 192 + 193 + func TestResolve_NoURLsNoIssuer(t *testing.T) { 194 + c := Config{} 195 + _, _, err := c.resolve(context.Background(), 5*time.Second) 196 + if err == nil { 197 + t.Fatal("expected error when no URLs and no issuer") 198 + } 199 + } 200 + 201 + func TestDiscoverEndpoints(t *testing.T) { 202 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 203 + if r.URL.Path != "/.well-known/openid-configuration" { 204 + http.NotFound(w, r) 205 + return 206 + } 207 + json.NewEncoder(w).Encode(map[string]string{ 208 + "authorization_endpoint": "https://provider.example.com/auth", 209 + "token_endpoint": "https://provider.example.com/token", 210 + }) 211 + })) 212 + defer srv.Close() 213 + 214 + auth, tok, err := discoverEndpoints(context.Background(), srv.URL) 215 + if err != nil { 216 + t.Fatal(err) 217 + } 218 + if auth != "https://provider.example.com/auth" { 219 + t.Errorf("authURL = %q", auth) 220 + } 221 + if tok != "https://provider.example.com/token" { 222 + t.Errorf("tokenURL = %q", tok) 223 + } 224 + } 225 + 226 + func TestDiscoverEndpoints_MissingFields(t *testing.T) { 227 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 228 + json.NewEncoder(w).Encode(map[string]string{ 229 + "authorization_endpoint": "https://provider.example.com/auth", 230 + // token_endpoint missing 231 + }) 232 + })) 233 + defer srv.Close() 234 + 235 + _, _, err := discoverEndpoints(context.Background(), srv.URL) 236 + if err == nil { 237 + t.Fatal("expected error for missing token_endpoint") 238 + } 239 + } 240 + 241 + func TestDiscoverEndpoints_BadHTTP(t *testing.T) { 242 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 243 + w.WriteHeader(http.StatusInternalServerError) 244 + })) 245 + defer srv.Close() 246 + 247 + _, _, err := discoverEndpoints(context.Background(), srv.URL) 248 + if err == nil { 249 + t.Fatal("expected error for HTTP 500") 250 + } 251 + } 252 + 253 + // --- Token file security: no sensitive data leaked in error messages --- 254 + 255 + func TestTokenErrors_NoTokenLeak(t *testing.T) { 256 + dir := t.TempDir() 257 + path := filepath.Join(dir, "tok.json") 258 + // Write a token, then corrupt it 259 + os.WriteFile(path, []byte(`{"access_token":"SUPER_SECRET_TOKEN"}`), 0600) 260 + 261 + // Corrupt the file 262 + os.WriteFile(path, []byte("corrupted{"), 0600) 263 + _, err := loadToken(path) 264 + if err == nil { 265 + t.Fatal("expected error") 266 + } 267 + if contains(err.Error(), "SUPER_SECRET_TOKEN") { 268 + t.Error("error message contains token value — credential leak") 269 + } 270 + } 271 + 272 + func contains(s, substr string) bool { 273 + return len(s) >= len(substr) && searchString(s, substr) 274 + } 275 + 276 + func searchString(s, sub string) bool { 277 + for i := 0; i <= len(s)-len(sub); i++ { 278 + if s[i:i+len(sub)] == sub { 279 + return true 280 + } 281 + } 282 + return false 283 + }
+68
internal/render/html_test.go
··· 1 + package render 2 + 3 + import ( 4 + "strings" 5 + "testing" 6 + ) 7 + 8 + func TestToHTML_Bold(t *testing.T) { 9 + out, err := ToHTML("**bold** text") 10 + if err != nil { 11 + t.Fatalf("ToHTML returned error: %v", err) 12 + } 13 + if !strings.Contains(out, "<strong>bold</strong>") { 14 + t.Errorf("expected <strong>bold</strong> in output, got:\n%s", out) 15 + } 16 + } 17 + 18 + func TestToHTML_HTMLWrapper(t *testing.T) { 19 + out, err := ToHTML("hello") 20 + if err != nil { 21 + t.Fatalf("ToHTML returned error: %v", err) 22 + } 23 + if !strings.HasPrefix(out, "<!DOCTYPE html>") { 24 + t.Errorf("expected output to start with <!DOCTYPE html>, got:\n%.80s...", out) 25 + } 26 + if !strings.Contains(out, "<body>") { 27 + t.Errorf("expected <body> in output") 28 + } 29 + } 30 + 31 + func TestToHTML_GFMTable(t *testing.T) { 32 + md := "| A | B |\n|---|---|\n| 1 | 2 |\n" 33 + out, err := ToHTML(md) 34 + if err != nil { 35 + t.Fatalf("ToHTML returned error: %v", err) 36 + } 37 + if !strings.Contains(out, "<table>") { 38 + t.Errorf("expected <table> in output, got:\n%s", out) 39 + } 40 + } 41 + 42 + func TestToHTML_CodeBlock(t *testing.T) { 43 + md := "```go\nfmt.Println(\"hi\")\n```\n" 44 + out, err := ToHTML(md) 45 + if err != nil { 46 + t.Fatalf("ToHTML returned error: %v", err) 47 + } 48 + if !strings.Contains(out, "<pre>") { 49 + t.Errorf("expected <pre> in output, got:\n%s", out) 50 + } 51 + } 52 + 53 + func TestToHTML_Empty(t *testing.T) { 54 + out, err := ToHTML("") 55 + if err != nil { 56 + t.Fatalf("ToHTML returned error for empty input: %v", err) 57 + } 58 + if !strings.HasPrefix(out, "<!DOCTYPE html>") { 59 + t.Errorf("expected DOCTYPE even for empty input, got:\n%.80s...", out) 60 + } 61 + } 62 + 63 + func TestToANSI_Smoke(t *testing.T) { 64 + _, err := ToANSI("# Hello\n\nSome **bold** text.", "dark", 80) 65 + if err != nil { 66 + t.Fatalf("ToANSI returned error: %v", err) 67 + } 68 + }
+393
internal/screener/screener_test.go
··· 1 + package screener 2 + 3 + import ( 4 + "os" 5 + "path/filepath" 6 + "sort" 7 + "testing" 8 + ) 9 + 10 + // --------------------------------------------------------------------------- 11 + // TestClassify — table-driven, uses pre-populated in-memory maps 12 + // --------------------------------------------------------------------------- 13 + 14 + func TestClassify(t *testing.T) { 15 + tests := []struct { 16 + name string 17 + screener *Screener 18 + from string 19 + want Category 20 + }{ 21 + { 22 + name: "spam wins over all other lists", 23 + screener: &Screener{ 24 + screenedIn: map[string]bool{"x@example.com": true}, 25 + screenedOut: map[string]bool{}, 26 + feed: map[string]bool{"x@example.com": true}, 27 + paperTrail: map[string]bool{}, 28 + spam: map[string]bool{"x@example.com": true}, 29 + }, 30 + from: "x@example.com", 31 + want: CategorySpam, 32 + }, 33 + { 34 + name: "screened out beats feed/papertrail/screened in", 35 + screener: &Screener{ 36 + screenedIn: map[string]bool{"a@example.com": true}, 37 + screenedOut: map[string]bool{"a@example.com": true}, 38 + feed: map[string]bool{"a@example.com": true}, 39 + paperTrail: map[string]bool{"a@example.com": true}, 40 + spam: map[string]bool{}, 41 + }, 42 + from: "a@example.com", 43 + want: CategoryScreenedOut, 44 + }, 45 + { 46 + name: "feed beats papertrail and screened in", 47 + screener: &Screener{ 48 + screenedIn: map[string]bool{"b@example.com": true}, 49 + screenedOut: map[string]bool{}, 50 + feed: map[string]bool{"b@example.com": true}, 51 + paperTrail: map[string]bool{"b@example.com": true}, 52 + spam: map[string]bool{}, 53 + }, 54 + from: "b@example.com", 55 + want: CategoryFeed, 56 + }, 57 + { 58 + name: "papertrail beats screened in", 59 + screener: &Screener{ 60 + screenedIn: map[string]bool{"c@example.com": true}, 61 + screenedOut: map[string]bool{}, 62 + feed: map[string]bool{}, 63 + paperTrail: map[string]bool{"c@example.com": true}, 64 + spam: map[string]bool{}, 65 + }, 66 + from: "c@example.com", 67 + want: CategoryPaperTrail, 68 + }, 69 + { 70 + name: "screened in returns CategoryInbox", 71 + screener: &Screener{ 72 + screenedIn: map[string]bool{"d@example.com": true}, 73 + screenedOut: map[string]bool{}, 74 + feed: map[string]bool{}, 75 + paperTrail: map[string]bool{}, 76 + spam: map[string]bool{}, 77 + }, 78 + from: "d@example.com", 79 + want: CategoryInbox, 80 + }, 81 + { 82 + name: "unknown returns CategoryToScreen", 83 + screener: &Screener{ 84 + screenedIn: map[string]bool{}, 85 + screenedOut: map[string]bool{}, 86 + feed: map[string]bool{}, 87 + paperTrail: map[string]bool{}, 88 + spam: map[string]bool{}, 89 + }, 90 + from: "nobody@example.com", 91 + want: CategoryToScreen, 92 + }, 93 + { 94 + name: "normalizes case", 95 + screener: &Screener{ 96 + screenedIn: map[string]bool{"user@example.com": true}, 97 + screenedOut: map[string]bool{}, 98 + feed: map[string]bool{}, 99 + paperTrail: map[string]bool{}, 100 + spam: map[string]bool{}, 101 + }, 102 + from: "USER@EXAMPLE.COM", 103 + want: CategoryInbox, 104 + }, 105 + { 106 + name: "normalizes angle brackets", 107 + screener: &Screener{ 108 + screenedIn: map[string]bool{}, 109 + screenedOut: map[string]bool{}, 110 + feed: map[string]bool{"user@ex.com": true}, 111 + paperTrail: map[string]bool{}, 112 + spam: map[string]bool{}, 113 + }, 114 + from: "Name <user@ex.com>", 115 + want: CategoryFeed, 116 + }, 117 + { 118 + name: "empty from returns ToScreen", 119 + screener: &Screener{ 120 + screenedIn: map[string]bool{}, 121 + screenedOut: map[string]bool{}, 122 + feed: map[string]bool{}, 123 + paperTrail: map[string]bool{}, 124 + spam: map[string]bool{}, 125 + }, 126 + from: "", 127 + want: CategoryToScreen, 128 + }, 129 + } 130 + 131 + for _, tt := range tests { 132 + t.Run(tt.name, func(t *testing.T) { 133 + got := tt.screener.Classify(tt.from) 134 + if got != tt.want { 135 + t.Errorf("Classify(%q) = %v, want %v", tt.from, got, tt.want) 136 + } 137 + }) 138 + } 139 + } 140 + 141 + // --------------------------------------------------------------------------- 142 + // TestFileOperations — uses t.TempDir() for isolation 143 + // --------------------------------------------------------------------------- 144 + 145 + func TestFileOperations(t *testing.T) { 146 + makeCfg := func(dir string) Config { 147 + return Config{ 148 + ScreenedIn: filepath.Join(dir, "screened_in.txt"), 149 + ScreenedOut: filepath.Join(dir, "screened_out.txt"), 150 + Feed: filepath.Join(dir, "feed.txt"), 151 + PaperTrail: filepath.Join(dir, "papertrail.txt"), 152 + Spam: filepath.Join(dir, "spam.txt"), 153 + } 154 + } 155 + 156 + t.Run("New with missing files returns no error", func(t *testing.T) { 157 + dir := t.TempDir() 158 + s, err := New(makeCfg(dir)) 159 + if err != nil { 160 + t.Fatalf("New() returned error: %v", err) 161 + } 162 + if !s.IsEmpty() { 163 + t.Error("expected IsEmpty() = true for fresh screener") 164 + } 165 + }) 166 + 167 + t.Run("New skips comment lines and blank lines", func(t *testing.T) { 168 + dir := t.TempDir() 169 + cfg := makeCfg(dir) 170 + content := "# this is a comment\n\nalice@example.com\n \n# another comment\nbob@example.com\n" 171 + if err := os.WriteFile(cfg.ScreenedIn, []byte(content), 0600); err != nil { 172 + t.Fatal(err) 173 + } 174 + s, err := New(cfg) 175 + if err != nil { 176 + t.Fatalf("New() returned error: %v", err) 177 + } 178 + if s.Classify("alice@example.com") != CategoryInbox { 179 + t.Error("alice should be in Inbox") 180 + } 181 + if s.Classify("bob@example.com") != CategoryInbox { 182 + t.Error("bob should be in Inbox") 183 + } 184 + if !s.IsEmpty() == true { 185 + // 2 entries loaded 186 + } 187 + }) 188 + 189 + t.Run("Approve adds to screened_in removes from screened_out and spam", func(t *testing.T) { 190 + dir := t.TempDir() 191 + cfg := makeCfg(dir) 192 + // Pre-populate screened_out and spam files 193 + os.WriteFile(cfg.ScreenedOut, []byte("victim@example.com\n"), 0600) 194 + os.WriteFile(cfg.Spam, []byte("victim@example.com\n"), 0600) 195 + 196 + s, err := New(cfg) 197 + if err != nil { 198 + t.Fatal(err) 199 + } 200 + if s.Classify("victim@example.com") != CategorySpam { 201 + t.Fatal("should start as spam") 202 + } 203 + if err := s.Approve("victim@example.com"); err != nil { 204 + t.Fatalf("Approve: %v", err) 205 + } 206 + if got := s.Classify("victim@example.com"); got != CategoryInbox { 207 + t.Errorf("after Approve got %v, want Inbox", got) 208 + } 209 + // Verify removed from files 210 + if data, _ := os.ReadFile(cfg.ScreenedOut); len(data) != 0 { 211 + t.Errorf("screened_out should be empty, got %q", data) 212 + } 213 + if data, _ := os.ReadFile(cfg.Spam); len(data) != 0 { 214 + t.Errorf("spam should be empty, got %q", data) 215 + } 216 + }) 217 + 218 + t.Run("Block adds to screened_out removes from screened_in", func(t *testing.T) { 219 + dir := t.TempDir() 220 + cfg := makeCfg(dir) 221 + os.WriteFile(cfg.ScreenedIn, []byte("annoying@example.com\n"), 0600) 222 + 223 + s, err := New(cfg) 224 + if err != nil { 225 + t.Fatal(err) 226 + } 227 + if err := s.Block("annoying@example.com"); err != nil { 228 + t.Fatalf("Block: %v", err) 229 + } 230 + if got := s.Classify("annoying@example.com"); got != CategoryScreenedOut { 231 + t.Errorf("after Block got %v, want ScreenedOut", got) 232 + } 233 + if data, _ := os.ReadFile(cfg.ScreenedIn); len(data) != 0 { 234 + t.Errorf("screened_in should be empty, got %q", data) 235 + } 236 + }) 237 + 238 + t.Run("MarkFeed persists across reload", func(t *testing.T) { 239 + dir := t.TempDir() 240 + cfg := makeCfg(dir) 241 + 242 + s, err := New(cfg) 243 + if err != nil { 244 + t.Fatal(err) 245 + } 246 + if err := s.MarkFeed("news@example.com"); err != nil { 247 + t.Fatal(err) 248 + } 249 + // Reload from files 250 + s2, err := New(cfg) 251 + if err != nil { 252 + t.Fatal(err) 253 + } 254 + if got := s2.Classify("news@example.com"); got != CategoryFeed { 255 + t.Errorf("reloaded Classify = %v, want Feed", got) 256 + } 257 + }) 258 + 259 + t.Run("MarkPaperTrail persists across reload", func(t *testing.T) { 260 + dir := t.TempDir() 261 + cfg := makeCfg(dir) 262 + 263 + s, err := New(cfg) 264 + if err != nil { 265 + t.Fatal(err) 266 + } 267 + if err := s.MarkPaperTrail("receipts@shop.com"); err != nil { 268 + t.Fatal(err) 269 + } 270 + s2, err := New(cfg) 271 + if err != nil { 272 + t.Fatal(err) 273 + } 274 + if got := s2.Classify("receipts@shop.com"); got != CategoryPaperTrail { 275 + t.Errorf("reloaded Classify = %v, want PaperTrail", got) 276 + } 277 + }) 278 + 279 + t.Run("MarkSpam removes from screened_in and screened_out", func(t *testing.T) { 280 + dir := t.TempDir() 281 + cfg := makeCfg(dir) 282 + os.WriteFile(cfg.ScreenedIn, []byte("bad@example.com\n"), 0600) 283 + os.WriteFile(cfg.ScreenedOut, []byte("bad@example.com\n"), 0600) 284 + 285 + s, err := New(cfg) 286 + if err != nil { 287 + t.Fatal(err) 288 + } 289 + if err := s.MarkSpam("bad@example.com"); err != nil { 290 + t.Fatal(err) 291 + } 292 + if got := s.Classify("bad@example.com"); got != CategorySpam { 293 + t.Errorf("after MarkSpam got %v, want Spam", got) 294 + } 295 + if data, _ := os.ReadFile(cfg.ScreenedIn); len(data) != 0 { 296 + t.Errorf("screened_in should be empty, got %q", data) 297 + } 298 + if data, _ := os.ReadFile(cfg.ScreenedOut); len(data) != 0 { 299 + t.Errorf("screened_out should be empty, got %q", data) 300 + } 301 + }) 302 + 303 + t.Run("IsEmpty true when no entries false after add", func(t *testing.T) { 304 + dir := t.TempDir() 305 + cfg := makeCfg(dir) 306 + 307 + s, err := New(cfg) 308 + if err != nil { 309 + t.Fatal(err) 310 + } 311 + if !s.IsEmpty() { 312 + t.Error("should be empty initially") 313 + } 314 + s.Approve("someone@example.com") 315 + if s.IsEmpty() { 316 + t.Error("should not be empty after Approve") 317 + } 318 + }) 319 + 320 + t.Run("AllAddresses deduplicates across lists", func(t *testing.T) { 321 + dir := t.TempDir() 322 + cfg := makeCfg(dir) 323 + // same address in screened_in and feed 324 + os.WriteFile(cfg.ScreenedIn, []byte("dup@example.com\nunique1@example.com\n"), 0600) 325 + os.WriteFile(cfg.Feed, []byte("dup@example.com\nunique2@example.com\n"), 0600) 326 + os.WriteFile(cfg.PaperTrail, []byte("dup@example.com\nunique3@example.com\n"), 0600) 327 + 328 + s, err := New(cfg) 329 + if err != nil { 330 + t.Fatal(err) 331 + } 332 + addrs := s.AllAddresses() 333 + sort.Strings(addrs) 334 + want := []string{"dup@example.com", "unique1@example.com", "unique2@example.com", "unique3@example.com"} 335 + sort.Strings(want) 336 + if len(addrs) != len(want) { 337 + t.Fatalf("AllAddresses len = %d, want %d; got %v", len(addrs), len(want), addrs) 338 + } 339 + for i := range want { 340 + if addrs[i] != want[i] { 341 + t.Errorf("AllAddresses[%d] = %q, want %q", i, addrs[i], want[i]) 342 + } 343 + } 344 + }) 345 + } 346 + 347 + // --------------------------------------------------------------------------- 348 + // Security tests — file permissions 349 + // --------------------------------------------------------------------------- 350 + 351 + func TestFilePermissions(t *testing.T) { 352 + t.Run("appendLine creates files with mode 0600", func(t *testing.T) { 353 + dir := t.TempDir() 354 + path := filepath.Join(dir, "new_list.txt") 355 + 356 + if err := appendLine(path, "test@example.com"); err != nil { 357 + t.Fatal(err) 358 + } 359 + info, err := os.Stat(path) 360 + if err != nil { 361 + t.Fatal(err) 362 + } 363 + if perm := info.Mode().Perm(); perm != 0600 { 364 + t.Errorf("appendLine file perm = %04o, want 0600", perm) 365 + } 366 + }) 367 + 368 + t.Run("removeFromList rewrites with mode 0600", func(t *testing.T) { 369 + dir := t.TempDir() 370 + path := filepath.Join(dir, "rewrite.txt") 371 + // Write initial file with 0600 (the mode screener itself would use) 372 + os.WriteFile(path, []byte("keep@example.com\nremove@example.com\n"), 0600) 373 + 374 + s := &Screener{ 375 + cfg: Config{ScreenedIn: path}, 376 + screenedIn: map[string]bool{"keep@example.com": true, "remove@example.com": true}, 377 + screenedOut: map[string]bool{}, 378 + feed: map[string]bool{}, 379 + paperTrail: map[string]bool{}, 380 + spam: map[string]bool{}, 381 + } 382 + if err := s.removeFromList(path, s.screenedIn, "remove@example.com"); err != nil { 383 + t.Fatal(err) 384 + } 385 + info, err := os.Stat(path) 386 + if err != nil { 387 + t.Fatal(err) 388 + } 389 + if perm := info.Mode().Perm(); perm != 0600 { 390 + t.Errorf("removeFromList file perm = %04o, want 0600", perm) 391 + } 392 + }) 393 + }
+387
internal/smtp/sender_test.go
··· 1 + package smtp 2 + 3 + import ( 4 + "bytes" 5 + "encoding/base64" 6 + "fmt" 7 + "image" 8 + "image/color" 9 + "image/png" 10 + "io" 11 + "mime" 12 + "mime/multipart" 13 + "net/mail" 14 + "os" 15 + "path/filepath" 16 + "strings" 17 + "testing" 18 + 19 + "github.com/sspaeti/neomd/internal/render" 20 + ) 21 + 22 + // parseMIME parses raw message bytes into a mail.Message and its top-level 23 + // media type and params. Fails the test on any error. 24 + func parseMIME(t *testing.T, raw []byte) (*mail.Message, string, map[string]string) { 25 + t.Helper() 26 + msg, err := mail.ReadMessage(bytes.NewReader(raw)) 27 + if err != nil { 28 + t.Fatalf("ReadMessage: %v", err) 29 + } 30 + mediaType, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type")) 31 + if err != nil { 32 + t.Fatalf("ParseMediaType: %v", err) 33 + } 34 + return msg, mediaType, params 35 + } 36 + 37 + // readParts reads all parts from a multipart reader and returns them. 38 + func readParts(t *testing.T, r *multipart.Reader) []*multipart.Part { 39 + t.Helper() 40 + var parts []*multipart.Part 41 + for { 42 + p, err := r.NextPart() 43 + if err == io.EOF { 44 + break 45 + } 46 + if err != nil { 47 + t.Fatalf("NextPart: %v", err) 48 + } 49 + parts = append(parts, p) 50 + } 51 + return parts 52 + } 53 + 54 + // create1x1PNG writes a minimal 1x1 red PNG to path. 55 + func create1x1PNG(t *testing.T, path string) { 56 + t.Helper() 57 + img := image.NewRGBA(image.Rect(0, 0, 1, 1)) 58 + img.Set(0, 0, color.RGBA{R: 255, A: 255}) 59 + f, err := os.Create(path) 60 + if err != nil { 61 + t.Fatalf("create png: %v", err) 62 + } 63 + defer f.Close() 64 + if err := png.Encode(f, img); err != nil { 65 + t.Fatalf("encode png: %v", err) 66 + } 67 + } 68 + 69 + func TestBuildMessage_PlainOnly(t *testing.T) { 70 + raw, err := buildMessage( 71 + "Alice <alice@example.com>", 72 + "Bob <bob@example.com>", 73 + "", 74 + "Hello", 75 + "plain body", 76 + "<p>html body</p>", 77 + nil, 78 + ) 79 + if err != nil { 80 + t.Fatalf("buildMessage: %v", err) 81 + } 82 + 83 + _, mediaType, params := parseMIME(t, raw) 84 + if mediaType != "multipart/alternative" { 85 + t.Fatalf("expected multipart/alternative, got %s", mediaType) 86 + } 87 + 88 + msg, _ := mail.ReadMessage(bytes.NewReader(raw)) 89 + mr := multipart.NewReader(msg.Body, params["boundary"]) 90 + parts := readParts(t, mr) 91 + 92 + if len(parts) != 2 { 93 + t.Fatalf("expected 2 parts, got %d", len(parts)) 94 + } 95 + 96 + ct0, _, _ := mime.ParseMediaType(parts[0].Header.Get("Content-Type")) 97 + ct1, _, _ := mime.ParseMediaType(parts[1].Header.Get("Content-Type")) 98 + 99 + if ct0 != "text/plain" { 100 + t.Errorf("part 0: expected text/plain, got %s", ct0) 101 + } 102 + if ct1 != "text/html" { 103 + t.Errorf("part 1: expected text/html, got %s", ct1) 104 + } 105 + } 106 + 107 + func TestBuildMessage_WithAttachment(t *testing.T) { 108 + dir := t.TempDir() 109 + attPath := filepath.Join(dir, "readme.txt") 110 + if err := os.WriteFile(attPath, []byte("file content"), 0644); err != nil { 111 + t.Fatal(err) 112 + } 113 + 114 + raw, err := buildMessage( 115 + "Alice <alice@example.com>", 116 + "Bob <bob@example.com>", 117 + "", 118 + "With attachment", 119 + "plain body", 120 + "<p>html body</p>", 121 + []string{attPath}, 122 + ) 123 + if err != nil { 124 + t.Fatalf("buildMessage: %v", err) 125 + } 126 + 127 + _, mediaType, params := parseMIME(t, raw) 128 + if mediaType != "multipart/mixed" { 129 + t.Fatalf("expected multipart/mixed, got %s", mediaType) 130 + } 131 + 132 + msg, _ := mail.ReadMessage(bytes.NewReader(raw)) 133 + mr := multipart.NewReader(msg.Body, params["boundary"]) 134 + 135 + // Part 0: multipart/alternative 136 + part0, err := mr.NextPart() 137 + if err != nil { 138 + t.Fatalf("NextPart 0: %v", err) 139 + } 140 + ct0, p0, _ := mime.ParseMediaType(part0.Header.Get("Content-Type")) 141 + if ct0 != "multipart/alternative" { 142 + t.Fatalf("first part: expected multipart/alternative, got %s", ct0) 143 + } 144 + // Read the nested alternative parts before advancing the outer reader. 145 + altMR := multipart.NewReader(part0, p0["boundary"]) 146 + altParts := readParts(t, altMR) 147 + if len(altParts) != 2 { 148 + t.Fatalf("expected 2 alternative parts, got %d", len(altParts)) 149 + } 150 + 151 + // Part 1: the file attachment 152 + part1, err := mr.NextPart() 153 + if err != nil { 154 + t.Fatalf("NextPart 1: %v", err) 155 + } 156 + ct1, _, _ := mime.ParseMediaType(part1.Header.Get("Content-Type")) 157 + if ct1 != "text/plain" { 158 + t.Errorf("attachment content-type: expected text/plain, got %s", ct1) 159 + } 160 + disp := part1.Header.Get("Content-Disposition") 161 + if !strings.Contains(disp, "readme.txt") { 162 + t.Errorf("attachment disposition missing filename: %s", disp) 163 + } 164 + 165 + // Verify attachment content round-trips through base64 166 + attData, _ := io.ReadAll(part1) 167 + decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(attData))) 168 + if err != nil { 169 + t.Fatalf("decode attachment: %v", err) 170 + } 171 + if string(decoded) != "file content" { 172 + t.Errorf("attachment content: got %q, want %q", decoded, "file content") 173 + } 174 + 175 + // Verify no more parts 176 + if _, err := mr.NextPart(); err != io.EOF { 177 + t.Error("expected exactly 2 top-level parts") 178 + } 179 + } 180 + 181 + func TestBuildMessage_WithInlineImage(t *testing.T) { 182 + dir := t.TempDir() 183 + imgPath := filepath.Join(dir, "pixel.png") 184 + create1x1PNG(t, imgPath) 185 + 186 + // Use goldmark to convert markdown containing the image reference. 187 + markdown := fmt.Sprintf("![img](%s)", imgPath) 188 + htmlBody, err := render.ToHTML(markdown) 189 + if err != nil { 190 + t.Fatalf("ToHTML: %v", err) 191 + } 192 + 193 + // Verify goldmark produced an img tag with the local path. 194 + if !strings.Contains(htmlBody, fmt.Sprintf(`src="%s"`, imgPath)) { 195 + t.Fatalf("expected local img src in HTML, got:\n%s", htmlBody) 196 + } 197 + 198 + raw, err := buildMessage( 199 + "Alice <alice@example.com>", 200 + "Bob <bob@example.com>", 201 + "", 202 + "Inline image", 203 + markdown, 204 + htmlBody, 205 + nil, 206 + ) 207 + if err != nil { 208 + t.Fatalf("buildMessage: %v", err) 209 + } 210 + 211 + _, mediaType, params := parseMIME(t, raw) 212 + if mediaType != "multipart/related" { 213 + t.Fatalf("expected multipart/related, got %s", mediaType) 214 + } 215 + 216 + msg, _ := mail.ReadMessage(bytes.NewReader(raw)) 217 + mr := multipart.NewReader(msg.Body, params["boundary"]) 218 + parts := readParts(t, mr) 219 + 220 + if len(parts) != 2 { 221 + t.Fatalf("expected 2 parts (alternative + inline image), got %d", len(parts)) 222 + } 223 + 224 + // First part: multipart/alternative 225 + ct0, _, _ := mime.ParseMediaType(parts[0].Header.Get("Content-Type")) 226 + if ct0 != "multipart/alternative" { 227 + t.Errorf("first part: expected multipart/alternative, got %s", ct0) 228 + } 229 + 230 + // Second part: inline image with Content-ID 231 + ct1, _, _ := mime.ParseMediaType(parts[1].Header.Get("Content-Type")) 232 + if ct1 != "image/png" { 233 + t.Errorf("image part: expected image/png, got %s", ct1) 234 + } 235 + cid := parts[1].Header.Get("Content-Id") 236 + if cid == "" { 237 + t.Error("image part missing Content-ID header") 238 + } 239 + if !strings.Contains(cid, "img0@neomd") { 240 + t.Errorf("unexpected Content-ID: %s", cid) 241 + } 242 + 243 + // Verify the HTML was rewritten from local path to cid: 244 + if strings.Contains(string(raw), fmt.Sprintf(`src="%s"`, imgPath)) { 245 + t.Error("HTML still contains local path instead of cid: reference") 246 + } 247 + if !strings.Contains(string(raw), "cid:img0@neomd") { 248 + t.Error("HTML does not contain expected cid:img0@neomd reference") 249 + } 250 + } 251 + 252 + func TestBuildMessage_Headers(t *testing.T) { 253 + raw, err := buildMessage( 254 + "Alice <alice@example.com>", 255 + "Bob <bob@example.com>", 256 + "", 257 + "Test Subject", 258 + "body", 259 + "<p>body</p>", 260 + nil, 261 + ) 262 + if err != nil { 263 + t.Fatalf("buildMessage: %v", err) 264 + } 265 + 266 + msg, _, _ := parseMIME(t, raw) 267 + 268 + checks := map[string]string{ 269 + "From": "Alice <alice@example.com>", 270 + "To": "Bob <bob@example.com>", 271 + "MIME-Version": "1.0", 272 + "X-Mailer": "neomd", 273 + } 274 + for hdr, want := range checks { 275 + got := msg.Header.Get(hdr) 276 + if got != want { 277 + t.Errorf("header %s: got %q, want %q", hdr, got, want) 278 + } 279 + } 280 + 281 + // Subject is Q-encoded, verify it decodes correctly 282 + subj := msg.Header.Get("Subject") 283 + if subj == "" { 284 + t.Error("Subject header missing") 285 + } 286 + 287 + // Date must be present and non-empty 288 + if msg.Header.Get("Date") == "" { 289 + t.Error("Date header missing") 290 + } 291 + 292 + // Message-ID must be present 293 + if msg.Header.Get("Message-Id") == "" { 294 + t.Error("Message-ID header missing") 295 + } 296 + } 297 + 298 + func TestBuildMessage_CCHeader(t *testing.T) { 299 + raw, err := buildMessage( 300 + "Alice <alice@example.com>", 301 + "Bob <bob@example.com>", 302 + "Carol <carol@example.com>", 303 + "CC test", 304 + "body", 305 + "<p>body</p>", 306 + nil, 307 + ) 308 + if err != nil { 309 + t.Fatalf("buildMessage: %v", err) 310 + } 311 + 312 + msg, _, _ := parseMIME(t, raw) 313 + cc := msg.Header.Get("Cc") 314 + if cc != "Carol <carol@example.com>" { 315 + t.Errorf("Cc header: got %q, want %q", cc, "Carol <carol@example.com>") 316 + } 317 + } 318 + 319 + func TestBuildMessage_NoBccInHeaders(t *testing.T) { 320 + // buildMessage does not accept a bcc parameter, so Bcc should never appear. 321 + raw, err := buildMessage( 322 + "Alice <alice@example.com>", 323 + "Bob <bob@example.com>", 324 + "", 325 + "No BCC", 326 + "body", 327 + "<p>body</p>", 328 + nil, 329 + ) 330 + if err != nil { 331 + t.Fatalf("buildMessage: %v", err) 332 + } 333 + 334 + msg, _, _ := parseMIME(t, raw) 335 + if bcc := msg.Header.Get("Bcc"); bcc != "" { 336 + t.Errorf("Bcc header should be absent, got %q", bcc) 337 + } 338 + 339 + // Also scan raw bytes for any Bcc header line 340 + if strings.Contains(strings.ToLower(string(raw)), "\nbcc:") || 341 + strings.HasPrefix(strings.ToLower(string(raw)), "bcc:") { 342 + t.Error("raw message contains Bcc header line") 343 + } 344 + } 345 + 346 + func TestExtractAddr(t *testing.T) { 347 + tests := []struct { 348 + name string 349 + input string 350 + want string 351 + }{ 352 + { 353 + name: "name and angle brackets", 354 + input: "Alice <alice@example.com>", 355 + want: "alice@example.com", 356 + }, 357 + { 358 + name: "bare address", 359 + input: "alice@example.com", 360 + want: "alice@example.com", 361 + }, 362 + { 363 + name: "empty string", 364 + input: "", 365 + want: "", 366 + }, 367 + { 368 + name: "with leading space", 369 + input: " Bob <bob@test.org>", 370 + want: "bob@test.org", 371 + }, 372 + { 373 + name: "angle brackets no name", 374 + input: "<solo@domain.com>", 375 + want: "solo@domain.com", 376 + }, 377 + } 378 + 379 + for _, tt := range tests { 380 + t.Run(tt.name, func(t *testing.T) { 381 + got := extractAddr(tt.input) 382 + if got != tt.want { 383 + t.Errorf("extractAddr(%q) = %q, want %q", tt.input, got, tt.want) 384 + } 385 + }) 386 + } 387 + }
+130
internal/ui/cmdline_test.go
··· 1 + package ui 2 + 3 + import ( 4 + "strings" 5 + "testing" 6 + 7 + "github.com/sspaeti/neomd/internal/imap" 8 + ) 9 + 10 + func TestMatchCmds_EmptyReturnsAll(t *testing.T) { 11 + got := matchCmds("") 12 + if len(got) != len(cmdRegistry) { 13 + t.Fatalf("matchCmds(\"\") returned %d commands, want %d", len(got), len(cmdRegistry)) 14 + } 15 + } 16 + 17 + func TestMatchCmds_ExactName(t *testing.T) { 18 + got := matchCmds("screen") 19 + names := make([]string, len(got)) 20 + for i, c := range got { 21 + names[i] = c.name 22 + } 23 + if len(got) != 2 { 24 + t.Fatalf("matchCmds(\"screen\") = %v, want [screen, screen-all]", names) 25 + } 26 + // Both "screen" and "screen-all" should match. 27 + found := map[string]bool{} 28 + for _, c := range got { 29 + found[c.name] = true 30 + } 31 + for _, want := range []string{"screen", "screen-all"} { 32 + if !found[want] { 33 + t.Errorf("expected %q in results, got %v", want, names) 34 + } 35 + } 36 + } 37 + 38 + func TestMatchCmds_Alias(t *testing.T) { 39 + got := matchCmds("sa") 40 + if len(got) == 0 { 41 + t.Fatal("matchCmds(\"sa\") returned no matches, want screen-all via alias") 42 + } 43 + found := false 44 + for _, c := range got { 45 + if c.name == "screen-all" { 46 + found = true 47 + break 48 + } 49 + } 50 + if !found { 51 + t.Errorf("expected screen-all in results for alias \"sa\"") 52 + } 53 + } 54 + 55 + func TestMatchCmds_Prefix(t *testing.T) { 56 + got := matchCmds("sc") 57 + if len(got) == 0 { 58 + t.Fatal("matchCmds(\"sc\") returned no matches") 59 + } 60 + for _, c := range got { 61 + if !strings.HasPrefix(c.name, "sc") { 62 + // Check aliases too. 63 + aliasMatch := false 64 + for _, a := range c.aliases { 65 + if strings.HasPrefix(a, "sc") { 66 + aliasMatch = true 67 + break 68 + } 69 + } 70 + if !aliasMatch { 71 + t.Errorf("unexpected match %q for prefix \"sc\"", c.name) 72 + } 73 + } 74 + } 75 + } 76 + 77 + func TestMatchCmds_NoMatch(t *testing.T) { 78 + got := matchCmds("zzz") 79 + if len(got) != 0 { 80 + t.Fatalf("matchCmds(\"zzz\") returned %d matches, want 0", len(got)) 81 + } 82 + } 83 + 84 + func TestMatchCmd_FirstMatch(t *testing.T) { 85 + got := matchCmd("r") 86 + if got == nil { 87 + t.Fatal("matchCmd(\"r\") returned nil, want non-nil") 88 + } 89 + } 90 + 91 + func TestMatchCmd_Empty(t *testing.T) { 92 + got := matchCmd("") 93 + if got != nil { 94 + t.Fatalf("matchCmd(\"\") = %q, want nil", got.name) 95 + } 96 + } 97 + 98 + func TestScreenSummary(t *testing.T) { 99 + moves := []autoScreenMove{ 100 + {email: &imap.Email{UID: 1}, dst: "Archive"}, 101 + {email: &imap.Email{UID: 2}, dst: "Archive"}, 102 + {email: &imap.Email{UID: 3}, dst: "Spam"}, 103 + {email: &imap.Email{UID: 4}, dst: "Archive"}, 104 + {email: &imap.Email{UID: 5}, dst: "Trash"}, 105 + } 106 + got := screenSummary(moves) 107 + 108 + // Should mention total count. 109 + if !strings.Contains(got, "5") { 110 + t.Errorf("summary should contain total count 5, got: %s", got) 111 + } 112 + 113 + // Should mention each destination folder. 114 + for _, folder := range []string{"Archive", "Spam", "Trash"} { 115 + if !strings.Contains(got, folder) { 116 + t.Errorf("summary should mention folder %q, got: %s", folder, got) 117 + } 118 + } 119 + 120 + // Should contain the arrow notation for counts. 121 + if !strings.Contains(got, "3→Archive") { 122 + t.Errorf("summary should contain \"3→Archive\", got: %s", got) 123 + } 124 + if !strings.Contains(got, "1→Spam") { 125 + t.Errorf("summary should contain \"1→Spam\", got: %s", got) 126 + } 127 + if !strings.Contains(got, "1→Trash") { 128 + t.Errorf("summary should contain \"1→Trash\", got: %s", got) 129 + } 130 + }
+59
internal/ui/model_test.go
··· 1 + package ui 2 + 3 + import ( 4 + "strings" 5 + "testing" 6 + ) 7 + 8 + func TestMaskEmail(t *testing.T) { 9 + tests := []struct { 10 + input string 11 + want string 12 + }{ 13 + {"user@example.com", "u***@example.com"}, 14 + {"Name <user@example.com>", "Name <u***@example.com>"}, 15 + {"a@b.com", "a***@b.com"}, 16 + {"", ""}, 17 + {"no-at-sign", "no-at-sign"}, 18 + } 19 + for _, tt := range tests { 20 + t.Run(tt.input, func(t *testing.T) { 21 + got := maskEmail(tt.input) 22 + if got != tt.want { 23 + t.Errorf("maskEmail(%q) = %q, want %q", tt.input, got, tt.want) 24 + } 25 + }) 26 + } 27 + } 28 + 29 + // isURLSchemeAllowed replicates the inline URL scheme check from model.go Update(). 30 + func isURLSchemeAllowed(url string) bool { 31 + lower := strings.ToLower(url) 32 + return strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") 33 + } 34 + 35 + func TestURLSchemeValidation(t *testing.T) { 36 + tests := []struct { 37 + url string 38 + allowed bool 39 + }{ 40 + {"http://example.com", true}, 41 + {"https://example.com", true}, 42 + {"HTTP://EXAMPLE.COM", true}, 43 + {"https://secure.example.com/path?q=1", true}, 44 + {"javascript:alert(1)", false}, 45 + {"ftp://files.example.com", false}, 46 + {"data:text/html,<h1>hi</h1>", false}, 47 + {"", false}, 48 + {"file:///etc/passwd", false}, 49 + {"mailto:user@example.com", false}, 50 + } 51 + for _, tt := range tests { 52 + t.Run(tt.url, func(t *testing.T) { 53 + got := isURLSchemeAllowed(tt.url) 54 + if got != tt.allowed { 55 + t.Errorf("isURLSchemeAllowed(%q) = %v, want %v", tt.url, got, tt.allowed) 56 + } 57 + }) 58 + } 59 + }