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 Proton Mail Bridge support

sspaeti fad76c4f 2aa17b99

+257 -37
+1
CHANGELOG.md
··· 1 1 # Changelog 2 2 3 3 # 2026-04-10 4 + - **Proton Mail Bridge compatibility** — documented that Proton Mail works with neomd only via Proton Mail Bridge (paid Proton feature), added optional `tls_cert_file` support for trusting Bridge’s exported self-signed certificate, and added a narrow localhost-only TLS retry fallback for Bridge connections on `127.0.0.1`/`localhost`; normal remote IMAP/SMTP providers keep their existing strict certificate verification behavior 4 5 - **Issue #6 verification pass** — reviewed the user report against the current code and specifically verified that startup auto-screening does not route Inbox mail to Trash in the current implementation, while manual `ToScreen` screening remains message-by-message by design 5 6 - **Fix: Drafts/Spam reload off-tab folder mismatch** — reloading while viewing an off-tab folder now reloads that actual mailbox instead of the currently selected tab's folder; fixes the confusing case where Drafts could show Inbox content after pressing `R` 6 7 - **Fix: committed `/` filter now clears with `esc`** — pressing `esc` now reliably clears the in-memory inbox filter even after the filter was already applied
+2
README.md
··· 147 147 user = "me@example.com" 148 148 password = "app-password" 149 149 from = "Me <me@example.com>" 150 + starttls = false 151 + tls_cert_file = "" # optional PEM cert/CA for self-signed local bridges 150 152 151 153 [screener] 152 154 screened_in = "~/.config/neomd/lists/screened_in.txt"
+7 -6
cmd/neomd/main.go
··· 57 57 // Security: non-standard ports default to TLS (e.g., Proton Mail Bridge on 1143). 58 58 useTLS, useSTARTTLS := inferIMAPSecurity(p, acc.STARTTLS) 59 59 imapCfg := goIMAP.Config{ 60 - Host: h, 61 - Port: p, 62 - User: acc.User, 63 - Password: acc.Password, 64 - TLS: useTLS, 65 - STARTTLS: useSTARTTLS, 60 + Host: h, 61 + Port: p, 62 + User: acc.User, 63 + Password: acc.Password, 64 + TLS: useTLS, 65 + STARTTLS: useSTARTTLS, 66 + TLSCertFile: acc.TLSCertFile, 66 67 } 67 68 if acc.IsOAuth2() { 68 69 if acc.OAuth2ClientID == "" {
+7 -6
cmd/sendtest/main.go
··· 67 67 68 68 h, p := splitAddr(acc.SMTP) 69 69 smtpCfg := smtp.Config{ 70 - Host: h, 71 - Port: p, 72 - User: acc.User, 73 - Password: acc.Password, 74 - From: acc.From, 75 - STARTTLS: acc.STARTTLS, 70 + Host: h, 71 + Port: p, 72 + User: acc.User, 73 + Password: acc.Password, 74 + From: acc.From, 75 + STARTTLS: acc.STARTTLS, 76 + TLSCertFile: acc.TLSCertFile, 76 77 } 77 78 78 79 for _, to := range recipients {
+9
docs/configuration.md
··· 13 13 password = "app-password" 14 14 from = "Me <me@example.com>" 15 15 starttls = false # optional: force STARTTLS (see TLS/STARTTLS section below) 16 + tls_cert_file = "" # optional PEM cert/CA for self-signed local bridges 16 17 17 18 # OAuth2 authenticated accounts are supported, it just need the relevant fields. Note that the password field is not required. 18 19 [[accounts]] ··· 122 123 imap = "imap.gmail.com:993" 123 124 smtp = "smtp.gmail.com:587" 124 125 starttls = false # optional, default behavior works 126 + tls_cert_file = "" 125 127 ``` 126 128 127 129 Proton Mail Bridge (local bridge on non-standard ports): ··· 130 132 imap = "127.0.0.1:1143" # Uses TLS automatically 131 133 smtp = "127.0.0.1:1025" # Uses TLS; set starttls=true if bridge uses STARTTLS 132 134 starttls = false 135 + tls_cert_file = "~/ProtonBridge/cert.pem" # optional: exported Bridge cert 133 136 ``` 134 137 135 138 Custom server with STARTTLS on non-standard port: ··· 141 144 ``` 142 145 143 146 See `docs/proton-bridge.md` for complete Proton Mail Bridge setup instructions. 147 + 148 + For localhost/self-signed bridges such as Proton Mail Bridge, neomd first tries 149 + normal certificate verification. If that fails with an unknown-authority error 150 + on a loopback host (`127.0.0.1`, `::1`, `localhost`), neomd retries once with a 151 + localhost-only fallback so existing Bridge setups keep working. If you want 152 + strict verification, export the Bridge certificate and set `tls_cert_file`. 144 153 145 154 ## Sending and Discarding 146 155
+17
docs/proton-bridge.md
··· 21 21 password = "bridge-password-here" # Get this from Proton Bridge settings 22 22 from = "Your Name <your-proton-email@proton.me>" 23 23 starttls = false # Proton Bridge uses TLS on non-standard ports 24 + tls_cert_file = "~/ProtonBridge/cert.pem" # optional: exported Bridge certificate 24 25 ``` 25 26 26 27 ## Key Configuration Details ··· 33 34 - Standard ports (993→TLS, 143→STARTTLS for IMAP; 465→TLS, 587→STARTTLS for SMTP) 34 35 - Non-standard ports default to TLS unless `starttls = true` is set 35 36 - Explicit `starttls = true` always forces STARTTLS 37 + - **Certificate**: Proton Bridge uses a self-signed certificate because the IMAP/SMTP server only runs on your own computer. neomd now handles this in two ways: 38 + - Best: export the Bridge certificate and set `tls_cert_file` 39 + - Fallback: for `127.0.0.1` / `localhost`, neomd retries once if verification fails with an unknown-authority error 36 40 37 41 ## Troubleshooting 38 42 ··· 44 48 1. Ensure you're running the latest version: `neomd --version` 45 49 2. Check your config has the correct IMAP/SMTP addresses 46 50 3. Verify Proton Bridge is running: `ps aux | grep bridge` 51 + 52 + ### "tls: failed to verify certificate" 53 + 54 + This usually means Proton Bridge presented its local self-signed certificate and 55 + your client did not trust it yet. 56 + 57 + Recommended fix: 58 + 1. In Proton Mail Bridge, export the TLS certificates 59 + 2. Point `tls_cert_file` at the exported `cert.pem` 60 + 3. Keep `starttls = false` for IMAP on `1143` unless your Bridge shows otherwise 61 + 62 + Without `tls_cert_file`, neomd now retries once for loopback hosts only, which 63 + keeps common Proton Bridge setups working without affecting normal remote IMAP servers. 47 64 48 65 ### Connection Refused 49 66
+10 -7
internal/config/config.go
··· 23 23 24 24 // AccountConfig holds IMAP/SMTP connection settings. 25 25 type AccountConfig struct { 26 - Name string `toml:"name"` 27 - IMAP string `toml:"imap"` // host:port (993 = TLS, 143 = STARTTLS) 28 - SMTP string `toml:"smtp"` // host:port (587 = STARTTLS, 465 = TLS) 29 - User string `toml:"user"` 30 - Password string `toml:"password"` 31 - From string `toml:"from"` // "Name <email@example.com>" 32 - STARTTLS bool `toml:"starttls"` 26 + Name string `toml:"name"` 27 + IMAP string `toml:"imap"` // host:port (993 = TLS, 143 = STARTTLS) 28 + SMTP string `toml:"smtp"` // host:port (587 = STARTTLS, 465 = TLS) 29 + User string `toml:"user"` 30 + Password string `toml:"password"` 31 + From string `toml:"from"` // "Name <email@example.com>" 32 + STARTTLS bool `toml:"starttls"` 33 + TLSCertFile string `toml:"tls_cert_file"` // optional PEM CA/cert for self-signed local bridges 33 34 34 35 // OAuth2 fields — only used when auth_type = "oauth2". 35 36 AuthType string `toml:"auth_type"` // "plain" (default) | "oauth2" ··· 289 290 for i := range cfg.Accounts { 290 291 cfg.Accounts[i].Password = expandEnv(cfg.Accounts[i].Password) 291 292 cfg.Accounts[i].User = expandEnv(cfg.Accounts[i].User) 293 + cfg.Accounts[i].TLSCertFile = expandPath(expandEnv(cfg.Accounts[i].TLSCertFile)) 292 294 } 293 295 cfg.Account.Password = expandEnv(cfg.Account.Password) 294 296 cfg.Account.User = expandEnv(cfg.Account.User) 297 + cfg.Account.TLSCertFile = expandPath(expandEnv(cfg.Account.TLSCertFile)) 295 298 296 299 return cfg, nil 297 300 }
+17 -2
internal/imap/client.go
··· 20 20 "github.com/emersion/go-imap/v2/imapclient" 21 21 "github.com/emersion/go-message" 22 22 "github.com/emersion/go-message/mail" 23 + "github.com/sspaeti/neomd/internal/mailtls" 23 24 "github.com/sspaeti/neomd/internal/oauth2" 24 25 ) 25 26 ··· 58 59 Password string 59 60 TLS bool // implicit TLS (port 993) 60 61 STARTTLS bool // STARTTLS upgrade (port 143) 62 + TLSCertFile string // optional PEM CA/cert for self-signed local bridges 61 63 TokenSource func() (string, error) // The token is used instead of the password for OAuth2 Accounts 62 64 } 63 65 ··· 86 88 return nil 87 89 } 88 90 addr := c.addr() 89 - opts := &imapclient.Options{} 91 + tlsCfg, err := mailtls.Config(c.cfg.Host, c.cfg.TLSCertFile) 92 + if err != nil { 93 + return err 94 + } 95 + opts := &imapclient.Options{TLSConfig: tlsCfg} 90 96 var ( 91 97 conn *imapclient.Client 92 - err error 93 98 ) 94 99 switch { 95 100 case c.cfg.TLS: ··· 98 103 conn, err = imapclient.DialStartTLS(addr, opts) 99 104 default: 100 105 return fmt.Errorf("refusing unencrypted connection to %s — use port 993 (TLS) or 143 (STARTTLS)", addr) 106 + } 107 + if err != nil && mailtls.ShouldRetryInsecureLocalhost(c.cfg.Host, c.cfg.TLSCertFile, err) { 108 + c.logger.Warn("retrying IMAP TLS connection with localhost self-signed certificate fallback", "host", c.cfg.Host, "port", c.cfg.Port) 109 + opts = &imapclient.Options{TLSConfig: mailtls.InsecureLocalhostConfig(c.cfg.Host)} 110 + switch { 111 + case c.cfg.TLS: 112 + conn, err = imapclient.DialTLS(addr, opts) 113 + case c.cfg.STARTTLS: 114 + conn, err = imapclient.DialStartTLS(addr, opts) 115 + } 101 116 } 102 117 if err != nil { 103 118 return fmt.Errorf("dial %s: %w", addr, err)
+72
internal/mailtls/config.go
··· 1 + package mailtls 2 + 3 + import ( 4 + "crypto/tls" 5 + "crypto/x509" 6 + "errors" 7 + "fmt" 8 + "net" 9 + "os" 10 + "strings" 11 + ) 12 + 13 + // Config returns a strict TLS config for the given host. 14 + // If certFile is set, it is added to the system root pool. 15 + func Config(host, certFile string) (*tls.Config, error) { 16 + cfg := &tls.Config{ServerName: host} 17 + if certFile == "" { 18 + return cfg, nil 19 + } 20 + 21 + pool, err := x509.SystemCertPool() 22 + if err != nil { 23 + return nil, fmt.Errorf("load system cert pool: %w", err) 24 + } 25 + if pool == nil { 26 + pool = x509.NewCertPool() 27 + } 28 + 29 + pem, err := os.ReadFile(certFile) 30 + if err != nil { 31 + return nil, fmt.Errorf("read tls_cert_file %q: %w", certFile, err) 32 + } 33 + if !pool.AppendCertsFromPEM(pem) { 34 + return nil, fmt.Errorf("parse tls_cert_file %q: no PEM certificates found", certFile) 35 + } 36 + cfg.RootCAs = pool 37 + return cfg, nil 38 + } 39 + 40 + // InsecureLocalhostConfig returns a loopback-only fallback config for local bridges 41 + // that use a self-signed certificate, such as Proton Mail Bridge. 42 + func InsecureLocalhostConfig(host string) *tls.Config { 43 + return &tls.Config{ 44 + ServerName: host, 45 + InsecureSkipVerify: true, 46 + } 47 + } 48 + 49 + // ShouldRetryInsecureLocalhost reports whether a strict TLS failure should be 50 + // retried once with loopback-only insecure verification. 51 + func ShouldRetryInsecureLocalhost(host, certFile string, err error) bool { 52 + if certFile != "" || !IsLoopbackHost(host) || err == nil { 53 + return false 54 + } 55 + 56 + var unknownAuthority x509.UnknownAuthorityError 57 + if errors.As(err, &unknownAuthority) { 58 + return true 59 + } 60 + 61 + msg := strings.ToLower(err.Error()) 62 + return strings.Contains(msg, "x509") && strings.Contains(msg, "certificate signed by") 63 + } 64 + 65 + func IsLoopbackHost(host string) bool { 66 + switch strings.ToLower(host) { 67 + case "localhost": 68 + return true 69 + } 70 + ip := net.ParseIP(host) 71 + return ip != nil && ip.IsLoopback() 72 + }
+42
internal/mailtls/config_test.go
··· 1 + package mailtls 2 + 3 + import ( 4 + "crypto/x509" 5 + "errors" 6 + "testing" 7 + ) 8 + 9 + func TestIsLoopbackHost(t *testing.T) { 10 + tests := []struct { 11 + host string 12 + want bool 13 + }{ 14 + {host: "localhost", want: true}, 15 + {host: "LOCALHOST", want: true}, 16 + {host: "127.0.0.1", want: true}, 17 + {host: "::1", want: true}, 18 + {host: "192.168.1.10", want: false}, 19 + {host: "imap.gmail.com", want: false}, 20 + } 21 + 22 + for _, tt := range tests { 23 + if got := IsLoopbackHost(tt.host); got != tt.want { 24 + t.Fatalf("IsLoopbackHost(%q) = %v, want %v", tt.host, got, tt.want) 25 + } 26 + } 27 + } 28 + 29 + func TestShouldRetryInsecureLocalhost(t *testing.T) { 30 + if !ShouldRetryInsecureLocalhost("127.0.0.1", "", x509.UnknownAuthorityError{}) { 31 + t.Fatal("expected localhost unknown authority error to trigger fallback") 32 + } 33 + if ShouldRetryInsecureLocalhost("imap.example.com", "", x509.UnknownAuthorityError{}) { 34 + t.Fatal("did not expect remote host to trigger fallback") 35 + } 36 + if ShouldRetryInsecureLocalhost("127.0.0.1", "/tmp/cert.pem", x509.UnknownAuthorityError{}) { 37 + t.Fatal("did not expect explicit tls_cert_file to trigger fallback") 38 + } 39 + if ShouldRetryInsecureLocalhost("127.0.0.1", "", errors.New("timeout")) { 40 + t.Fatal("did not expect non-certificate error to trigger fallback") 41 + } 42 + }
+72 -16
internal/smtp/sender.go
··· 19 19 "strings" 20 20 "time" 21 21 22 + "github.com/sspaeti/neomd/internal/mailtls" 22 23 "github.com/sspaeti/neomd/internal/render" 23 24 ) 24 25 ··· 27 28 28 29 // Config holds outgoing mail settings. 29 30 type Config struct { 30 - Host string // e.g. "smtp.example.com" 31 - Port string // e.g. "587" (STARTTLS) or "465" (TLS) 32 - User string 33 - Password string 34 - From string // "Name <email>" 35 - STARTTLS bool // User's explicit starttls config preference 31 + Host string // e.g. "smtp.example.com" 32 + Port string // e.g. "587" (STARTTLS) or "465" (TLS) 33 + User string 34 + Password string 35 + From string // "Name <email>" 36 + STARTTLS bool // User's explicit starttls config preference 37 + TLSCertFile string // optional PEM CA/cert for self-signed local bridges 36 38 37 39 // TokenSource is used for OAuth2 accounts instead of Password. 38 40 TokenSource func() (string, error) ··· 100 102 return err 101 103 } 102 104 addr := cfg.Host + ":" + cfg.Port 105 + tlsCfg, err := mailtls.Config(cfg.Host, cfg.TLSCertFile) 106 + if err != nil { 107 + return err 108 + } 103 109 if inferSMTPUseTLS(cfg.Port, cfg.STARTTLS) { 104 - return sendTLS(addr, cfg.Host, auth, fromAddr, toAddrs, raw) 110 + return sendTLS(addr, cfg, tlsCfg, auth, fromAddr, toAddrs, raw) 105 111 } 106 - return sendSTARTTLS(addr, auth, fromAddr, toAddrs, raw) 112 + return sendSTARTTLS(addr, cfg, tlsCfg, auth, fromAddr, toAddrs, raw) 107 113 } 108 114 109 115 // SendRaw delivers a pre-built raw MIME message (e.g. from BuildMessage). ··· 116 122 return err 117 123 } 118 124 addr := cfg.Host + ":" + cfg.Port 125 + tlsCfg, err := mailtls.Config(cfg.Host, cfg.TLSCertFile) 126 + if err != nil { 127 + return err 128 + } 119 129 if inferSMTPUseTLS(cfg.Port, cfg.STARTTLS) { 120 - return sendTLS(addr, cfg.Host, auth, fromAddr, toAddrs, raw) 130 + return sendTLS(addr, cfg, tlsCfg, auth, fromAddr, toAddrs, raw) 121 131 } 122 - return sendSTARTTLS(addr, auth, fromAddr, toAddrs, raw) 132 + return sendSTARTTLS(addr, cfg, tlsCfg, auth, fromAddr, toAddrs, raw) 123 133 } 124 134 125 135 // inferSMTPUseTLS determines whether to use implicit TLS or STARTTLS based on ··· 148 158 } 149 159 150 160 // sendSTARTTLS sends via STARTTLS upgrade (port 587). 151 - func sendSTARTTLS(addr string, auth smtp.Auth, from string, to []string, msg []byte) error { 152 - return smtp.SendMail(addr, auth, from, to, msg) 161 + func sendSTARTTLS(addr string, cfg Config, tlsCfg *tls.Config, auth smtp.Auth, from string, to []string, msg []byte) error { 162 + err := sendSTARTTLSWithConfig(addr, cfg.Host, tlsCfg, auth, from, to, msg) 163 + if err != nil && mailtls.ShouldRetryInsecureLocalhost(cfg.Host, cfg.TLSCertFile, err) { 164 + return sendSTARTTLSWithConfig(addr, cfg.Host, mailtls.InsecureLocalhostConfig(cfg.Host), auth, from, to, msg) 165 + } 166 + return err 153 167 } 154 168 155 169 // sendTLS sends via implicit TLS (port 465 / SMTPS). 156 - func sendTLS(addr, host string, auth smtp.Auth, from string, to []string, msg []byte) error { 157 - tlsCfg := &tls.Config{ServerName: host} 170 + func sendTLS(addr string, cfg Config, tlsCfg *tls.Config, auth smtp.Auth, from string, to []string, msg []byte) error { 158 171 conn, err := tls.Dial("tcp", addr, tlsCfg) 159 172 if err != nil { 160 - return fmt.Errorf("TLS dial %s: %w", addr, err) 173 + if mailtls.ShouldRetryInsecureLocalhost(cfg.Host, cfg.TLSCertFile, err) { 174 + conn, err = tls.Dial("tcp", addr, mailtls.InsecureLocalhostConfig(cfg.Host)) 175 + } 176 + if err != nil { 177 + return fmt.Errorf("TLS dial %s: %w", addr, err) 178 + } 161 179 } 162 180 163 - c, err := smtp.NewClient(conn, host) 181 + c, err := smtp.NewClient(conn, cfg.Host) 164 182 if err != nil { 165 183 return fmt.Errorf("SMTP new client: %w", err) 166 184 } ··· 168 186 169 187 if err := c.Auth(auth); err != nil { 170 188 return fmt.Errorf("SMTP auth: %w", err) 189 + } 190 + if err := c.Mail(from); err != nil { 191 + return fmt.Errorf("SMTP MAIL FROM: %w", err) 192 + } 193 + for _, r := range to { 194 + if err := c.Rcpt(r); err != nil { 195 + return fmt.Errorf("SMTP RCPT TO %s: %w", r, err) 196 + } 197 + } 198 + w, err := c.Data() 199 + if err != nil { 200 + return fmt.Errorf("SMTP DATA: %w", err) 201 + } 202 + if _, err := w.Write(msg); err != nil { 203 + return fmt.Errorf("write message: %w", err) 204 + } 205 + return w.Close() 206 + } 207 + 208 + func sendSTARTTLSWithConfig(addr, host string, tlsCfg *tls.Config, auth smtp.Auth, from string, to []string, msg []byte) error { 209 + c, err := smtp.Dial(addr) 210 + if err != nil { 211 + return fmt.Errorf("SMTP dial %s: %w", addr, err) 212 + } 213 + defer c.Close() 214 + 215 + if ok, _ := c.Extension("STARTTLS"); !ok { 216 + return fmt.Errorf("SMTP server %s does not support STARTTLS", addr) 217 + } 218 + if err := c.StartTLS(tlsCfg); err != nil { 219 + return fmt.Errorf("SMTP STARTTLS %s: %w", addr, err) 220 + } 221 + if auth != nil { 222 + if ok, _ := c.Extension("AUTH"); ok { 223 + if err := c.Auth(auth); err != nil { 224 + return fmt.Errorf("SMTP auth: %w", err) 225 + } 226 + } 171 227 } 172 228 if err := c.Mail(from); err != nil { 173 229 return fmt.Errorf("SMTP MAIL FROM: %w", err)
+1
internal/ui/model.go
··· 723 723 Password: smtpAcct.Password, 724 724 From: from, 725 725 STARTTLS: smtpAcct.STARTTLS, 726 + TLSCertFile: smtpAcct.TLSCertFile, 726 727 TokenSource: m.tokenSourceFor(smtpAcct.Name), 727 728 } 728 729 cli := m.imapCliForAccount(smtpAcct.Name)