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 HTML Signatures

sspaeti d9b2bda6 c061c9e6

+258 -15
+2 -1
.claude/settings.local.json
··· 4 4 "Bash(go build:*)", 5 5 "Bash(go test:*)", 6 6 "Bash(make build:*)", 7 - "Bash(make test:*)" 7 + "Bash(make test:*)", 8 + "WebFetch(domain:www.ssp.sh)" 8 9 ] 9 10 } 10 11 }
+5
CHANGELOG.md
··· 1 1 # Changelog 2 2 3 3 # 2026-04-10 4 + 5 + 6 + # 2026-04-10 7 + - **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 8 + - **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 4 9 - **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 5 10 - **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 6 11 - **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
··· 81 81 - **Link opener** — links in emails are numbered `[1]`-`[0]` in the reader header; press `space+digit` to open in `$BROWSER` 82 82 - **CC, BCC, Reply-all** — optional Cc/Bcc fields (toggle with `ctrl+b`); `R` in the reader replies to sender + all CC recipients 83 83 - **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) 84 + - **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 84 85 - **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 85 86 - **Undo** — `u` reverses the last move or delete (`x`, `A`, `M*`) using the UIDPLUS destination UID 86 87 - **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
··· 180 180 181 181 Use 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. 182 182 183 + ### HTML Signatures 184 + 185 + For professional HTML signatures (with logos, tables, styled text), use the `[ui.signature_block]` config with separate `text` and `html` fields: 186 + 187 + ```toml 188 + [ui.signature_block] 189 + text = """[html-signature]""" 190 + 191 + 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;"> 192 + <tr> 193 + <td style="padding-right: 20px; vertical-align: top;"> 194 + <img src="https://example.com/logo.png" alt="Company Name" width="80" style="display: block; border: 0;"> 195 + </td> 196 + <td style="border-left: 2px solid #e0e0e0; padding-left: 20px;"> 197 + <div style="margin-bottom: 8px;"> 198 + <strong style="font-size: 16px; color: #1a1a1a;">Your Name</strong><br> 199 + <span style="color: #666; font-size: 13px;">Your Title, Company Name</span> 200 + </div> 201 + <div style="margin-bottom: 6px; font-size: 13px; color: #888;"> 202 + <span>Connect:</span> 203 + <a href="https://linkedin.com/in/..." style="text-decoration: none; margin: 0 4px;">LinkedIn</a> 204 + </div> 205 + <div style="font-size: 11px; color: #999; font-style: italic;"> 206 + sent from <a href="https://neomd.ssp.sh" style="text-decoration: none;">neomd</a> 207 + </div> 208 + </td> 209 + </tr> 210 + </table>""" 211 + ``` 212 + 213 + **How it works:** 214 + 215 + - **Text signature** — appears in the editor buffer and in the `text/plain` MIME part 216 + - **HTML signature** — appends to the `text/html` MIME part only (recipients using HTML email clients see this) 217 + - **`[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 218 + - **Per-email control** — delete the `[html-signature]` line in the editor to send without the HTML signature for that email 219 + 220 + **Notes:** 221 + 222 + - Use inline styles only (no `<style>` blocks or external CSS) for maximum email client compatibility 223 + - Host logo images externally (e.g., `https://example.com/logo.png`) so they display for recipients 224 + - The `text` field is backward compatible: if empty, neomd falls back to the legacy `signature` field 225 + - The `--` separator is added automatically before the text signature 226 + 183 227 ## OAuth2 Authentication 184 228 185 229 Neomd 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
··· 78 78 79 79 Press `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. 80 80 81 + **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. 82 + 83 + ## HTML Signatures 84 + 85 + neomd supports dual-format signatures for professional email layouts with logos, tables, and styled text. 86 + 87 + Configure separate text and HTML signatures in `[ui.signature_block]`: 88 + 89 + ```toml 90 + [ui.signature_block] 91 + text = """[html-signature]""" 92 + 93 + html = """<table style="font-size: 14px; color: #333;"> 94 + <tr> 95 + <td><img src="https://example.com/logo.png" width="80"></td> 96 + <td> 97 + <strong>Your Name</strong><br> 98 + Your Title, Company Name 99 + </td> 100 + </tr> 101 + </table>""" 102 + ``` 103 + 104 + **How it works:** 105 + 106 + - The **text signature** appears in the editor and in the `text/plain` MIME part 107 + - The **HTML signature** is appended to the `text/html` MIME part only 108 + - Recipients using HTML email clients see the styled HTML signature 109 + - Recipients using plain text clients see the text signature 110 + 111 + **The `[html-signature]` placeholder:** 112 + 113 + Include `[html-signature]` in your text signature (as shown above) to control HTML signature inclusion on a per-email basis: 114 + 115 + - The placeholder is **visible** in the editor and pre-send preview 116 + - When you send, neomd strips the placeholder and appends the HTML signature to the HTML part 117 + - **Delete the placeholder** in the editor to send without the HTML signature for that specific email 118 + 119 + This gives you full control: professional HTML signatures by default, plain signatures when needed. 120 + 121 + **Best practices:** 122 + 123 + - Use inline styles only (no `<style>` blocks) for maximum email client compatibility 124 + - Host images externally (`https://example.com/logo.png`) so they display for recipients 125 + - Test your HTML signature by sending to yourself first 126 + - The `--` separator is added automatically before the text signature 127 + 128 + For full HTML signature configuration examples, see [configuration.md](configuration.md#html-signatures). 129 + 81 130 For reading emails — images, links, attachments, and navigation — see [reading.md](reading.md).
+28 -7
internal/config/config.go
··· 118 118 return tabs 119 119 } 120 120 121 + // SignatureConfig holds plain text and HTML signature blocks. 122 + type SignatureConfig struct { 123 + Text string `toml:"text"` // markdown/plain text signature for text/plain part and editor 124 + HTML string `toml:"html"` // optional HTML signature injected into text/html part 125 + } 126 + 121 127 // UIConfig holds display preferences. 122 128 type UIConfig struct { 123 - Theme string `toml:"theme"` // dark | light | auto 124 - InboxCount int `toml:"inbox_count"` // number of messages to fetch 125 - Signature string `toml:"signature"` // appended to new compose buffers (markdown) 126 - AutoScreenOnLoad *bool `toml:"auto_screen_on_load"` // screen inbox on every load (default true) 127 - BgSyncInterval int `toml:"bg_sync_interval"` // background sync interval in minutes (0 = disabled, default 5) 128 - BulkProgressThreshold int `toml:"bulk_progress_threshold"` // show progress counter for batches larger than this (default 10) 129 - DraftBackupCount int `toml:"draft_backup_count"` // rolling compose backups in ~/.cache/neomd/drafts/ (default 20, -1 = disabled) 129 + Theme string `toml:"theme"` // dark | light | auto 130 + InboxCount int `toml:"inbox_count"` // number of messages to fetch 131 + Signature string `toml:"signature"` // legacy: plain signature (markdown). Deprecated in favor of [ui.signature] block. 132 + SignatureBlock SignatureConfig `toml:"signature_block"` // new structured signature config 133 + AutoScreenOnLoad *bool `toml:"auto_screen_on_load"` // screen inbox on every load (default true) 134 + BgSyncInterval int `toml:"bg_sync_interval"` // background sync interval in minutes (0 = disabled, default 5) 135 + BulkProgressThreshold int `toml:"bulk_progress_threshold"` // show progress counter for batches larger than this (default 10) 136 + DraftBackupCount int `toml:"draft_backup_count"` // rolling compose backups in ~/.cache/neomd/drafts/ (default 20, -1 = disabled) 137 + } 138 + 139 + // TextSignature returns the text/markdown signature for editor and text/plain part. 140 + // Prefers signature_block.text, falls back to legacy signature field. 141 + func (u UIConfig) TextSignature() string { 142 + if u.SignatureBlock.Text != "" { 143 + return u.SignatureBlock.Text 144 + } 145 + return u.Signature 146 + } 147 + 148 + // HTMLSignature returns the HTML signature for text/html part, or empty if not configured. 149 + func (u UIConfig) HTMLSignature() string { 150 + return u.SignatureBlock.HTML 130 151 } 131 152 132 153 // DraftBackups returns the max number of rolling draft backups (default 20, -1 = disabled).
+2 -2
internal/integration_test.go
··· 509 509 body := "This email tests SaveSent IMAP APPEND." 510 510 511 511 // Build the message (same as neomd does before sending) 512 - raw, err := smtp.BuildMessage(env.from, env.user, "", subject, body, nil) 512 + raw, err := smtp.BuildMessage(env.from, env.user, "", subject, body, nil, "") 513 513 if err != nil { 514 514 t.Fatalf("BuildMessage: %v", err) 515 515 } ··· 601 601 // The email lands in demo's Sent (via SaveSent) but also in demo's INBOX 602 602 // if demo is in CC. Since demo is not in To/CC here, we save to Sent to 603 603 // have a copy to inspect. 604 - raw, err := smtp.BuildMessage(env.from, user2, user3, origSubject, origBody, nil) 604 + raw, err := smtp.BuildMessage(env.from, user2, user3, origSubject, origBody, nil, "") 605 605 if err != nil { 606 606 t.Fatalf("BuildMessage: %v", err) 607 607 }
+6 -1
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 - func BuildMessage(from, to, cc, subject, markdownBody string, attachments []string) ([]byte, error) { 250 + // htmlSignature, if non-empty, is appended to the text/html part after markdown conversion. 251 + func BuildMessage(from, to, cc, subject, markdownBody string, attachments []string, htmlSignature string) ([]byte, error) { 251 252 htmlBody, err := render.ToHTML(markdownBody) 252 253 if err != nil { 253 254 return nil, fmt.Errorf("markdown to html: %w", err) 255 + } 256 + // Append HTML signature to the HTML part if provided 257 + if htmlSignature != "" { 258 + htmlBody = htmlBody + "\n" + htmlSignature 254 259 } 255 260 return buildMessage(from, to, cc, subject, markdownBody, htmlBody, attachments) 256 261 }
+93
internal/smtp/sender_test.go
··· 498 498 } 499 499 } 500 500 501 + func TestBuildMessage_WithHTMLSignature(t *testing.T) { 502 + // Test that HTML signature is appended to text/html part only 503 + markdownBody := "Hello world!" 504 + htmlSig := `<table style="font-size:12px"><tr><td>John Doe</td></tr></table>` 505 + 506 + raw, err := BuildMessage( 507 + "Alice <alice@example.com>", 508 + "Bob <bob@example.com>", 509 + "", 510 + "Test HTML Signature", 511 + markdownBody, 512 + nil, 513 + htmlSig, 514 + ) 515 + if err != nil { 516 + t.Fatalf("BuildMessage: %v", err) 517 + } 518 + 519 + _, mediaType, params := parseMIME(t, raw) 520 + if mediaType != "multipart/alternative" { 521 + t.Fatalf("expected multipart/alternative, got %s", mediaType) 522 + } 523 + 524 + msg, _ := mail.ReadMessage(bytes.NewReader(raw)) 525 + mr := multipart.NewReader(msg.Body, params["boundary"]) 526 + 527 + // Read text/plain part 528 + part0, err := mr.NextPart() 529 + if err != nil { 530 + t.Fatalf("NextPart 0: %v", err) 531 + } 532 + ct0, _, _ := mime.ParseMediaType(part0.Header.Get("Content-Type")) 533 + if ct0 != "text/plain" { 534 + t.Errorf("part 0: expected text/plain, got %s", ct0) 535 + } 536 + plainBody, _ := io.ReadAll(part0) 537 + plainStr := string(plainBody) 538 + if strings.Contains(plainStr, "table") || strings.Contains(plainStr, "<tr>") { 539 + t.Error("text/plain part contains HTML signature (should be plain text only)") 540 + } 541 + // QP encoding preserves "Hello world" as-is (all ASCII) 542 + if !strings.Contains(plainStr, "Hello world") { 543 + t.Errorf("text/plain part missing body content, got:\n%s", plainStr) 544 + } 545 + 546 + // Read text/html part 547 + part1, err := mr.NextPart() 548 + if err != nil { 549 + t.Fatalf("NextPart 1: %v", err) 550 + } 551 + ct1, _, _ := mime.ParseMediaType(part1.Header.Get("Content-Type")) 552 + if ct1 != "text/html" { 553 + t.Errorf("part 1: expected text/html, got %s", ct1) 554 + } 555 + htmlBody, _ := io.ReadAll(part1) 556 + htmlStr := string(htmlBody) 557 + // Check for key parts (QP may have soft line breaks, but these strings should appear) 558 + if !strings.Contains(htmlStr, "Hello world") { 559 + t.Errorf("text/html part missing body content, got:\n%s", htmlStr) 560 + } 561 + if !strings.Contains(htmlStr, "table") || !strings.Contains(htmlStr, "John Doe") { 562 + t.Errorf("text/html part missing HTML signature, got:\n%s", htmlStr) 563 + } 564 + } 565 + 566 + func TestBuildMessage_WithoutHTMLSignature(t *testing.T) { 567 + // Passing empty htmlSignature should work fine (backward compatibility) 568 + raw, err := BuildMessage( 569 + "Alice <alice@example.com>", 570 + "Bob <bob@example.com>", 571 + "", 572 + "No HTML Signature", 573 + "plain body", 574 + nil, 575 + "", // empty HTML signature 576 + ) 577 + if err != nil { 578 + t.Fatalf("BuildMessage: %v", err) 579 + } 580 + 581 + // Should still produce multipart/alternative with both parts 582 + msg, mediaType, params := parseMIME(t, raw) 583 + if mediaType != "multipart/alternative" { 584 + t.Errorf("expected multipart/alternative, got %s", mediaType) 585 + } 586 + 587 + mr := multipart.NewReader(msg.Body, params["boundary"]) 588 + parts := readParts(t, mr) 589 + if len(parts) != 2 { 590 + t.Errorf("expected 2 parts, got %d", len(parts)) 591 + } 592 + } 593 + 501 594 func TestInferSMTPUseTLS(t *testing.T) { 502 595 tests := []struct { 503 596 name string
+28 -4
internal/ui/model.go
··· 728 728 } 729 729 } 730 730 731 - func (m Model) sendEmailCmd(smtpAcct config.AccountConfig, from, to, cc, bcc, subject, body string, attachments []string, replyToUID uint32, replyToFolder, replyToAccount string) tea.Cmd { 731 + 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 { 732 732 h, p := splitAddr(smtpAcct.SMTP) 733 733 cfg := smtp.Config{ 734 734 Host: h, ··· 743 743 cli := m.sentDraftsIMAPClient() 744 744 sentFolder := m.cfg.Folders.Sent 745 745 replyCli := m.imapCliForAccount(replyToAccount) 746 + htmlSignature := "" 747 + if includeHTMLSig { 748 + htmlSignature = m.cfg.UI.HTMLSignature() 749 + } 746 750 return func() tea.Msg { 747 751 // Build raw MIME once — reused for both SMTP delivery and Sent copy. 748 752 // BCC is intentionally excluded from headers but included in RCPT TO. 749 - raw, err := smtp.BuildMessage(from, to, cc, subject, body, attachments) 753 + raw, err := smtp.BuildMessage(from, to, cc, subject, body, attachments, htmlSignature) 750 754 if err != nil { 751 755 return sendDoneMsg{err: fmt.Errorf("build message: %w", err)} 752 756 } ··· 1818 1822 return m, nil 1819 1823 } 1820 1824 // Strip editor header hints and extract [attach] lines. 1825 + // [html-signature] marker is NOT extracted yet — keep it in body for preview. 1821 1826 inlineAttach, cleanBody := extractInlineAttachments(stripPrelude(msg.body)) 1822 1827 m.attachments = append(m.attachments, inlineAttach...) 1823 1828 m.applyEditedFrom(msg.from) ··· 3113 3118 smtpAcct := m.presendSMTPAccount() 3114 3119 attachments := m.attachments 3115 3120 replyUID, replyFolder := ps.replyToUID, ps.replyToFolder 3121 + // Extract [html-signature] marker from body now (right before sending) 3122 + includeHTMLSig, cleanBody := extractHTMLSignatureMarker(ps.body) 3116 3123 m.attachments = nil 3117 3124 m.pendingSend = nil 3118 - 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)) 3125 + 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)) 3119 3126 case "ctrl+f": 3120 3127 froms := m.presendFroms() 3121 3128 if len(froms) <= 1 { ··· 3296 3303 cc := m.compose.cc.Value() 3297 3304 bcc := m.compose.bcc.Value() 3298 3305 subject := m.compose.subject.Value() 3299 - prelude := editor.Prelude(to, cc, bcc, m.presendFrom(), subject, m.cfg.UI.Signature) 3306 + prelude := editor.Prelude(to, cc, bcc, m.presendFrom(), subject, m.cfg.UI.TextSignature()) 3300 3307 3301 3308 // Write temp file 3302 3309 f, err := os.CreateTemp(neomdTempDir(), "neomd-*.md") ··· 3735 3742 kept = append(kept, line) 3736 3743 } 3737 3744 return files, strings.Join(kept, "\n") 3745 + } 3746 + 3747 + // extractHTMLSignatureMarker scans body for [html-signature] marker. 3748 + // If found, removes it and returns (true, cleanBody). 3749 + // If not found, returns (false, body unchanged). 3750 + func extractHTMLSignatureMarker(body string) (includeHTMLSig bool, clean string) { 3751 + const marker = "[html-signature]" 3752 + var kept []string 3753 + for _, line := range strings.Split(body, "\n") { 3754 + trimmed := strings.TrimSpace(line) 3755 + if trimmed == marker { 3756 + includeHTMLSig = true 3757 + continue 3758 + } 3759 + kept = append(kept, line) 3760 + } 3761 + return includeHTMLSig, strings.Join(kept, "\n") 3738 3762 } 3739 3763 3740 3764 // ── View ──────────────────────────────────────────────────────────────────