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.

Squash merge stability-2-recover-charset into main

+465 -23
+6
CHANGELOG.md
··· 8 8 - **Browser view sanitization** — pressing `O` to open email in browser now injects a Content-Security-Policy that blocks JavaScript, iframes, and embedded objects (`script-src 'none'; frame-src 'none'; object-src 'none'`) while allowing remote images 9 9 - **Reader space chord hints** — pressing `space` in the reader now shows all available actions (`1-0 links`, `d download .eml`, `l11-99 links 11+`) instead of only link info; `space+d` for EML download now works even when no links are present 10 10 - **Colored attachments in reader** — attachment filenames in the reader header are now rendered in waveAqua2 color instead of dim gray for better visibility 11 + - **Panic recovery** — all background goroutines (mark-as-read, spy pixel cache, temp file cleanup) are now wrapped with `safeGo()` which recovers panics instead of crashing the TUI; panics are logged to `~/.cache/neomd/crash.log` with timestamp and full stack trace for post-mortem debugging 12 + - **IMAP connection health check** — after 2+ minutes of inactivity (e.g. laptop suspend/resume), neomd probes the connection with IMAP NOOP before the next operation; if the connection is dead, it automatically reconnects — no more manual `R` refresh needed after sleep 13 + - **IMAP retry for read-only operations** — read-only IMAP commands (FETCH, SEARCH, STATUS) automatically retry once after reconnecting on network error; mutating operations (MOVE, APPEND, STORE) are NOT retried to prevent duplicate emails or replayed mutations 14 + - **MIME charset/encoding fallback** — emails with unknown charsets (ISO-8859-15, Windows-1256) or unknown transfer encodings no longer fail; neomd continues with raw bytes instead of crashing, matching aerc's graceful degradation pattern 15 + - **Config validation** — config is now validated on load: IMAP/SMTP addresses checked for valid `host:port` format with port range 1-65535, required fields enforced, UI values checked for non-negative ranges; clear error messages instead of silent failures 16 + - **Integration tests for security features** — new `TestIntegration_SecurityFeatures` (disguised attachment + callout) and `TestIntegration_BrowserSanitization` (CSP script/iframe blocking) send real test emails for live inspection 11 17 12 18 # 2026-04-27 13 19 - **Mailto handler (`--mailto` / positional URI)** — neomd can now be used as the system default `mailto:` handler; clicking a `mailto:` link in any browser opens a foot terminal with neomd in compose mode, pre-filled with To, CC, BCC, Subject, and Body from the URI; supports both `neomd --mailto "mailto:user@example.com?subject=Hello"` and `neomd "mailto:..."` (positional, for `.desktop` integration); registered via `xdg-mime` with a `neomd-mailto.desktop` file; after sending or cancelling, neomd continues as normal
+82
internal/config/config.go
··· 3 3 4 4 import ( 5 5 "fmt" 6 + "net" 6 7 "os" 7 8 "path/filepath" 8 9 "runtime" 10 + "strconv" 9 11 "strings" 10 12 11 13 "github.com/BurntSushi/toml" ··· 271 273 return p 272 274 } 273 275 276 + // CrashLogPath returns the path for the crash log file. 277 + func CrashLogPath() string { 278 + if dir, err := os.UserCacheDir(); err == nil { 279 + p := filepath.Join(dir, cacheDirName) 280 + _ = os.MkdirAll(p, 0700) 281 + return filepath.Join(p, "crash.log") 282 + } 283 + return filepath.Join(os.TempDir(), fmt.Sprintf("neomd_%d_crash.log", os.Getuid())) 284 + } 285 + 274 286 // SpyPixelCachePath returns the path for the spy pixel cache file. 275 287 func SpyPixelCachePath() string { 276 288 if dir, err := os.UserCacheDir(); err == nil { ··· 358 370 359 371 cfg.Listmonk.APIToken = expandEnv(cfg.Listmonk.APIToken) 360 372 373 + if err := cfg.validate(); err != nil { 374 + return nil, fmt.Errorf("config validation: %w", err) 375 + } 376 + 361 377 return cfg, nil 378 + } 379 + 380 + // validate checks config values for common mistakes. 381 + func (cfg *Config) validate() error { 382 + if len(cfg.Accounts) == 0 && cfg.Account.IMAP == "" { 383 + return fmt.Errorf("no accounts configured — add at least one [[accounts]] section") 384 + } 385 + for i, a := range cfg.Accounts { 386 + label := a.Name 387 + if label == "" { 388 + label = fmt.Sprintf("accounts[%d]", i) 389 + } 390 + if a.IMAP == "" { 391 + return fmt.Errorf("account %q: imap address is required", label) 392 + } 393 + if a.SMTP == "" { 394 + return fmt.Errorf("account %q: smtp address is required", label) 395 + } 396 + if err := validateHostPort(a.IMAP, label, "imap"); err != nil { 397 + return err 398 + } 399 + if err := validateHostPort(a.SMTP, label, "smtp"); err != nil { 400 + return err 401 + } 402 + if a.User == "" && !a.IsOAuth2() { 403 + return fmt.Errorf("account %q: user is required", label) 404 + } 405 + } 406 + // Validate legacy single-account fields if used 407 + if cfg.Account.IMAP != "" { 408 + if err := validateHostPort(cfg.Account.IMAP, "account", "imap"); err != nil { 409 + return err 410 + } 411 + if cfg.Account.SMTP != "" { 412 + if err := validateHostPort(cfg.Account.SMTP, "account", "smtp"); err != nil { 413 + return err 414 + } 415 + } 416 + } 417 + // Validate UI settings 418 + if cfg.UI.InboxCount < 0 { 419 + return fmt.Errorf("ui.inbox_count must be >= 0, got %d", cfg.UI.InboxCount) 420 + } 421 + if cfg.UI.BgSyncInterval < 0 { 422 + return fmt.Errorf("ui.bg_sync_interval must be >= 0, got %d", cfg.UI.BgSyncInterval) 423 + } 424 + if cfg.UI.MarkAsReadAfterSecs < 0 { 425 + return fmt.Errorf("ui.mark_as_read_after_secs must be >= 0, got %d", cfg.UI.MarkAsReadAfterSecs) 426 + } 427 + return nil 428 + } 429 + 430 + // validateHostPort checks that an address is in host:port format with a valid port. 431 + func validateHostPort(addr, account, field string) error { 432 + host, portStr, err := net.SplitHostPort(addr) 433 + if err != nil { 434 + return fmt.Errorf("account %q: %s %q is not valid host:port — %w", account, field, addr, err) 435 + } 436 + if host == "" { 437 + return fmt.Errorf("account %q: %s host is empty in %q", account, field, addr) 438 + } 439 + port, err := strconv.Atoi(portStr) 440 + if err != nil || port < 1 || port > 65535 { 441 + return fmt.Errorf("account %q: %s port %q is not a valid port (1-65535)", account, field, portStr) 442 + } 443 + return nil 362 444 } 363 445 364 446 func defaults() *Config {
+84
internal/config/config_test.go
··· 245 245 t.Errorf("error message contains password %q — potential leak", password) 246 246 } 247 247 } 248 + 249 + func TestValidateHostPort(t *testing.T) { 250 + tests := []struct { 251 + addr string 252 + wantErr bool 253 + }{ 254 + {"imap.example.com:993", false}, 255 + {"localhost:143", false}, 256 + {"127.0.0.1:1143", false}, 257 + {"mail.example.com:65535", false}, 258 + // Invalid 259 + {"imap.example.com", true}, // no port 260 + {":993", true}, // no host 261 + {"imap.example.com:0", true}, // port 0 262 + {"imap.example.com:99999", true}, // port > 65535 263 + {"imap.example.com:abc", true}, // non-numeric port 264 + {"", true}, // empty 265 + } 266 + for _, tt := range tests { 267 + t.Run(tt.addr, func(t *testing.T) { 268 + err := validateHostPort(tt.addr, "test", "imap") 269 + if (err != nil) != tt.wantErr { 270 + t.Errorf("validateHostPort(%q) error = %v, wantErr %v", tt.addr, err, tt.wantErr) 271 + } 272 + }) 273 + } 274 + } 275 + 276 + func TestValidate_MissingAccount(t *testing.T) { 277 + cfg := &Config{} 278 + err := cfg.validate() 279 + if err == nil { 280 + t.Error("expected error for config with no accounts") 281 + } 282 + } 283 + 284 + func TestValidate_InvalidPort(t *testing.T) { 285 + cfg := &Config{ 286 + Accounts: []AccountConfig{{ 287 + Name: "Test", 288 + IMAP: "imap.example.com:99999", 289 + SMTP: "smtp.example.com:587", 290 + User: "test@example.com", 291 + }}, 292 + } 293 + err := cfg.validate() 294 + if err == nil { 295 + t.Error("expected error for invalid IMAP port") 296 + } 297 + if !strings.Contains(err.Error(), "port") { 298 + t.Errorf("error should mention port, got: %v", err) 299 + } 300 + } 301 + 302 + func TestValidate_ValidConfig(t *testing.T) { 303 + cfg := &Config{ 304 + Accounts: []AccountConfig{{ 305 + Name: "Test", 306 + IMAP: "imap.example.com:993", 307 + SMTP: "smtp.example.com:587", 308 + User: "test@example.com", 309 + }}, 310 + } 311 + err := cfg.validate() 312 + if err != nil { 313 + t.Errorf("valid config should not error, got: %v", err) 314 + } 315 + } 316 + 317 + func TestValidate_NegativeUIValues(t *testing.T) { 318 + cfg := &Config{ 319 + Accounts: []AccountConfig{{ 320 + Name: "Test", 321 + IMAP: "imap.example.com:993", 322 + SMTP: "smtp.example.com:587", 323 + User: "test@example.com", 324 + }}, 325 + UI: UIConfig{InboxCount: -1}, 326 + } 327 + err := cfg.validate() 328 + if err == nil { 329 + t.Error("expected error for negative inbox_count") 330 + } 331 + }
+72 -15
internal/imap/client.go
··· 72 72 mu sync.Mutex 73 73 conn *imapclient.Client 74 74 selectedMailbox string 75 + lastActivity time.Time // tracks last successful operation for health checks 75 76 } 76 77 77 78 // New creates a new IMAP client (does not connect yet). ··· 157 158 return c.connect(ctx) 158 159 } 159 160 161 + // withConn runs fn on the IMAP connection, reconnecting if needed. 162 + // Does NOT retry on network errors — safe for mutating operations (APPEND, MOVE, STORE). 160 163 func (c *Client) withConn(ctx context.Context, fn func(*imapclient.Client) error) error { 164 + return c.withConnRetryable(ctx, fn, false) 165 + } 166 + 167 + // withConnRetry runs fn on the IMAP connection with one automatic retry on network error. 168 + // Only safe for idempotent/read-only operations (FETCH, SEARCH, SELECT, NOOP). 169 + func (c *Client) withConnRetry(ctx context.Context, fn func(*imapclient.Client) error) error { 170 + return c.withConnRetryable(ctx, fn, true) 171 + } 172 + 173 + func (c *Client) withConnRetryable(ctx context.Context, fn func(*imapclient.Client) error, retry bool) error { 161 174 c.mu.Lock() 162 175 defer c.mu.Unlock() 163 176 if err := c.connect(ctx); err != nil { 164 177 return err 165 178 } 179 + // After 2+ minutes of inactivity (e.g. laptop suspend/resume), 180 + // probe the connection with NOOP before running the real operation. 181 + if !c.lastActivity.IsZero() && time.Since(c.lastActivity) > 2*time.Minute { 182 + if err := c.conn.Noop().Wait(); err != nil { 183 + _ = c.conn.Close() 184 + c.conn = nil 185 + c.selectedMailbox = "" 186 + if err := c.connect(ctx); err != nil { 187 + return err 188 + } 189 + } 190 + } 166 191 if err := fn(c.conn); err != nil { 167 192 if isNetErr(err) { 168 193 _ = c.conn.Close() 169 194 c.conn = nil 170 195 c.selectedMailbox = "" 196 + if retry { 197 + time.Sleep(1 * time.Second) 198 + if err := c.connect(ctx); err != nil { 199 + return err 200 + } 201 + if err := fn(c.conn); err != nil { 202 + if isNetErr(err) { 203 + _ = c.conn.Close() 204 + c.conn = nil 205 + c.selectedMailbox = "" 206 + } 207 + return err 208 + } 209 + c.lastActivity = time.Now() 210 + return nil 211 + } 171 212 } 172 213 return err 173 214 } 215 + c.lastActivity = time.Now() 174 216 return nil 175 217 } 176 218 ··· 220 262 if ctx == nil { 221 263 ctx = context.Background() 222 264 } 223 - return c.withConn(ctx, func(conn *imapclient.Client) error { 265 + return c.withConnRetry(ctx, func(conn *imapclient.Client) error { 224 266 return conn.Noop().Wait() 225 267 }) 226 268 } ··· 231 273 ctx = context.Background() 232 274 } 233 275 var emails []Email 234 - err := c.withConn(ctx, func(conn *imapclient.Client) error { 276 + err := c.withConnRetry(ctx, func(conn *imapclient.Client) error { 235 277 emails = nil // reset on retry to avoid duplicates 236 278 if err := c.selectMailbox(folder); err != nil { 237 279 return err ··· 354 396 ctx = context.Background() 355 397 } 356 398 var uids []uint32 357 - err := c.withConn(ctx, func(conn *imapclient.Client) error { 399 + err := c.withConnRetry(ctx, func(conn *imapclient.Client) error { 358 400 uids = nil // reset on retry 359 401 if err := c.selectMailbox(folder); err != nil { 360 402 return err ··· 384 426 if ctx == nil { 385 427 ctx = context.Background() 386 428 } 387 - counts := make(map[string]int, len(folders)) 388 - err := c.withConn(ctx, func(conn *imapclient.Client) error { 429 + var counts map[string]int 430 + err := c.withConnRetry(ctx, func(conn *imapclient.Client) error { 431 + counts = make(map[string]int, len(folders)) // reset on retry 389 432 for label, mailbox := range folders { 390 433 data, err := conn.Status(mailbox, &imap.StatusOptions{NumUnseen: true}).Wait() 391 434 if err != nil { 435 + if isNetErr(err) { 436 + return err // let withConnRetry reconnect 437 + } 392 438 continue // folder may not exist; skip 393 439 } 394 440 if data.NumUnseen != nil { ··· 441 487 criteria := buildSearchCriteria(query) 442 488 443 489 var uids []uint32 444 - err := c.withConn(ctx, func(conn *imapclient.Client) error { 490 + err := c.withConnRetry(ctx, func(conn *imapclient.Client) error { 445 491 uids = nil // reset on retry 446 492 if err := c.selectMailbox(folder); err != nil { 447 493 return err ··· 652 698 return nil, nil 653 699 } 654 700 var emails []Email 655 - err := c.withConn(ctx, func(conn *imapclient.Client) error { 701 + err := c.withConnRetry(ctx, func(conn *imapclient.Client) error { 656 702 emails = nil // reset on retry 657 703 if err := c.selectMailbox(folder); err != nil { 658 704 return err ··· 732 778 var markdown, rawHTML, webURL, references string 733 779 var attachments []Attachment 734 780 var spyPixels SpyPixelInfo 735 - err := c.withConn(ctx, func(conn *imapclient.Client) error { 781 + err := c.withConnRetry(ctx, func(conn *imapclient.Client) error { 736 782 if err := c.selectMailbox(folder); err != nil { 737 783 return err 738 784 } ··· 766 812 ctx = context.Background() 767 813 } 768 814 var spy SpyPixelInfo 769 - err := c.withConn(ctx, func(conn *imapclient.Client) error { 815 + err := c.withConnRetry(ctx, func(conn *imapclient.Client) error { 770 816 if err := c.selectMailbox(folder); err != nil { 771 817 return err 772 818 } ··· 795 841 // extractHTMLPart pulls just the text/html content from raw MIME bytes. 796 842 func extractHTMLPart(raw []byte) string { 797 843 e, err := message.Read(bytes.NewReader(raw)) 798 - if err != nil && !message.IsUnknownCharset(err) { 844 + if err != nil && !message.IsUnknownCharset(err) && !message.IsUnknownEncoding(err) { 799 845 return "" 800 846 } 801 847 mr := mail.NewReader(e) 802 848 for { 803 849 p, err := mr.NextPart() 850 + if err == io.EOF { 851 + break 852 + } 804 853 if err != nil { 805 - break 854 + if !message.IsUnknownCharset(err) && !message.IsUnknownEncoding(err) { 855 + break 856 + } 857 + if p == nil { 858 + continue 859 + } 806 860 } 807 861 if h, ok := p.Header.(*mail.InlineHeader); ok { 808 862 ct, _, _ := h.ContentType() ··· 821 875 ctx = context.Background() 822 876 } 823 877 var raw []byte 824 - err := c.withConn(ctx, func(conn *imapclient.Client) error { 878 + err := c.withConnRetry(ctx, func(conn *imapclient.Client) error { 825 879 if err := c.selectMailbox(folder); err != nil { 826 880 return err 827 881 } ··· 915 969 if err != nil { 916 970 var imapErr *imap.Error 917 971 if errors.As(err, &imapErr) && imapErr.Code == imap.ResponseCodeAlreadyExists { 918 - continue // already there, nothing to do 972 + // Folder exists — still ensure it's subscribed 973 + _ = conn.Subscribe(folder).Wait() 974 + continue 919 975 } 920 976 return fmt.Errorf("CREATE %s: %w", folder, err) 921 977 } ··· 1073 1129 // preamble (e.g. Substack's "View this post on the web at https://…") 1074 1130 func parseBody(raw []byte) (markdown, rawHTML, webURL string, attachments []Attachment, references string, spyPixels SpyPixelInfo) { 1075 1131 e, err := message.Read(bytes.NewReader(raw)) 1076 - if err != nil && !message.IsUnknownCharset(err) { 1132 + if err != nil && !message.IsUnknownCharset(err) && !message.IsUnknownEncoding(err) { 1077 1133 return string(raw), "", "", nil, "", SpyPixelInfo{} 1078 1134 } 1079 1135 ··· 1107 1163 break 1108 1164 } 1109 1165 if err != nil { 1110 - if !message.IsUnknownCharset(err) { 1166 + if !message.IsUnknownCharset(err) && !message.IsUnknownEncoding(err) { 1111 1167 break 1112 1168 } 1169 + // Unknown charset/encoding — continue with raw bytes rather than failing 1113 1170 if p == nil { 1114 1171 continue 1115 1172 }
+91
internal/imap/client_test.go
··· 4 4 "context" 5 5 "strings" 6 6 "testing" 7 + "time" 7 8 8 9 imap "github.com/emersion/go-imap/v2" 9 10 ) ··· 480 481 481 482 if spy.Count != 0 { 482 483 t.Errorf("plain-text email SpyPixelInfo.Count = %d, want 0", spy.Count) 484 + } 485 + } 486 + 487 + func TestParseBody_UnknownCharset(t *testing.T) { 488 + // Emails with unknown charsets should not fail — they should render 489 + // with raw bytes rather than crashing. This is common with legacy 490 + // encodings (ISO-8859-15, Windows-1256, etc.). 491 + raw := "MIME-Version: 1.0\r\n" + 492 + "Content-Type: text/plain; charset=x-unknown-charset-999\r\n" + 493 + "Content-Transfer-Encoding: 7bit\r\n" + 494 + "\r\n" + 495 + "This email uses an unknown charset but should still be readable." 496 + 497 + body, _, _, _, _, _ := parseBody([]byte(raw)) 498 + 499 + if body == "" { 500 + t.Error("parseBody returned empty body for unknown charset — should fall back to raw bytes") 501 + } 502 + if !strings.Contains(body, "unknown charset") { 503 + t.Errorf("expected body to contain raw text, got: %q", body) 504 + } 505 + } 506 + 507 + func TestParseBody_UnknownEncoding(t *testing.T) { 508 + // Emails with unknown transfer encodings should degrade gracefully. 509 + raw := "MIME-Version: 1.0\r\n" + 510 + "Content-Type: text/plain; charset=utf-8\r\n" + 511 + "Content-Transfer-Encoding: x-uuencode\r\n" + 512 + "\r\n" + 513 + "This email uses an unusual encoding." 514 + 515 + body, _, _, _, _, _ := parseBody([]byte(raw)) 516 + 517 + // Should not panic or return empty — may return raw bytes or partial content 518 + if body == "" { 519 + t.Error("parseBody returned empty body for unknown encoding — should not crash") 520 + } 521 + } 522 + 523 + func TestParseBody_MultipartUnknownCharset(t *testing.T) { 524 + // Multipart email where one part has an unknown charset. 525 + // The other part should still be parsed correctly. 526 + boundary := "test-boundary-charset" 527 + raw := "MIME-Version: 1.0\r\n" + 528 + "Content-Type: multipart/alternative; boundary=" + boundary + "\r\n" + 529 + "\r\n" + 530 + "--" + boundary + "\r\n" + 531 + "Content-Type: text/plain; charset=x-fake-charset\r\n" + 532 + "\r\n" + 533 + "Plain text with unknown charset\r\n" + 534 + "--" + boundary + "\r\n" + 535 + "Content-Type: text/html; charset=utf-8\r\n" + 536 + "\r\n" + 537 + "<html><body><p>HTML part is fine</p></body></html>\r\n" + 538 + "--" + boundary + "--\r\n" 539 + 540 + body, _, _, _, _, _ := parseBody([]byte(raw)) 541 + 542 + if body == "" { 543 + t.Error("parseBody returned empty body for multipart with unknown charset") 544 + } 545 + } 546 + 547 + func TestConnectionHealthCheck_LastActivity(t *testing.T) { 548 + // Verify that lastActivity is tracked by the Client struct. 549 + // We can't test the actual NOOP probe without a real IMAP server, 550 + // but we can verify the field exists and the logic is wired up. 551 + c := &Client{ 552 + cfg: Config{ 553 + Host: "imap.example.com", 554 + Port: "993", 555 + TLS: true, 556 + }, 557 + } 558 + 559 + // Initially zero — first withConn should not trigger NOOP 560 + if !c.lastActivity.IsZero() { 561 + t.Error("lastActivity should be zero on new Client") 562 + } 563 + 564 + // After setting lastActivity to recent, NOOP should not trigger 565 + c.lastActivity = time.Now() 566 + if time.Since(c.lastActivity) > 2*time.Minute { 567 + t.Error("recent lastActivity should not trigger health check") 568 + } 569 + 570 + // After setting lastActivity to 3 minutes ago, NOOP should trigger 571 + c.lastActivity = time.Now().Add(-3 * time.Minute) 572 + if time.Since(c.lastActivity) <= 2*time.Minute { 573 + t.Error("stale lastActivity (3min ago) should trigger health check") 483 574 } 484 575 } 485 576
+36 -8
internal/ui/model.go
··· 3 3 4 4 import ( 5 5 "fmt" 6 + "log" 6 7 "net/http" 7 8 "os" 8 9 "os/exec" 9 10 "path/filepath" 10 11 "regexp" 12 + "runtime/debug" 11 13 "sort" 12 14 "strconv" 13 15 "strings" ··· 1811 1813 } 1812 1814 if !m.spyScannedKeys[key] { 1813 1815 m.spyScannedKeys[key] = true 1814 - go saveSpyPixelCache(copyMap(m.spyPixelKeys), copyMap(m.spyScannedKeys)) 1816 + safeGo(func() { saveSpyPixelCache(copyMap(m.spyPixelKeys), copyMap(m.spyScannedKeys)) }) 1815 1817 } 1816 1818 } 1817 1819 // Store References header in the email struct for threading ··· 1822 1824 uid := msg.email.UID 1823 1825 folder := msg.email.Folder 1824 1826 markImmediately := func() { 1825 - go func() { _ = m.imapCli().MarkSeen(nil, folder, uid) }() 1827 + safeGo(func() { _ = m.imapCli().MarkSeen(nil, folder, uid) }) 1826 1828 // Update local state immediately 1827 1829 for i := range m.emails { 1828 1830 if m.emails[i].UID == uid && m.emails[i].Folder == folder { ··· 1971 1973 // Timer fired - mark email as read if user is still viewing it 1972 1974 if m.state == stateReading && m.markAsReadUID == msg.uid && m.markAsReadFolder == msg.folder { 1973 1975 // Still viewing the same email - mark it as read 1974 - go func() { _ = m.imapCli().MarkSeen(nil, msg.folder, msg.uid) }() 1976 + safeGo(func() { _ = m.imapCli().MarkSeen(nil, msg.folder, msg.uid) }) 1975 1977 // Update local state immediately 1976 1978 for i := range m.emails { 1977 1979 if m.emails[i].UID == msg.uid && m.emails[i].Folder == msg.folder { ··· 2067 2069 } 2068 2070 // Save cache and rebuild inbox on the main goroutine. 2069 2071 if len(msg.scannedKeys) > 0 { 2070 - go saveSpyPixelCache(copyMap(m.spyPixelKeys), copyMap(m.spyScannedKeys)) 2072 + safeGo(func() { saveSpyPixelCache(copyMap(m.spyPixelKeys), copyMap(m.spyScannedKeys)) }) 2071 2073 } 2072 2074 return m, m.applyFilter() 2073 2075 ··· 2933 2935 _ = os.WriteFile(config.SpyPixelCachePath(), []byte(strings.Join(lines, "\n")+"\n"), 0600) 2934 2936 } 2935 2937 2938 + // safeGo runs fn in a goroutine with panic recovery. If the goroutine panics, 2939 + // the stack trace is logged to stderr and written to ~/.cache/neomd/crash.log. 2940 + func safeGo(fn func()) { 2941 + go func() { 2942 + defer func() { 2943 + if r := recover(); r != nil { 2944 + stack := debug.Stack() 2945 + log.Printf("goroutine panic recovered: %v\n%s", r, stack) 2946 + writeCrashLog(r, stack) 2947 + } 2948 + }() 2949 + fn() 2950 + }() 2951 + } 2952 + 2953 + // writeCrashLog appends a panic record to the crash log file. 2954 + func writeCrashLog(r interface{}, stack []byte) { 2955 + path := config.CrashLogPath() 2956 + f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600) 2957 + if err != nil { 2958 + return 2959 + } 2960 + defer f.Close() 2961 + fmt.Fprintf(f, "=== neomd crash at %s ===\npanic: %v\n%s\n\n", time.Now().Format(time.RFC3339), r, stack) 2962 + } 2963 + 2936 2964 // copyMap returns a shallow copy of a map, safe for passing to goroutines. 2937 2965 func copyMap(m map[string]bool) map[string]bool { 2938 2966 c := make(map[string]bool, len(m)) ··· 3386 3414 // xdg-open exits immediately after handing off to the browser process, 3387 3415 // so cmd.Wait() returns before the browser has read the file. 3388 3416 // Sleep long enough for any browser to finish loading from disk. 3389 - go func() { 3417 + safeGo(func() { 3390 3418 time.Sleep(15 * time.Second) 3391 3419 os.Remove(tmpPath) 3392 3420 for _, p := range tmpImages { 3393 3421 os.Remove(p) 3394 3422 } 3395 - }() 3423 + }) 3396 3424 return nil 3397 3425 } 3398 3426 } ··· 4030 4058 return m, func() tea.Msg { 4031 4059 cmd := exec.Command(browser, tmpPath) 4032 4060 _ = cmd.Start() 4033 - go func() { 4061 + safeGo(func() { 4034 4062 time.Sleep(15 * time.Second) 4035 4063 os.Remove(tmpPath) 4036 - }() 4064 + }) 4037 4065 return nil 4038 4066 } 4039 4067 }
+94
internal/ui/model_test.go
··· 2 2 3 3 import ( 4 4 "net/http" 5 + "os" 5 6 "reflect" 6 7 "strings" 8 + "sync" 7 9 "testing" 10 + "time" 8 11 9 12 tea "github.com/charmbracelet/bubbletea" 10 13 "github.com/sspaeti/neomd/internal/config" ··· 739 742 }) 740 743 } 741 744 } 745 + 746 + func TestSafeGo_RecoversPanic(t *testing.T) { 747 + // safeGo should recover from panics without crashing the process. 748 + // If this test passes, the goroutine panic was caught. 749 + var wg sync.WaitGroup 750 + wg.Add(1) 751 + 752 + completed := false 753 + safeGo(func() { 754 + defer wg.Done() 755 + completed = true 756 + panic("intentional test panic") 757 + }) 758 + 759 + // Wait for the goroutine to finish (panic should be recovered) 760 + wg.Wait() 761 + 762 + if !completed { 763 + t.Error("safeGo goroutine did not execute before panicking") 764 + } 765 + // If we reach here, the panic was recovered — test passes 766 + } 767 + 768 + func TestSafeGo_NormalExecution(t *testing.T) { 769 + // safeGo should work normally for non-panicking functions. 770 + var wg sync.WaitGroup 771 + wg.Add(1) 772 + 773 + result := 0 774 + safeGo(func() { 775 + defer wg.Done() 776 + result = 42 777 + }) 778 + 779 + wg.Wait() 780 + 781 + if result != 42 { 782 + t.Errorf("safeGo normal execution: got %d, want 42", result) 783 + } 784 + } 785 + 786 + func TestSafeGo_WritesCrashLog(t *testing.T) { 787 + // safeGo should write panics to the crash log file. 788 + var wg sync.WaitGroup 789 + wg.Add(1) 790 + 791 + safeGo(func() { 792 + defer wg.Done() 793 + panic("crash log test panic") 794 + }) 795 + 796 + wg.Wait() 797 + time.Sleep(100 * time.Millisecond) // let file write complete 798 + 799 + path := config.CrashLogPath() 800 + data, err := os.ReadFile(path) 801 + if err != nil { 802 + t.Skipf("crash log not readable (may not exist in test env): %v", err) 803 + } 804 + if !strings.Contains(string(data), "crash log test panic") { 805 + t.Error("crash log should contain the panic message") 806 + } 807 + } 808 + 809 + func TestSafeGo_MultiplePanics(t *testing.T) { 810 + // Multiple concurrent panicking goroutines should all be recovered. 811 + var wg sync.WaitGroup 812 + count := 10 813 + wg.Add(count) 814 + 815 + for i := 0; i < count; i++ { 816 + safeGo(func() { 817 + defer wg.Done() 818 + panic("concurrent panic") 819 + }) 820 + } 821 + 822 + // All should complete without crashing the process 823 + done := make(chan struct{}) 824 + go func() { 825 + wg.Wait() 826 + close(done) 827 + }() 828 + 829 + select { 830 + case <-done: 831 + // Success — all panics recovered 832 + case <-time.After(5 * time.Second): 833 + t.Fatal("timed out waiting for panicking goroutines to recover") 834 + } 835 + }