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 mailto

+94 -6
+30 -5
cmd/neomd/main.go
··· 189 189 if decoded, err := url.PathUnescape(to); err == nil { 190 190 to = decoded 191 191 } 192 - q := u.Query() 192 + // Parse query manually: url.Query() decodes '+' as space, but in 193 + // mailto: URIs '+' is a literal character (RFC 6068). 194 + q := parseMailtoQuery(u.RawQuery) 193 195 return &ui.MailtoParams{ 194 196 To: to, 195 - CC: q.Get("cc"), 196 - BCC: q.Get("bcc"), 197 - Subject: q.Get("subject"), 198 - Body: q.Get("body"), 197 + CC: q("cc"), 198 + BCC: q("bcc"), 199 + Subject: q("subject"), 200 + Body: q("body"), 201 + } 202 + } 203 + 204 + // parseMailtoQuery parses a raw query string using PathUnescape (not 205 + // QueryUnescape) so that literal '+' characters are preserved per RFC 6068. 206 + func parseMailtoQuery(raw string) func(string) string { 207 + m := make(map[string]string) 208 + for _, pair := range strings.Split(raw, "&") { 209 + if pair == "" { 210 + continue 211 + } 212 + k, v, _ := strings.Cut(pair, "=") 213 + if dk, err := url.PathUnescape(k); err == nil { 214 + k = dk 215 + } 216 + if dv, err := url.PathUnescape(v); err == nil { 217 + v = dv 218 + } 219 + k = strings.ToLower(k) 220 + if _, exists := m[k]; !exists { 221 + m[k] = v 222 + } 199 223 } 224 + return func(key string) string { return m[key] } 200 225 } 201 226 202 227 func inferIMAPSecurity(port string, userSTARTTLS bool) (useTLS, useSTARTTLS bool) {
+64 -1
cmd/neomd/main_test.go
··· 1 1 package main 2 2 3 - import "testing" 3 + import ( 4 + "testing" 5 + ) 4 6 5 7 func TestInferIMAPSecurity(t *testing.T) { 6 8 tests := []struct { ··· 76 78 }) 77 79 } 78 80 } 81 + 82 + func TestParseMailtoQuery_PlusPreserved(t *testing.T) { 83 + tests := []struct { 84 + name string 85 + raw string 86 + key string 87 + want string 88 + }{ 89 + { 90 + name: "plus in cc address", 91 + raw: "cc=alice%2Bnews@example.com", 92 + key: "cc", 93 + want: "alice+news@example.com", 94 + }, 95 + { 96 + name: "literal plus not decoded as space", 97 + raw: "subject=a+b+c", 98 + key: "subject", 99 + want: "a+b+c", 100 + }, 101 + { 102 + name: "percent-encoded space", 103 + raw: "subject=hello%20world", 104 + key: "subject", 105 + want: "hello world", 106 + }, 107 + { 108 + name: "multiple params with plus", 109 + raw: "cc=a%2Bb@x.com&subject=re%3A+test", 110 + key: "cc", 111 + want: "a+b@x.com", 112 + }, 113 + { 114 + name: "empty query", 115 + raw: "", 116 + key: "cc", 117 + want: "", 118 + }, 119 + } 120 + 121 + for _, tt := range tests { 122 + t.Run(tt.name, func(t *testing.T) { 123 + q := parseMailtoQuery(tt.raw) 124 + got := q(tt.key) 125 + if got != tt.want { 126 + t.Errorf("parseMailtoQuery(%q)(%q) = %q, want %q", tt.raw, tt.key, got, tt.want) 127 + } 128 + }) 129 + } 130 + } 131 + 132 + func TestParseMailto_PlusInAddress(t *testing.T) { 133 + params := parseMailto("mailto:user@example.com?cc=alice%2Bnews@example.com&subject=a+b") 134 + if params.CC != "alice+news@example.com" { 135 + t.Errorf("CC = %q, want %q", params.CC, "alice+news@example.com") 136 + } 137 + // '+' in subject should stay literal, not become space 138 + if params.Subject != "a+b" { 139 + t.Errorf("Subject = %q, want %q", params.Subject, "a+b") 140 + } 141 + }