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.

minor fixes, HTML signature updates

sspaeti c33dda74 b0276dd6

+150 -5
+3 -1
README.md
··· 150 150 from = "Me <me@example.com>" 151 151 starttls = false 152 152 tls_cert_file = "" # optional PEM cert/CA for self-signed local bridges 153 - store_sent_drafts_in_sending_account = false 153 + 154 + # Root-level settings 155 + store_sent_drafts_in_sending_account = false # default: Sent/Drafts use first account; true = follow sending account 154 156 155 157 [screener] 156 158 screened_in = "~/.config/neomd/lists/screened_in.txt"
+2
docs/configuration.md
··· 29 29 30 30 # Multiple accounts supported — add more [[accounts]] blocks 31 31 # Switch between them with `ctrl+a` in the inbox 32 + 33 + # Root-level settings 32 34 store_sent_drafts_in_sending_account = false # default: Sent/Drafts stay in the first IMAP account 33 35 34 36 # Optional: SMTP-only aliases — cycle with ctrl+f in compose/pre-send
images/html-signature.png

This is a binary file and will not be displayed.

+11
internal/imap/client.go
··· 975 975 return string(raw), "", "", nil 976 976 } 977 977 978 + // Check if this is a neomd-authored draft. Drafts use the X-Neomd-Draft header 979 + // to signal that the plain text body is already markdown and should not be 980 + // normalized (which adds trailing spaces and would mutate the draft on each save/load). 981 + isDraft := e.Header.Get("X-Neomd-Draft") == "true" 982 + 978 983 // List-Post header contains the canonical article URL on most newsletters: 979 984 // List-Post: <https://newsletter.example.com/p/slug> 980 985 if lp := e.Header.Get("List-Post"); lp != "" { ··· 1080 1085 return htmlToMarkdown(htmlText), htmlText, webURL, attachments 1081 1086 } 1082 1087 if plainText != "" { 1088 + // For neomd drafts, return the raw markdown without normalization. 1089 + // Normalization adds trailing spaces for hard line breaks, which would 1090 + // mutate the draft content on each save/reopen cycle. 1091 + if isDraft { 1092 + return plainText, "", webURL, attachments 1093 + } 1083 1094 return normalizePlainText(plainText), "", webURL, attachments 1084 1095 } 1085 1096 return "(no body)", "", webURL, attachments
+84
internal/imap/client_test.go
··· 302 302 t.Errorf("error = %q, want it to contain 'refusing unencrypted'", err.Error()) 303 303 } 304 304 } 305 + 306 + func TestParseBody_DraftRoundTrip(t *testing.T) { 307 + // Test that draft content survives multiple save/load cycles without mutation. 308 + // This verifies the X-Neomd-Draft header correctly bypasses normalizePlainText. 309 + 310 + // Original markdown with various formatting that would be mutated by normalization 311 + originalBody := `Hello there 312 + 313 + This is line 1 314 + This is line 2 315 + 316 + **Bold text** and *italic text* 317 + 318 + [Link](https://example.com) 319 + 320 + Code: ` + "`inline code`" + ` 321 + 322 + -- 323 + Signature line 1 324 + Signature line 2` 325 + 326 + // Build a draft MIME message (plain text with X-Neomd-Draft header) 327 + draftMIME := "From: Alice <alice@example.com>\r\n" + 328 + "To: Bob <bob@example.com>\r\n" + 329 + "Subject: Test Draft\r\n" + 330 + "MIME-Version: 1.0\r\n" + 331 + "Content-Type: text/plain; charset=utf-8\r\n" + 332 + "X-Neomd-Draft: true\r\n" + 333 + "\r\n" + 334 + originalBody 335 + 336 + // First parse (simulating draft reopen) 337 + body1, _, _, _ := parseBody([]byte(draftMIME)) 338 + 339 + // Verify the body matches exactly (no trailing spaces added) 340 + if body1 != originalBody { 341 + t.Errorf("first parse mutated draft content\ngot:\n%q\nwant:\n%q", body1, originalBody) 342 + } 343 + 344 + // Second parse (simulating a save/reopen cycle) 345 + draftMIME2 := "From: Alice <alice@example.com>\r\n" + 346 + "To: Bob <bob@example.com>\r\n" + 347 + "Subject: Test Draft\r\n" + 348 + "MIME-Version: 1.0\r\n" + 349 + "Content-Type: text/plain; charset=utf-8\r\n" + 350 + "X-Neomd-Draft: true\r\n" + 351 + "\r\n" + 352 + body1 // Use the result from first parse 353 + 354 + body2, _, _, _ := parseBody([]byte(draftMIME2)) 355 + 356 + // Verify still matches exactly (no accumulation of trailing spaces) 357 + if body2 != originalBody { 358 + t.Errorf("second parse mutated draft content\ngot:\n%q\nwant:\n%q", body2, originalBody) 359 + } 360 + 361 + // Verify they're all equal 362 + if body1 != body2 { 363 + t.Errorf("draft content changed between parse cycles\nfirst:\n%q\nsecond:\n%q", body1, body2) 364 + } 365 + } 366 + 367 + func TestParseBody_NonDraftGetsNormalized(t *testing.T) { 368 + // Test that regular emails (without X-Neomd-Draft) still get normalizePlainText applied. 369 + 370 + originalBody := "Line 1\nLine 2" 371 + 372 + // Regular email (no X-Neomd-Draft header) 373 + regularMIME := "From: Alice <alice@example.com>\r\n" + 374 + "To: Bob <bob@example.com>\r\n" + 375 + "Subject: Regular Email\r\n" + 376 + "MIME-Version: 1.0\r\n" + 377 + "Content-Type: text/plain; charset=utf-8\r\n" + 378 + "\r\n" + 379 + originalBody 380 + 381 + body, _, _, _ := parseBody([]byte(regularMIME)) 382 + 383 + // Normalization should add two trailing spaces before the newline 384 + expectedNormalized := "Line 1 \nLine 2" 385 + if body != expectedNormalized { 386 + t.Errorf("normalization not applied to regular email\ngot:\n%q\nwant:\n%q", body, expectedNormalized) 387 + } 388 + }
+10 -3
internal/smtp/sender.go
··· 247 247 // BCC must not be passed — it must never appear in message headers. 248 248 // When attachments is non-empty the message is wrapped in multipart/mixed; 249 249 // otherwise the structure is unchanged (multipart/alternative only). 250 - // htmlSignature, if non-empty, is appended to the text/html part after markdown conversion. 250 + // htmlSignature, if non-empty, is injected before the closing </body> tag in the HTML part. 251 251 func BuildMessage(from, to, cc, subject, markdownBody string, attachments []string, htmlSignature string) ([]byte, error) { 252 252 htmlBody, err := render.ToHTML(markdownBody) 253 253 if err != nil { 254 254 return nil, fmt.Errorf("markdown to html: %w", err) 255 255 } 256 - // Append HTML signature to the HTML part if provided 256 + // Inject HTML signature before </body> tag if provided 257 257 if htmlSignature != "" { 258 - htmlBody = htmlBody + "\n" + htmlSignature 258 + // Replace the last occurrence of </body> with signature + </body> 259 + // This ensures the signature is inside the HTML document structure 260 + idx := strings.LastIndex(htmlBody, "</body>") 261 + if idx >= 0 { 262 + htmlBody = htmlBody[:idx] + "\n" + htmlSignature + "\n" + htmlBody[idx:] 263 + } 259 264 } 260 265 return buildMessage(from, to, cc, subject, markdownBody, htmlBody, attachments) 261 266 } ··· 355 360 hdr("Content-Type", "text/plain; charset=utf-8") 356 361 hdr("Content-Transfer-Encoding", "quoted-printable") 357 362 hdr("X-Mailer", "neomd") 363 + hdr("X-Neomd-Draft", "true") 358 364 b.WriteString("\r\n") 359 365 writeQP(&b, plainText) 360 366 ··· 378 384 hdr("MIME-Version", "1.0") 379 385 hdr("Content-Type", `multipart/mixed; boundary="`+mixedBoundary+`"`) 380 386 hdr("X-Mailer", "neomd") 387 + hdr("X-Neomd-Draft", "true") 381 388 b.WriteString("\r\n") 382 389 fmt.Fprintf(&b, "--%s\r\n", mixedBoundary) 383 390 b.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
+24
internal/smtp/sender_test.go
··· 561 561 if !strings.Contains(htmlStr, "table") || !strings.Contains(htmlStr, "John Doe") { 562 562 t.Errorf("text/html part missing HTML signature, got:\n%s", htmlStr) 563 563 } 564 + 565 + // CRITICAL: Verify the signature is placed BEFORE </body>, not after </html> 566 + // The signature should be inside the HTML document structure 567 + bodyCloseIdx := strings.Index(htmlStr, "</body>") 568 + htmlCloseIdx := strings.Index(htmlStr, "</html>") 569 + signatureIdx := strings.Index(htmlStr, "table") 570 + 571 + if bodyCloseIdx < 0 || htmlCloseIdx < 0 { 572 + t.Fatal("HTML document missing </body> or </html> tags") 573 + } 574 + if signatureIdx < 0 { 575 + t.Fatal("HTML signature not found in output") 576 + } 577 + 578 + // Signature must come BEFORE </body> (inside the document) 579 + if signatureIdx >= bodyCloseIdx { 580 + t.Errorf("HTML signature is placed AFTER </body> (position %d >= %d)\nThis creates malformed HTML where the signature is outside the document structure.\nFull HTML:\n%s", 581 + signatureIdx, bodyCloseIdx, htmlStr) 582 + } 583 + // Signature must come BEFORE </html> (obviously) 584 + if signatureIdx >= htmlCloseIdx { 585 + t.Errorf("HTML signature is placed AFTER </html> (position %d >= %d)\nFull HTML:\n%s", 586 + signatureIdx, htmlCloseIdx, htmlStr) 587 + } 564 588 } 565 589 566 590 func TestBuildMessage_WithoutHTMLSignature(t *testing.T) {
+16 -1
internal/ui/model.go
··· 3247 3247 return m, nil 3248 3248 } 3249 3249 3250 - htmlBody, err := render.ToHTML(ps.body) 3250 + // Extract [html-signature] marker the same way as the send path 3251 + // so the preview matches what recipients will actually receive 3252 + includeHTMLSig, cleanBody := extractHTMLSignatureMarker(ps.body) 3253 + 3254 + htmlBody, err := render.ToHTML(cleanBody) 3251 3255 if err != nil { 3252 3256 m.status = "preview: " + err.Error() 3253 3257 m.isError = true 3254 3258 return m, nil 3259 + } 3260 + 3261 + // Inject HTML signature before </body> tag if enabled (matching send path) 3262 + if includeHTMLSig { 3263 + htmlSig := m.cfg.UI.HTMLSignature() 3264 + if htmlSig != "" { 3265 + idx := strings.LastIndex(htmlBody, "</body>") 3266 + if idx >= 0 { 3267 + htmlBody = htmlBody[:idx] + "\n" + htmlSig + "\n" + htmlBody[idx:] 3268 + } 3269 + } 3255 3270 } 3256 3271 3257 3272 // Convert absolute image paths to file:// URLs so the browser can display them.