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.

fix for none standard ports. e.g. proton mail

sspaeti 7a9bf7f1 ae65b1ae

+329 -10
+3
CHANGELOG.md
··· 1 1 # Changelog 2 2 3 + # 2026-04-09 4 + - **Fix: non-standard IMAP/SMTP ports** — neomd now correctly handles non-standard ports (e.g., Proton Mail Bridge on `127.0.0.1:1143` and `127.0.0.1:1025`); previously hardcoded port-based logic ignored the user's `starttls` config and refused unencrypted connections to any port other than 993/143 (IMAP) or 465/587 (SMTP); new behavior: user's explicit `starttls = true` always forces STARTTLS, standard ports use their defaults (993→TLS, 143→STARTTLS, 465→TLS, 587→STARTTLS), non-standard ports default to TLS for security (user must set `starttls = true` if their provider uses STARTTLS on a custom port); fixes "refusing unencrypted connection to 127.0.0.1:1143" error reported by Proton Bridge users; comprehensive test coverage added for all port/config combinations 5 + 3 6 # 2026-04-08 4 7 - **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 8 - **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
+4
README.md
··· 152 152 153 153 For the full configuration reference including multiple accounts, OAuth2 authentication, `[[senders]]` aliases, folder customization, signatures, and UI options, see [docs/configuration.md](docs/configuration.md). 154 154 155 + **Provider-specific guides:** 156 + - Gmail: [docs/gmail.md](docs/gmail.md) — folder name mapping and OAuth2 setup 157 + - Proton Mail Bridge: [docs/proton-bridge.md](docs/proton-bridge.md) — non-standard port configuration 158 + 155 159 ### Onboarding 156 160 157 161 On first launch, **auto-screening is paused** because your screener lists are empty — neomd won't move anything until you've classified your first sender. Your Inbox loads normally so you can explore.
+28 -2
cmd/neomd/main.go
··· 53 53 imapClients := make([]*goIMAP.Client, 0, len(accounts)) 54 54 for _, acc := range accounts { 55 55 h, p := splitAddr(acc.IMAP) 56 + // Determine TLS/STARTTLS: respect explicit user config, otherwise infer from port. 57 + // Security: non-standard ports default to TLS (e.g., Proton Mail Bridge on 1143). 58 + useTLS, useSTARTTLS := inferIMAPSecurity(p, acc.STARTTLS) 56 59 imapCfg := goIMAP.Config{ 57 60 Host: h, 58 61 Port: p, 59 62 User: acc.User, 60 63 Password: acc.Password, 61 - TLS: p == "993", 62 - STARTTLS: p == "143", 64 + TLS: useTLS, 65 + STARTTLS: useSTARTTLS, 63 66 } 64 67 if acc.IsOAuth2() { 65 68 if acc.OAuth2ClientID == "" { ··· 136 139 } 137 140 return addr[:i], addr[i+1:] 138 141 } 142 + 143 + // inferIMAPSecurity determines TLS/STARTTLS settings based on port and user config. 144 + // Returns (useTLS, useSTARTTLS). 145 + // 146 + // Logic: 147 + // - If userSTARTTLS is true: always use STARTTLS (user explicitly enabled it) 148 + // - Standard ports: 993 → TLS, 143 → STARTTLS 149 + // - Non-standard ports: default to TLS (e.g., Proton Mail Bridge on 1143) 150 + func inferIMAPSecurity(port string, userSTARTTLS bool) (useTLS, useSTARTTLS bool) { 151 + if userSTARTTLS { 152 + // User explicitly set starttls=true in config — honor it. 153 + return false, true 154 + } 155 + switch port { 156 + case "993": 157 + return true, false // Standard IMAPS (implicit TLS) 158 + case "143": 159 + return false, true // Standard IMAP (STARTTLS upgrade) 160 + default: 161 + // Non-standard port (e.g., Proton Mail Bridge): default to TLS for security. 162 + return true, false 163 + } 164 + }
+78
cmd/neomd/main_test.go
··· 1 + package main 2 + 3 + import "testing" 4 + 5 + func TestInferIMAPSecurity(t *testing.T) { 6 + tests := []struct { 7 + name string 8 + port string 9 + userSTARTTLS bool 10 + wantTLS bool 11 + wantSTARTTLS bool 12 + description string 13 + }{ 14 + // Standard ports 15 + { 16 + name: "standard IMAPS port 993", 17 + port: "993", 18 + userSTARTTLS: false, 19 + wantTLS: true, 20 + wantSTARTTLS: false, 21 + description: "Port 993 should use implicit TLS", 22 + }, 23 + { 24 + name: "standard IMAP port 143", 25 + port: "143", 26 + userSTARTTLS: false, 27 + wantTLS: false, 28 + wantSTARTTLS: true, 29 + description: "Port 143 should use STARTTLS", 30 + }, 31 + // Non-standard ports (Proton Mail Bridge, etc.) 32 + { 33 + name: "Proton Mail Bridge IMAP port 1143", 34 + port: "1143", 35 + userSTARTTLS: false, 36 + wantTLS: true, 37 + wantSTARTTLS: false, 38 + description: "Non-standard port 1143 should default to TLS", 39 + }, 40 + { 41 + name: "custom port 1143 with STARTTLS override", 42 + port: "1143", 43 + userSTARTTLS: true, 44 + wantTLS: false, 45 + wantSTARTTLS: true, 46 + description: "User override should force STARTTLS even on non-standard port", 47 + }, 48 + // User config overrides 49 + { 50 + name: "port 993 with STARTTLS override", 51 + port: "993", 52 + userSTARTTLS: true, 53 + wantTLS: false, 54 + wantSTARTTLS: true, 55 + description: "User setting starttls=true should override port-based inference", 56 + }, 57 + { 58 + name: "port 143 with STARTTLS override", 59 + port: "143", 60 + userSTARTTLS: true, 61 + wantTLS: false, 62 + wantSTARTTLS: true, 63 + description: "Port 143 with starttls=true should use STARTTLS (same as default)", 64 + }, 65 + } 66 + 67 + for _, tt := range tests { 68 + t.Run(tt.name, func(t *testing.T) { 69 + gotTLS, gotSTARTTLS := inferIMAPSecurity(tt.port, tt.userSTARTTLS) 70 + if gotTLS != tt.wantTLS { 71 + t.Errorf("%s: got TLS=%v, want TLS=%v", tt.description, gotTLS, tt.wantTLS) 72 + } 73 + if gotSTARTTLS != tt.wantSTARTTLS { 74 + t.Errorf("%s: got STARTTLS=%v, want STARTTLS=%v", tt.description, gotSTARTTLS, tt.wantSTARTTLS) 75 + } 76 + }) 77 + } 78 + }
+1
cmd/sendtest/main.go
··· 72 72 User: acc.User, 73 73 Password: acc.Password, 74 74 From: acc.From, 75 + STARTTLS: acc.STARTTLS, 75 76 } 76 77 77 78 for _, to := range recipients {
+46 -1
docs/configuration.md
··· 8 8 [[accounts]] 9 9 name = "Personal" 10 10 imap = "imap.example.com:993" # :993 = TLS, :143 = STARTTLS 11 - smtp = "smtp.example.com:587" 11 + smtp = "smtp.example.com:587" # :587 = STARTTLS, :465 = TLS 12 12 user = "me@example.com" 13 13 password = "app-password" 14 14 from = "Me <me@example.com>" 15 + starttls = false # optional: force STARTTLS (see TLS/STARTTLS section below) 15 16 16 17 # OAuth2 authenticated accounts are supported, it just need the relevant fields. Note that the password field is not required. 17 18 [[accounts]] ··· 94 95 Values containing other text or multiple `$` signs are left as-is, so passwords that happen to contain `$` are never mangled. 95 96 96 97 Credentials are stored only in `~/.config/neomd/config.toml` (mode 0600) and never written elsewhere; all IMAP connections use TLS (port 993) or STARTTLS (port 143). 98 + 99 + ### TLS and STARTTLS Configuration 100 + 101 + Neomd automatically determines the correct encryption method based on the port and the optional `starttls` config field: 102 + 103 + **IMAP ports:** 104 + - `993` → Implicit TLS (standard IMAPS) 105 + - `143` → STARTTLS upgrade (standard IMAP) 106 + - Non-standard ports (e.g., `1143` for Proton Mail Bridge) → TLS by default 107 + - Set `starttls = true` to force STARTTLS on any port 108 + 109 + **SMTP ports:** 110 + - `465` → Implicit TLS (SMTPS) 111 + - `587` → STARTTLS upgrade (modern submission standard) 112 + - Non-standard ports (e.g., `1025` for Proton Mail Bridge) → TLS by default 113 + - Set `starttls = true` to force STARTTLS on any port 114 + 115 + **Examples:** 116 + 117 + Standard provider (Gmail, Hostpoint, etc.): 118 + ```toml 119 + [[accounts]] 120 + imap = "imap.gmail.com:993" 121 + smtp = "smtp.gmail.com:587" 122 + starttls = false # optional, default behavior works 123 + ``` 124 + 125 + Proton Mail Bridge (local bridge on non-standard ports): 126 + ```toml 127 + [[accounts]] 128 + imap = "127.0.0.1:1143" # Uses TLS automatically 129 + smtp = "127.0.0.1:1025" # Uses TLS; set starttls=true if bridge uses STARTTLS 130 + starttls = false 131 + ``` 132 + 133 + Custom server with STARTTLS on non-standard port: 134 + ```toml 135 + [[accounts]] 136 + imap = "mail.custom.com:2143" 137 + smtp = "mail.custom.com:2587" 138 + starttls = true # Forces STARTTLS instead of TLS 139 + ``` 140 + 141 + See `docs/proton-bridge.md` for complete Proton Mail Bridge setup instructions. 97 142 98 143 ## Sending and Discarding 99 144
+67
docs/proton-bridge.md
··· 1 + # Configuring neomd with Proton Mail Bridge 2 + 3 + Proton Mail Bridge allows you to use neomd with ProtonMail accounts by running a local IMAP/SMTP bridge. 4 + 5 + ## Installation 6 + 7 + 1. Install Proton Mail Bridge: https://proton.me/mail/bridge 8 + 2. Launch the bridge and configure your ProtonMail account 9 + 3. Note the IMAP and SMTP connection details (typically `127.0.0.1:1143` and `127.0.0.1:1025`) 10 + 11 + ## neomd Configuration 12 + 13 + Add the following to your `~/.config/neomd/config.toml`: 14 + 15 + ```toml 16 + [[accounts]] 17 + name = "ProtonMail" 18 + imap = "127.0.0.1:1143" 19 + smtp = "127.0.0.1:1025" 20 + user = "your-proton-email@proton.me" 21 + password = "bridge-password-here" # Get this from Proton Bridge settings 22 + from = "Your Name <your-proton-email@proton.me>" 23 + starttls = false # Proton Bridge uses TLS on non-standard ports 24 + ``` 25 + 26 + ## Key Configuration Details 27 + 28 + - **IMAP Port**: Proton Bridge defaults to `1143` with TLS 29 + - **SMTP Port**: Proton Bridge defaults to `1025` with STARTTLS 30 + - If you need STARTTLS for SMTP, set `starttls = true` 31 + - **Password**: Use the bridge-generated password (not your ProtonMail password) 32 + - **TLS/STARTTLS**: neomd automatically detects the correct security mode based on: 33 + - Standard ports (993→TLS, 143→STARTTLS for IMAP; 465→TLS, 587→STARTTLS for SMTP) 34 + - Non-standard ports default to TLS unless `starttls = true` is set 35 + - Explicit `starttls = true` always forces STARTTLS 36 + 37 + ## Troubleshooting 38 + 39 + ### "refusing unencrypted connection to 127.0.0.1:1143" 40 + 41 + This error occurred in older versions of neomd that didn't respect the `starttls` config or handle non-standard ports correctly. **This is now fixed** (v0.4.15+). 42 + 43 + If you still see this error: 44 + 1. Ensure you're running the latest version: `neomd --version` 45 + 2. Check your config has the correct IMAP/SMTP addresses 46 + 3. Verify Proton Bridge is running: `ps aux | grep bridge` 47 + 48 + ### Connection Refused 49 + 50 + - Make sure Proton Mail Bridge is running 51 + - Verify the bridge ports in its settings (they may differ from the defaults) 52 + - Check firewall settings allow localhost connections 53 + 54 + ## Custom Ports 55 + 56 + If your Proton Bridge uses different ports, adjust the config accordingly: 57 + 58 + ```toml 59 + imap = "127.0.0.1:YOUR_IMAP_PORT" 60 + smtp = "127.0.0.1:YOUR_SMTP_PORT" 61 + ``` 62 + 63 + For non-standard ports, neomd defaults to TLS. If your bridge uses STARTTLS on a custom port, add: 64 + 65 + ```toml 66 + starttls = true 67 + ```
+29 -7
internal/smtp/sender.go
··· 32 32 User string 33 33 Password string 34 34 From string // "Name <email>" 35 + STARTTLS bool // User's explicit starttls config preference 35 36 36 37 // TokenSource is used for OAuth2 accounts instead of Password. 37 38 TokenSource func() (string, error) ··· 99 100 return err 100 101 } 101 102 addr := cfg.Host + ":" + cfg.Port 102 - switch cfg.Port { 103 - case "465": // Implicit TLS (SMTPS) 103 + if inferSMTPUseTLS(cfg.Port, cfg.STARTTLS) { 104 104 return sendTLS(addr, cfg.Host, auth, fromAddr, toAddrs, raw) 105 - default: // STARTTLS (587) or plain (25) 106 - return sendSTARTTLS(addr, auth, fromAddr, toAddrs, raw) 107 105 } 106 + return sendSTARTTLS(addr, auth, fromAddr, toAddrs, raw) 108 107 } 109 108 110 109 // SendRaw delivers a pre-built raw MIME message (e.g. from BuildMessage). ··· 117 116 return err 118 117 } 119 118 addr := cfg.Host + ":" + cfg.Port 120 - switch cfg.Port { 119 + if inferSMTPUseTLS(cfg.Port, cfg.STARTTLS) { 120 + return sendTLS(addr, cfg.Host, auth, fromAddr, toAddrs, raw) 121 + } 122 + return sendSTARTTLS(addr, auth, fromAddr, toAddrs, raw) 123 + } 124 + 125 + // inferSMTPUseTLS determines whether to use implicit TLS or STARTTLS based on 126 + // port and user config. Returns true for TLS, false for STARTTLS. 127 + // 128 + // Logic: 129 + // - If userSTARTTLS is true: always use STARTTLS (user explicitly enabled it) 130 + // - Standard ports: 465 → TLS, 587 → STARTTLS 131 + // - Non-standard ports: default to TLS (e.g., Proton Mail Bridge on 1025 uses STARTTLS, 132 + // but user must set starttls=true for that) 133 + func inferSMTPUseTLS(port string, userSTARTTLS bool) bool { 134 + if userSTARTTLS { 135 + // User explicitly set starttls=true in config — use STARTTLS. 136 + return false 137 + } 138 + switch port { 121 139 case "465": 122 - return sendTLS(addr, cfg.Host, auth, fromAddr, toAddrs, raw) 140 + return true // SMTPS (implicit TLS) 141 + case "587": 142 + return false // Submission with STARTTLS (modern standard) 123 143 default: 124 - return sendSTARTTLS(addr, auth, fromAddr, toAddrs, raw) 144 + // Non-standard port: default to TLS for security. 145 + // User must explicitly set starttls=true if their provider uses STARTTLS. 146 + return true 125 147 } 126 148 } 127 149
+72
internal/smtp/sender_test.go
··· 385 385 }) 386 386 } 387 387 } 388 + 389 + func TestInferSMTPUseTLS(t *testing.T) { 390 + tests := []struct { 391 + name string 392 + port string 393 + userSTARTTLS bool 394 + wantTLS bool 395 + description string 396 + }{ 397 + // Standard ports 398 + { 399 + name: "standard SMTPS port 465", 400 + port: "465", 401 + userSTARTTLS: false, 402 + wantTLS: true, 403 + description: "Port 465 should use implicit TLS", 404 + }, 405 + { 406 + name: "standard submission port 587", 407 + port: "587", 408 + userSTARTTLS: false, 409 + wantTLS: false, 410 + description: "Port 587 should use STARTTLS", 411 + }, 412 + // Non-standard ports (Proton Mail Bridge, etc.) 413 + { 414 + name: "Proton Mail Bridge SMTP port 1025", 415 + port: "1025", 416 + userSTARTTLS: false, 417 + wantTLS: true, 418 + description: "Non-standard port 1025 should default to TLS (user must set starttls=true if needed)", 419 + }, 420 + { 421 + name: "custom port 1025 with STARTTLS override", 422 + port: "1025", 423 + userSTARTTLS: true, 424 + wantTLS: false, 425 + description: "User setting starttls=true should force STARTTLS on non-standard port", 426 + }, 427 + // User config overrides 428 + { 429 + name: "port 465 with STARTTLS override", 430 + port: "465", 431 + userSTARTTLS: true, 432 + wantTLS: false, 433 + description: "User setting starttls=true should override port 465 default", 434 + }, 435 + { 436 + name: "port 587 with STARTTLS override", 437 + port: "587", 438 + userSTARTTLS: true, 439 + wantTLS: false, 440 + description: "Port 587 with starttls=true should use STARTTLS (same as default)", 441 + }, 442 + { 443 + name: "port 587 with starttls false", 444 + port: "587", 445 + userSTARTTLS: false, 446 + wantTLS: false, 447 + description: "Port 587 should use STARTTLS even when starttls=false (port takes precedence)", 448 + }, 449 + } 450 + 451 + for _, tt := range tests { 452 + t.Run(tt.name, func(t *testing.T) { 453 + got := inferSMTPUseTLS(tt.port, tt.userSTARTTLS) 454 + if got != tt.wantTLS { 455 + t.Errorf("%s: got TLS=%v, want TLS=%v", tt.description, got, tt.wantTLS) 456 + } 457 + }) 458 + } 459 + }
+1
internal/ui/model.go
··· 670 670 User: smtpAcct.User, 671 671 Password: smtpAcct.Password, 672 672 From: from, 673 + STARTTLS: smtpAcct.STARTTLS, 673 674 TokenSource: m.tokenSourceFor(smtpAcct.Name), 674 675 } 675 676 cli := m.imapCli()