···29293030# Multiple accounts supported — add more [[accounts]] blocks
3131# Switch between them with `ctrl+a` in the inbox
3232+3333+# Root-level settings
3234store_sent_drafts_in_sending_account = false # default: Sent/Drafts stay in the first IMAP account
33353436# 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
···975975 return string(raw), "", "", nil
976976 }
977977978978+ // Check if this is a neomd-authored draft. Drafts use the X-Neomd-Draft header
979979+ // to signal that the plain text body is already markdown and should not be
980980+ // normalized (which adds trailing spaces and would mutate the draft on each save/load).
981981+ isDraft := e.Header.Get("X-Neomd-Draft") == "true"
982982+978983 // List-Post header contains the canonical article URL on most newsletters:
979984 // List-Post: <https://newsletter.example.com/p/slug>
980985 if lp := e.Header.Get("List-Post"); lp != "" {
···10801085 return htmlToMarkdown(htmlText), htmlText, webURL, attachments
10811086 }
10821087 if plainText != "" {
10881088+ // For neomd drafts, return the raw markdown without normalization.
10891089+ // Normalization adds trailing spaces for hard line breaks, which would
10901090+ // mutate the draft content on each save/reopen cycle.
10911091+ if isDraft {
10921092+ return plainText, "", webURL, attachments
10931093+ }
10831094 return normalizePlainText(plainText), "", webURL, attachments
10841095 }
10851096 return "(no body)", "", webURL, attachments
+84
internal/imap/client_test.go
···302302 t.Errorf("error = %q, want it to contain 'refusing unencrypted'", err.Error())
303303 }
304304}
305305+306306+func TestParseBody_DraftRoundTrip(t *testing.T) {
307307+ // Test that draft content survives multiple save/load cycles without mutation.
308308+ // This verifies the X-Neomd-Draft header correctly bypasses normalizePlainText.
309309+310310+ // Original markdown with various formatting that would be mutated by normalization
311311+ originalBody := `Hello there
312312+313313+This is line 1
314314+This is line 2
315315+316316+**Bold text** and *italic text*
317317+318318+[Link](https://example.com)
319319+320320+Code: ` + "`inline code`" + `
321321+322322+--
323323+Signature line 1
324324+Signature line 2`
325325+326326+ // Build a draft MIME message (plain text with X-Neomd-Draft header)
327327+ draftMIME := "From: Alice <alice@example.com>\r\n" +
328328+ "To: Bob <bob@example.com>\r\n" +
329329+ "Subject: Test Draft\r\n" +
330330+ "MIME-Version: 1.0\r\n" +
331331+ "Content-Type: text/plain; charset=utf-8\r\n" +
332332+ "X-Neomd-Draft: true\r\n" +
333333+ "\r\n" +
334334+ originalBody
335335+336336+ // First parse (simulating draft reopen)
337337+ body1, _, _, _ := parseBody([]byte(draftMIME))
338338+339339+ // Verify the body matches exactly (no trailing spaces added)
340340+ if body1 != originalBody {
341341+ t.Errorf("first parse mutated draft content\ngot:\n%q\nwant:\n%q", body1, originalBody)
342342+ }
343343+344344+ // Second parse (simulating a save/reopen cycle)
345345+ draftMIME2 := "From: Alice <alice@example.com>\r\n" +
346346+ "To: Bob <bob@example.com>\r\n" +
347347+ "Subject: Test Draft\r\n" +
348348+ "MIME-Version: 1.0\r\n" +
349349+ "Content-Type: text/plain; charset=utf-8\r\n" +
350350+ "X-Neomd-Draft: true\r\n" +
351351+ "\r\n" +
352352+ body1 // Use the result from first parse
353353+354354+ body2, _, _, _ := parseBody([]byte(draftMIME2))
355355+356356+ // Verify still matches exactly (no accumulation of trailing spaces)
357357+ if body2 != originalBody {
358358+ t.Errorf("second parse mutated draft content\ngot:\n%q\nwant:\n%q", body2, originalBody)
359359+ }
360360+361361+ // Verify they're all equal
362362+ if body1 != body2 {
363363+ t.Errorf("draft content changed between parse cycles\nfirst:\n%q\nsecond:\n%q", body1, body2)
364364+ }
365365+}
366366+367367+func TestParseBody_NonDraftGetsNormalized(t *testing.T) {
368368+ // Test that regular emails (without X-Neomd-Draft) still get normalizePlainText applied.
369369+370370+ originalBody := "Line 1\nLine 2"
371371+372372+ // Regular email (no X-Neomd-Draft header)
373373+ regularMIME := "From: Alice <alice@example.com>\r\n" +
374374+ "To: Bob <bob@example.com>\r\n" +
375375+ "Subject: Regular Email\r\n" +
376376+ "MIME-Version: 1.0\r\n" +
377377+ "Content-Type: text/plain; charset=utf-8\r\n" +
378378+ "\r\n" +
379379+ originalBody
380380+381381+ body, _, _, _ := parseBody([]byte(regularMIME))
382382+383383+ // Normalization should add two trailing spaces before the newline
384384+ expectedNormalized := "Line 1 \nLine 2"
385385+ if body != expectedNormalized {
386386+ t.Errorf("normalization not applied to regular email\ngot:\n%q\nwant:\n%q", body, expectedNormalized)
387387+ }
388388+}
+10-3
internal/smtp/sender.go
···247247// BCC must not be passed — it must never appear in message headers.
248248// When attachments is non-empty the message is wrapped in multipart/mixed;
249249// otherwise the structure is unchanged (multipart/alternative only).
250250-// htmlSignature, if non-empty, is appended to the text/html part after markdown conversion.
250250+// htmlSignature, if non-empty, is injected before the closing </body> tag in the HTML part.
251251func BuildMessage(from, to, cc, subject, markdownBody string, attachments []string, htmlSignature string) ([]byte, error) {
252252 htmlBody, err := render.ToHTML(markdownBody)
253253 if err != nil {
254254 return nil, fmt.Errorf("markdown to html: %w", err)
255255 }
256256- // Append HTML signature to the HTML part if provided
256256+ // Inject HTML signature before </body> tag if provided
257257 if htmlSignature != "" {
258258- htmlBody = htmlBody + "\n" + htmlSignature
258258+ // Replace the last occurrence of </body> with signature + </body>
259259+ // This ensures the signature is inside the HTML document structure
260260+ idx := strings.LastIndex(htmlBody, "</body>")
261261+ if idx >= 0 {
262262+ htmlBody = htmlBody[:idx] + "\n" + htmlSignature + "\n" + htmlBody[idx:]
263263+ }
259264 }
260265 return buildMessage(from, to, cc, subject, markdownBody, htmlBody, attachments)
261266}
···355360 hdr("Content-Type", "text/plain; charset=utf-8")
356361 hdr("Content-Transfer-Encoding", "quoted-printable")
357362 hdr("X-Mailer", "neomd")
363363+ hdr("X-Neomd-Draft", "true")
358364 b.WriteString("\r\n")
359365 writeQP(&b, plainText)
360366···378384 hdr("MIME-Version", "1.0")
379385 hdr("Content-Type", `multipart/mixed; boundary="`+mixedBoundary+`"`)
380386 hdr("X-Mailer", "neomd")
387387+ hdr("X-Neomd-Draft", "true")
381388 b.WriteString("\r\n")
382389 fmt.Fprintf(&b, "--%s\r\n", mixedBoundary)
383390 b.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
+24
internal/smtp/sender_test.go
···561561 if !strings.Contains(htmlStr, "table") || !strings.Contains(htmlStr, "John Doe") {
562562 t.Errorf("text/html part missing HTML signature, got:\n%s", htmlStr)
563563 }
564564+565565+ // CRITICAL: Verify the signature is placed BEFORE </body>, not after </html>
566566+ // The signature should be inside the HTML document structure
567567+ bodyCloseIdx := strings.Index(htmlStr, "</body>")
568568+ htmlCloseIdx := strings.Index(htmlStr, "</html>")
569569+ signatureIdx := strings.Index(htmlStr, "table")
570570+571571+ if bodyCloseIdx < 0 || htmlCloseIdx < 0 {
572572+ t.Fatal("HTML document missing </body> or </html> tags")
573573+ }
574574+ if signatureIdx < 0 {
575575+ t.Fatal("HTML signature not found in output")
576576+ }
577577+578578+ // Signature must come BEFORE </body> (inside the document)
579579+ if signatureIdx >= bodyCloseIdx {
580580+ 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",
581581+ signatureIdx, bodyCloseIdx, htmlStr)
582582+ }
583583+ // Signature must come BEFORE </html> (obviously)
584584+ if signatureIdx >= htmlCloseIdx {
585585+ t.Errorf("HTML signature is placed AFTER </html> (position %d >= %d)\nFull HTML:\n%s",
586586+ signatureIdx, htmlCloseIdx, htmlStr)
587587+ }
564588}
565589566590func TestBuildMessage_WithoutHTMLSignature(t *testing.T) {
+16-1
internal/ui/model.go
···32473247 return m, nil
32483248 }
3249324932503250- htmlBody, err := render.ToHTML(ps.body)
32503250+ // Extract [html-signature] marker the same way as the send path
32513251+ // so the preview matches what recipients will actually receive
32523252+ includeHTMLSig, cleanBody := extractHTMLSignatureMarker(ps.body)
32533253+32543254+ htmlBody, err := render.ToHTML(cleanBody)
32513255 if err != nil {
32523256 m.status = "preview: " + err.Error()
32533257 m.isError = true
32543258 return m, nil
32593259+ }
32603260+32613261+ // Inject HTML signature before </body> tag if enabled (matching send path)
32623262+ if includeHTMLSig {
32633263+ htmlSig := m.cfg.UI.HTMLSignature()
32643264+ if htmlSig != "" {
32653265+ idx := strings.LastIndex(htmlBody, "</body>")
32663266+ if idx >= 0 {
32673267+ htmlBody = htmlBody[:idx] + "\n" + htmlSig + "\n" + htmlBody[idx:]
32683268+ }
32693269+ }
32553270 }
3256327132573272 // Convert absolute image paths to file:// URLs so the browser can display them.