···11# Changelog
2233# 2026-04-10
44+55+66+# 2026-04-10
77+- **HTML signature support** — new `[ui.signature_block]` config with separate `text` and `html` fields for dual-format signatures; text signature appears in the editor and text/plain MIME part, HTML signature appends to the text/html part only; use `[html-signature]` placeholder in text signature to control HTML signature inclusion per-email (visible in preview, deletable before sending); backward compatible with legacy `signature` field
88+- **Fix: draft formatting corruption** — drafts are now stored as plain text only instead of multipart/alternative to prevent HTML→markdown conversion artifacts; fixes line break addition, pipe escaping (`|` → `\|`), and italic style changes (`*` → `_`) when reopening saved drafts
49- **Sent/Drafts primary-account default restored** — in multi-account setups, Sent and Drafts now default back to the first configured IMAP account while SMTP still uses the selected sending identity; added `store_sent_drafts_in_sending_account = true` for users who want Sent/Drafts to follow the sending account instead
510- **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
611- **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
+1
README.md
···8181- **Link opener** — links in emails are numbered `[1]`-`[0]` in the reader header; press `space+digit` to open in `$BROWSER`
8282- **CC, BCC, Reply-all** — optional Cc/Bcc fields (toggle with `ctrl+b`); `R` in the reader replies to sender + all CC recipients
8383- **Drafts** — `d` in pre-send saves to Drafts (IMAP APPEND); `E` in the reader re-opens a draft as an editable compose; compose sessions are auto-backed up to `~/.cache/neomd/drafts/` so you never lose an unsent email (`:recover` to reopen)
8484+- **HTML signatures** — configure separate text and HTML signatures; text signature appears in editor and plain text part, HTML signature in HTML part only; use `[html-signature]` placeholder to control inclusion per-email
8485- **Multiple From addresses** — define SMTP-only `[[senders]]` aliases (e.g. `s@ssp.sh` through an existing account); cycle with `ctrl+f` in compose and pre-send; sent copies always land in the Sent folder
8586- **Undo** — `u` reverses the last move or delete (`x`, `A`, `M*`) using the UIDPLUS destination UID
8687- **Search** — `/` filters loaded emails in-memory; `space /` or `:search` runs IMAP SEARCH across all folders (only fetching header capped at 100 per folder) with results in a temporary "Search" tab; supports `from:`, `subject:`, `to:` prefixes
+44
docs/configuration.md
···180180181181Use TOML triple-quoted strings (`"""`) to preserve line breaks. The signature appears at the end of the buffer — you can edit or delete it before saving.
182182183183+### HTML Signatures
184184+185185+For professional HTML signatures (with logos, tables, styled text), use the `[ui.signature_block]` config with separate `text` and `html` fields:
186186+187187+```toml
188188+[ui.signature_block]
189189+ text = """[html-signature]"""
190190+191191+ html = """<table cellpadding="0" cellspacing="0" border="0" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; line-height: 1.6; color: #333; margin-top: 20px;">
192192+ <tr>
193193+ <td style="padding-right: 20px; vertical-align: top;">
194194+ <img src="https://example.com/logo.png" alt="Company Name" width="80" style="display: block; border: 0;">
195195+ </td>
196196+ <td style="border-left: 2px solid #e0e0e0; padding-left: 20px;">
197197+ <div style="margin-bottom: 8px;">
198198+ <strong style="font-size: 16px; color: #1a1a1a;">Your Name</strong><br>
199199+ <span style="color: #666; font-size: 13px;">Your Title, Company Name</span>
200200+ </div>
201201+ <div style="margin-bottom: 6px; font-size: 13px; color: #888;">
202202+ <span>Connect:</span>
203203+ <a href="https://linkedin.com/in/..." style="text-decoration: none; margin: 0 4px;">LinkedIn</a>
204204+ </div>
205205+ <div style="font-size: 11px; color: #999; font-style: italic;">
206206+ sent from <a href="https://neomd.ssp.sh" style="text-decoration: none;">neomd</a>
207207+ </div>
208208+ </td>
209209+ </tr>
210210+</table>"""
211211+```
212212+213213+**How it works:**
214214+215215+- **Text signature** — appears in the editor buffer and in the `text/plain` MIME part
216216+- **HTML signature** — appends to the `text/html` MIME part only (recipients using HTML email clients see this)
217217+- **`[html-signature]` placeholder** — include this in your text signature to enable HTML signature for a specific email; visible in the editor and pre-send preview, but stripped before sending
218218+- **Per-email control** — delete the `[html-signature]` line in the editor to send without the HTML signature for that email
219219+220220+**Notes:**
221221+222222+- Use inline styles only (no `<style>` blocks or external CSS) for maximum email client compatibility
223223+- Host logo images externally (e.g., `https://example.com/logo.png`) so they display for recipients
224224+- The `text` field is backward compatible: if empty, neomd falls back to the legacy `signature` field
225225+- The `--` separator is added automatically before the text signature
226226+183227## OAuth2 Authentication
184228185229Neomd supports OpenAuth2 authenticated accounts, you just need to add `oauth2_client_id`, `oauth2_client_secret`, `oauth2_scopes` and `oauth2_issuer_url`.
+49
docs/sending.md
···78787979Press `d` in the pre-send screen to save to Drafts instead of sending. Navigate to Drafts with `gd`. To resume a saved draft, open it and press `E` — it re-opens in the editor with all fields pre-filled, and saving goes through the normal pre-send review.
80808181+**Note:** Drafts are stored as plain text only (not multipart/alternative) to preserve markdown formatting when reopening. This prevents formatting corruption like line break addition, pipe escaping, and italic style changes.
8282+8383+## HTML Signatures
8484+8585+neomd supports dual-format signatures for professional email layouts with logos, tables, and styled text.
8686+8787+Configure separate text and HTML signatures in `[ui.signature_block]`:
8888+8989+```toml
9090+[ui.signature_block]
9191+ text = """[html-signature]"""
9292+9393+ html = """<table style="font-size: 14px; color: #333;">
9494+ <tr>
9595+ <td><img src="https://example.com/logo.png" width="80"></td>
9696+ <td>
9797+ <strong>Your Name</strong><br>
9898+ Your Title, Company Name
9999+ </td>
100100+ </tr>
101101+</table>"""
102102+```
103103+104104+**How it works:**
105105+106106+- The **text signature** appears in the editor and in the `text/plain` MIME part
107107+- The **HTML signature** is appended to the `text/html` MIME part only
108108+- Recipients using HTML email clients see the styled HTML signature
109109+- Recipients using plain text clients see the text signature
110110+111111+**The `[html-signature]` placeholder:**
112112+113113+Include `[html-signature]` in your text signature (as shown above) to control HTML signature inclusion on a per-email basis:
114114+115115+- The placeholder is **visible** in the editor and pre-send preview
116116+- When you send, neomd strips the placeholder and appends the HTML signature to the HTML part
117117+- **Delete the placeholder** in the editor to send without the HTML signature for that specific email
118118+119119+This gives you full control: professional HTML signatures by default, plain signatures when needed.
120120+121121+**Best practices:**
122122+123123+- Use inline styles only (no `<style>` blocks) for maximum email client compatibility
124124+- Host images externally (`https://example.com/logo.png`) so they display for recipients
125125+- Test your HTML signature by sending to yourself first
126126+- The `--` separator is added automatically before the text signature
127127+128128+For full HTML signature configuration examples, see [configuration.md](configuration.md#html-signatures).
129129+81130For reading emails — images, links, attachments, and navigation — see [reading.md](reading.md).
+28-7
internal/config/config.go
···118118 return tabs
119119}
120120121121+// SignatureConfig holds plain text and HTML signature blocks.
122122+type SignatureConfig struct {
123123+ Text string `toml:"text"` // markdown/plain text signature for text/plain part and editor
124124+ HTML string `toml:"html"` // optional HTML signature injected into text/html part
125125+}
126126+121127// UIConfig holds display preferences.
122128type UIConfig struct {
123123- Theme string `toml:"theme"` // dark | light | auto
124124- InboxCount int `toml:"inbox_count"` // number of messages to fetch
125125- Signature string `toml:"signature"` // appended to new compose buffers (markdown)
126126- AutoScreenOnLoad *bool `toml:"auto_screen_on_load"` // screen inbox on every load (default true)
127127- BgSyncInterval int `toml:"bg_sync_interval"` // background sync interval in minutes (0 = disabled, default 5)
128128- BulkProgressThreshold int `toml:"bulk_progress_threshold"` // show progress counter for batches larger than this (default 10)
129129- DraftBackupCount int `toml:"draft_backup_count"` // rolling compose backups in ~/.cache/neomd/drafts/ (default 20, -1 = disabled)
129129+ Theme string `toml:"theme"` // dark | light | auto
130130+ InboxCount int `toml:"inbox_count"` // number of messages to fetch
131131+ Signature string `toml:"signature"` // legacy: plain signature (markdown). Deprecated in favor of [ui.signature] block.
132132+ SignatureBlock SignatureConfig `toml:"signature_block"` // new structured signature config
133133+ AutoScreenOnLoad *bool `toml:"auto_screen_on_load"` // screen inbox on every load (default true)
134134+ BgSyncInterval int `toml:"bg_sync_interval"` // background sync interval in minutes (0 = disabled, default 5)
135135+ BulkProgressThreshold int `toml:"bulk_progress_threshold"` // show progress counter for batches larger than this (default 10)
136136+ DraftBackupCount int `toml:"draft_backup_count"` // rolling compose backups in ~/.cache/neomd/drafts/ (default 20, -1 = disabled)
137137+}
138138+139139+// TextSignature returns the text/markdown signature for editor and text/plain part.
140140+// Prefers signature_block.text, falls back to legacy signature field.
141141+func (u UIConfig) TextSignature() string {
142142+ if u.SignatureBlock.Text != "" {
143143+ return u.SignatureBlock.Text
144144+ }
145145+ return u.Signature
146146+}
147147+148148+// HTMLSignature returns the HTML signature for text/html part, or empty if not configured.
149149+func (u UIConfig) HTMLSignature() string {
150150+ return u.SignatureBlock.HTML
130151}
131152132153// DraftBackups returns the max number of rolling draft backups (default 20, -1 = disabled).
+2-2
internal/integration_test.go
···509509 body := "This email tests SaveSent IMAP APPEND."
510510511511 // Build the message (same as neomd does before sending)
512512- raw, err := smtp.BuildMessage(env.from, env.user, "", subject, body, nil)
512512+ raw, err := smtp.BuildMessage(env.from, env.user, "", subject, body, nil, "")
513513 if err != nil {
514514 t.Fatalf("BuildMessage: %v", err)
515515 }
···601601 // The email lands in demo's Sent (via SaveSent) but also in demo's INBOX
602602 // if demo is in CC. Since demo is not in To/CC here, we save to Sent to
603603 // have a copy to inspect.
604604- raw, err := smtp.BuildMessage(env.from, user2, user3, origSubject, origBody, nil)
604604+ raw, err := smtp.BuildMessage(env.from, user2, user3, origSubject, origBody, nil, "")
605605 if err != nil {
606606 t.Fatalf("BuildMessage: %v", err)
607607 }
+6-1
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-func BuildMessage(from, to, cc, subject, markdownBody string, attachments []string) ([]byte, error) {
250250+// htmlSignature, if non-empty, is appended to the text/html part after markdown conversion.
251251+func BuildMessage(from, to, cc, subject, markdownBody string, attachments []string, htmlSignature string) ([]byte, error) {
251252 htmlBody, err := render.ToHTML(markdownBody)
252253 if err != nil {
253254 return nil, fmt.Errorf("markdown to html: %w", err)
255255+ }
256256+ // Append HTML signature to the HTML part if provided
257257+ if htmlSignature != "" {
258258+ htmlBody = htmlBody + "\n" + htmlSignature
254259 }
255260 return buildMessage(from, to, cc, subject, markdownBody, htmlBody, attachments)
256261}
+93
internal/smtp/sender_test.go
···498498 }
499499}
500500501501+func TestBuildMessage_WithHTMLSignature(t *testing.T) {
502502+ // Test that HTML signature is appended to text/html part only
503503+ markdownBody := "Hello world!"
504504+ htmlSig := `<table style="font-size:12px"><tr><td>John Doe</td></tr></table>`
505505+506506+ raw, err := BuildMessage(
507507+ "Alice <alice@example.com>",
508508+ "Bob <bob@example.com>",
509509+ "",
510510+ "Test HTML Signature",
511511+ markdownBody,
512512+ nil,
513513+ htmlSig,
514514+ )
515515+ if err != nil {
516516+ t.Fatalf("BuildMessage: %v", err)
517517+ }
518518+519519+ _, mediaType, params := parseMIME(t, raw)
520520+ if mediaType != "multipart/alternative" {
521521+ t.Fatalf("expected multipart/alternative, got %s", mediaType)
522522+ }
523523+524524+ msg, _ := mail.ReadMessage(bytes.NewReader(raw))
525525+ mr := multipart.NewReader(msg.Body, params["boundary"])
526526+527527+ // Read text/plain part
528528+ part0, err := mr.NextPart()
529529+ if err != nil {
530530+ t.Fatalf("NextPart 0: %v", err)
531531+ }
532532+ ct0, _, _ := mime.ParseMediaType(part0.Header.Get("Content-Type"))
533533+ if ct0 != "text/plain" {
534534+ t.Errorf("part 0: expected text/plain, got %s", ct0)
535535+ }
536536+ plainBody, _ := io.ReadAll(part0)
537537+ plainStr := string(plainBody)
538538+ if strings.Contains(plainStr, "table") || strings.Contains(plainStr, "<tr>") {
539539+ t.Error("text/plain part contains HTML signature (should be plain text only)")
540540+ }
541541+ // QP encoding preserves "Hello world" as-is (all ASCII)
542542+ if !strings.Contains(plainStr, "Hello world") {
543543+ t.Errorf("text/plain part missing body content, got:\n%s", plainStr)
544544+ }
545545+546546+ // Read text/html part
547547+ part1, err := mr.NextPart()
548548+ if err != nil {
549549+ t.Fatalf("NextPart 1: %v", err)
550550+ }
551551+ ct1, _, _ := mime.ParseMediaType(part1.Header.Get("Content-Type"))
552552+ if ct1 != "text/html" {
553553+ t.Errorf("part 1: expected text/html, got %s", ct1)
554554+ }
555555+ htmlBody, _ := io.ReadAll(part1)
556556+ htmlStr := string(htmlBody)
557557+ // Check for key parts (QP may have soft line breaks, but these strings should appear)
558558+ if !strings.Contains(htmlStr, "Hello world") {
559559+ t.Errorf("text/html part missing body content, got:\n%s", htmlStr)
560560+ }
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+566566+func TestBuildMessage_WithoutHTMLSignature(t *testing.T) {
567567+ // Passing empty htmlSignature should work fine (backward compatibility)
568568+ raw, err := BuildMessage(
569569+ "Alice <alice@example.com>",
570570+ "Bob <bob@example.com>",
571571+ "",
572572+ "No HTML Signature",
573573+ "plain body",
574574+ nil,
575575+ "", // empty HTML signature
576576+ )
577577+ if err != nil {
578578+ t.Fatalf("BuildMessage: %v", err)
579579+ }
580580+581581+ // Should still produce multipart/alternative with both parts
582582+ msg, mediaType, params := parseMIME(t, raw)
583583+ if mediaType != "multipart/alternative" {
584584+ t.Errorf("expected multipart/alternative, got %s", mediaType)
585585+ }
586586+587587+ mr := multipart.NewReader(msg.Body, params["boundary"])
588588+ parts := readParts(t, mr)
589589+ if len(parts) != 2 {
590590+ t.Errorf("expected 2 parts, got %d", len(parts))
591591+ }
592592+}
593593+501594func TestInferSMTPUseTLS(t *testing.T) {
502595 tests := []struct {
503596 name string
+28-4
internal/ui/model.go
···728728 }
729729}
730730731731-func (m Model) sendEmailCmd(smtpAcct config.AccountConfig, from, to, cc, bcc, subject, body string, attachments []string, replyToUID uint32, replyToFolder, replyToAccount string) tea.Cmd {
731731+func (m Model) sendEmailCmd(smtpAcct config.AccountConfig, from, to, cc, bcc, subject, body string, attachments []string, includeHTMLSig bool, replyToUID uint32, replyToFolder, replyToAccount string) tea.Cmd {
732732 h, p := splitAddr(smtpAcct.SMTP)
733733 cfg := smtp.Config{
734734 Host: h,
···743743 cli := m.sentDraftsIMAPClient()
744744 sentFolder := m.cfg.Folders.Sent
745745 replyCli := m.imapCliForAccount(replyToAccount)
746746+ htmlSignature := ""
747747+ if includeHTMLSig {
748748+ htmlSignature = m.cfg.UI.HTMLSignature()
749749+ }
746750 return func() tea.Msg {
747751 // Build raw MIME once — reused for both SMTP delivery and Sent copy.
748752 // BCC is intentionally excluded from headers but included in RCPT TO.
749749- raw, err := smtp.BuildMessage(from, to, cc, subject, body, attachments)
753753+ raw, err := smtp.BuildMessage(from, to, cc, subject, body, attachments, htmlSignature)
750754 if err != nil {
751755 return sendDoneMsg{err: fmt.Errorf("build message: %w", err)}
752756 }
···18181822 return m, nil
18191823 }
18201824 // Strip editor header hints and extract [attach] lines.
18251825+ // [html-signature] marker is NOT extracted yet — keep it in body for preview.
18211826 inlineAttach, cleanBody := extractInlineAttachments(stripPrelude(msg.body))
18221827 m.attachments = append(m.attachments, inlineAttach...)
18231828 m.applyEditedFrom(msg.from)
···31133118 smtpAcct := m.presendSMTPAccount()
31143119 attachments := m.attachments
31153120 replyUID, replyFolder := ps.replyToUID, ps.replyToFolder
31213121+ // Extract [html-signature] marker from body now (right before sending)
31223122+ includeHTMLSig, cleanBody := extractHTMLSignatureMarker(ps.body)
31163123 m.attachments = nil
31173124 m.pendingSend = nil
31183118- return m, tea.Batch(m.spinner.Tick, m.sendEmailCmd(smtpAcct, from, ps.to, ps.cc, ps.bcc, ps.subject, ps.body, attachments, replyUID, replyFolder, ps.replyToAccount))
31253125+ return m, tea.Batch(m.spinner.Tick, m.sendEmailCmd(smtpAcct, from, ps.to, ps.cc, ps.bcc, ps.subject, cleanBody, attachments, includeHTMLSig, replyUID, replyFolder, ps.replyToAccount))
31193126 case "ctrl+f":
31203127 froms := m.presendFroms()
31213128 if len(froms) <= 1 {
···32963303 cc := m.compose.cc.Value()
32973304 bcc := m.compose.bcc.Value()
32983305 subject := m.compose.subject.Value()
32993299- prelude := editor.Prelude(to, cc, bcc, m.presendFrom(), subject, m.cfg.UI.Signature)
33063306+ prelude := editor.Prelude(to, cc, bcc, m.presendFrom(), subject, m.cfg.UI.TextSignature())
3300330733013308 // Write temp file
33023309 f, err := os.CreateTemp(neomdTempDir(), "neomd-*.md")
···37353742 kept = append(kept, line)
37363743 }
37373744 return files, strings.Join(kept, "\n")
37453745+}
37463746+37473747+// extractHTMLSignatureMarker scans body for [html-signature] marker.
37483748+// If found, removes it and returns (true, cleanBody).
37493749+// If not found, returns (false, body unchanged).
37503750+func extractHTMLSignatureMarker(body string) (includeHTMLSig bool, clean string) {
37513751+ const marker = "[html-signature]"
37523752+ var kept []string
37533753+ for _, line := range strings.Split(body, "\n") {
37543754+ trimmed := strings.TrimSpace(line)
37553755+ if trimmed == marker {
37563756+ includeHTMLSig = true
37573757+ continue
37583758+ }
37593759+ kept = append(kept, line)
37603760+ }
37613761+ return includeHTMLSig, strings.Join(kept, "\n")
37383762}
3739376337403764// ── View ──────────────────────────────────────────────────────────────────