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.

emoji reaction, threading header

sspaeti 3f5b98be 562f52dd

+138 -60
+4 -1
.claude/settings.local.json
··· 5 5 "Bash(go test:*)", 6 6 "Bash(make build:*)", 7 7 "Bash(make test:*)", 8 - "WebFetch(domain:www.ssp.sh)" 8 + "WebFetch(domain:www.ssp.sh)", 9 + "Bash(roborev --help:*)", 10 + "WebFetch(domain:www.roborev.io)", 11 + "Bash(roborev check-agents:*)" 9 12 ] 10 13 } 11 14 }
+3 -1
CHANGELOG.md
··· 1 1 # Changelog 2 2 3 - # 2026-04-10 3 + # 2026-04-13 4 + - **Emoji reactions (`ctrl+e`)** — fast, keyboard-driven emoji reactions from inbox or reader; press `ctrl+e` to open emoji picker overlay, select with `1`-`8` for instant send or navigate with `j`/`k` and press `enter`; sends minimal reaction email (emoji + italic footer + quoted original message) with proper threading headers; available reactions: 👍 ❤️ 😂 🎉 🙏 💯 👀 ✅; original email marked with `\Answered` flag; reaction saved to Sent folder; auto-selects From address matching recipient (same logic as regular replies) 5 + - **Email threading headers** — all replies (regular `r`/`R` and emoji reactions `ctrl+e`) now include proper `In-Reply-To` and `References` headers for conversation threading; ensures replies appear correctly grouped in Gmail, Outlook, and Apple Mail conversation views; `References` header extracted from IMAP message body and preserved in reply chain 4 6 5 7 6 8 # 2026-04-10
+1
README.md
··· 81 81 - **Attachments** — attach files from the pre-send screen via yazi (`a`); images appear inline in the email body, other files as attachments; also attach from within neovim via `<leader>a`; the reader lists all attachments (including inline images) and `1`–`9` downloads and opens them 82 82 - **Link opener** — links in emails are numbered `[1]`-`[0]` in the reader header; press `space+digit` to open in `$BROWSER` 83 83 - **CC, BCC, Reply-all** — optional Cc/Bcc fields (toggle with `ctrl+b`); `R` in the reader replies to sender + all CC recipients 84 + - **Emoji reactions** — press `ctrl+e` from inbox or reader to react with emoji (👍 ❤️ 😂 🎉 🙏 💯 👀 ✅); instant send with proper threading and quoted message history, no editor needed; reactions appear in conversation threads with neomd branding 84 85 - **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) 85 86 - **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 86 87 - **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
+50
docs/sending.md
··· 28 28 29 29 In the compose form, `ctrl+b` toggles the Cc and Bcc fields (hidden by default). Bcc recipients receive the email but are never written to message headers. From the reader, `r` replies to the sender and `R` replies to the sender plus all Cc recipients (your own address excluded, `Reply-To` respected). 30 30 31 + All replies include proper `In-Reply-To` and `References` headers for email threading, ensuring they appear in conversation threads in Gmail, Outlook, and Apple Mail. 32 + 31 33 Press `f` to forward an email — works from both the reader and the inbox list (the body is fetched automatically). The editor opens with the original message quoted and `Fwd:` prepended to the subject. Fill in the `# [neomd: to: ]` field and add your own text above the quoted block. 34 + 35 + ## Emoji Reactions 36 + 37 + Press `ctrl+e` from the inbox or reader view to react to an email with a single emoji — a fast, lightweight way to acknowledge receipt without writing a full reply. 38 + 39 + **Available reactions:** 40 + - 👍 Thumbs up 41 + - ❤️ Love 42 + - 😂 Laugh 43 + - 🎉 Celebrate 44 + - 🙏 Thanks 45 + - 💯 Perfect 46 + - 👀 Eyes 47 + - ✅ Check 48 + 49 + **How it works:** 50 + 51 + 1. Press `ctrl+e` while viewing or selecting an email 52 + 2. Choose an emoji by pressing `1`-`8` (instant send) or navigate with `j`/`k` and press `enter` 53 + 3. Press `esc` to cancel 54 + 55 + The reaction is sent immediately (no editor, no pre-send review) as a properly formatted email with: 56 + 57 + **Plain text:** 58 + ``` 59 + 👍 60 + 61 + Simon Späti reacted via [neomd](https://neomd.ssp.sh) 62 + 63 + --- 64 + 65 + > **John Doe** wrote: 66 + > 67 + > original email body quoted here 68 + 69 + --- 70 + ``` 71 + 72 + **HTML:** 73 + The emoji is displayed at 48px with a styled footer containing your name and a link to neomd. The original message is quoted below in a styled blockquote. 74 + 75 + **Threading:** 76 + Reactions include proper `In-Reply-To` and `References` headers so they appear in the conversation thread (tested with Gmail, Outlook, and Apple Mail). The original email is marked with the `\Answered` flag. 77 + 78 + **From address:** 79 + The reaction is automatically sent from the address that received the original email (same logic as regular replies). A copy is saved to your Sent folder. 80 + 81 + Emoji reactions are perfect for quick acknowledgments, celebrating good news, or thanking someone without the overhead of composing a full reply. 32 82 33 83 ## Attachments 34 84
+13 -13
internal/editor/editor.go
··· 107 107 } 108 108 109 109 // ReplyPrelude builds a quote block for replies. cc and from may be empty. 110 + // buildQuotedReply builds the quoted "wrote:" section used in replies and reactions. 111 + func buildQuotedReply(originalFrom, originalBody string) string { 112 + return fmt.Sprintf("---\n\n> **%s** wrote:\n>\n%s\n\n---\n\n", 113 + originalFrom, quoteLines(originalBody)) 114 + } 115 + 110 116 func ReplyPrelude(to, cc, subject, from, originalFrom, originalBody string) string { 111 - return Prelude(to, cc, "", from, subject, "") + 112 - fmt.Sprintf("---\n\n> **%s** wrote:\n>\n%s\n\n---\n\n", 113 - originalFrom, quoteLines(originalBody)) 117 + return Prelude(to, cc, "", from, subject, "") + buildQuotedReply(originalFrom, originalBody) 114 118 } 115 119 116 120 // ForwardPrelude builds a quoted forward block. The To field is left empty for ··· 130 134 } 131 135 132 136 // ReactionBody builds the plain text body for an emoji reaction. 133 - func ReactionBody(emoji, fromName string) string { 134 - return fmt.Sprintf("%s\n\n%s reacted via neomd (https://neomd.ssp.sh)\n", emoji, fromName) 135 - } 136 - 137 - // ReactionBodyHTML builds the HTML body for an emoji reaction. 138 - func ReactionBodyHTML(emoji, fromName string) string { 139 - return fmt.Sprintf(`<div style="font-size: 48px; margin: 20px 0;">%s</div> 140 - <p style="color: #666; font-size: 14px; margin-top: 40px; border-top: 1px solid #ddd; padding-top: 20px;"> 141 - %s reacted via <a href="https://neomd.ssp.sh" style="color: #7E9CD8; text-decoration: none;">neomd</a> 142 - </p>`, emoji, fromName) 137 + // Includes the quoted original message below the emoji and footer. 138 + // Uses the same quoting logic as regular replies. 139 + func ReactionBody(emoji, fromName, originalFrom, originalBody string) string { 140 + quoted := buildQuotedReply(originalFrom, originalBody) 141 + return fmt.Sprintf("%s\n\n_%s reacted via [neomd](https://neomd.ssp.sh)_\n\n%s", emoji, fromName, 142 + quoted) 143 143 } 144 144 145 145 // ParseHeaders scans raw editor content for # [neomd: key: value] lines and
+14 -11
internal/imap/client.go
··· 715 715 } 716 716 717 717 // FetchBody fetches the body of a single message. 718 - // Returns (markdownBody, rawHTML, webURL, attachments, error). 719 - func (c *Client) FetchBody(ctx context.Context, folder string, uid uint32) (string, string, string, []Attachment, error) { 718 + // Returns (markdownBody, rawHTML, webURL, attachments, references, error). 719 + func (c *Client) FetchBody(ctx context.Context, folder string, uid uint32) (string, string, string, []Attachment, string, error) { 720 720 if ctx == nil { 721 721 ctx = context.Background() 722 722 } 723 - var markdown, rawHTML, webURL string 723 + var markdown, rawHTML, webURL, references string 724 724 var attachments []Attachment 725 725 err := c.withConn(ctx, func(conn *imapclient.Client) error { 726 726 if err := c.selectMailbox(folder); err != nil { ··· 742 742 } 743 743 744 744 if len(msgs[0].BodySection) > 0 { 745 - markdown, rawHTML, webURL, attachments = parseBody(msgs[0].BodySection[0].Bytes) 745 + markdown, rawHTML, webURL, attachments, references = parseBody(msgs[0].BodySection[0].Bytes) 746 746 } 747 747 return nil 748 748 }) 749 - return markdown, rawHTML, webURL, attachments, err 749 + return markdown, rawHTML, webURL, attachments, references, err 750 750 } 751 751 752 752 // MoveMessage moves uid from src to dst using the IMAP MOVE command (RFC 6851). ··· 972 972 // - rawHTML: original HTML part verbatim (empty for plain-text emails) 973 973 // - webURL: "view online" URL extracted from List-Post header or plain-text 974 974 // preamble (e.g. Substack's "View this post on the web at https://…") 975 - func parseBody(raw []byte) (markdown, rawHTML, webURL string, attachments []Attachment) { 975 + func parseBody(raw []byte) (markdown, rawHTML, webURL string, attachments []Attachment, references string) { 976 976 e, err := message.Read(bytes.NewReader(raw)) 977 977 if err != nil && !message.IsUnknownCharset(err) { 978 - return string(raw), "", "", nil 978 + return string(raw), "", "", nil, "" 979 979 } 980 980 981 981 // Check if this is a neomd-authored draft. Drafts use the X-Neomd-Draft header 982 982 // to signal that the plain text body is already markdown and should not be 983 983 // normalized (which adds trailing spaces and would mutate the draft on each save/load). 984 984 isDraft := e.Header.Get("X-Neomd-Draft") == "true" 985 + 986 + // Extract References header for email threading 987 + references = e.Header.Get("References") 985 988 986 989 // List-Post header contains the canonical article URL on most newsletters: 987 990 // List-Post: <https://newsletter.example.com/p/slug> ··· 1085 1088 // text/plain part is typically a stripped dump with raw redirect URLs. 1086 1089 // Fall back to plain text for plain-text-only emails (e.g. direct replies). 1087 1090 if htmlText != "" { 1088 - return htmlToMarkdown(htmlText), htmlText, webURL, attachments 1091 + return htmlToMarkdown(htmlText), htmlText, webURL, attachments, references 1089 1092 } 1090 1093 if plainText != "" { 1091 1094 // For neomd drafts, return the raw markdown without normalization. 1092 1095 // Normalization adds trailing spaces for hard line breaks, which would 1093 1096 // mutate the draft content on each save/reopen cycle. 1094 1097 if isDraft { 1095 - return plainText, "", webURL, attachments 1098 + return plainText, "", webURL, attachments, references 1096 1099 } 1097 - return normalizePlainText(plainText), "", webURL, attachments 1100 + return normalizePlainText(plainText), "", webURL, attachments, references 1098 1101 } 1099 - return "(no body)", "", webURL, attachments 1102 + return "(no body)", "", webURL, attachments, references 1100 1103 } 1101 1104 1102 1105 // extractPlainTextWebURL looks for a "View … on the web at https://…" line
+5 -5
internal/imap/client_test.go
··· 228 228 "iVBORw0KGgo=\r\n" + 229 229 "--" + boundary + "--\r\n" 230 230 231 - _, _, _, attachments := parseBody([]byte(raw)) 231 + _, _, _, attachments, _ := parseBody([]byte(raw)) 232 232 233 233 if len(attachments) == 0 { 234 234 t.Fatal("expected at least 1 attachment, got 0") ··· 273 273 "JVBERi0=\r\n" + 274 274 "--" + boundary + "--\r\n" 275 275 276 - _, _, _, attachments := parseBody([]byte(raw)) 276 + _, _, _, attachments, _ := parseBody([]byte(raw)) 277 277 278 278 if len(attachments) == 0 { 279 279 t.Fatal("expected at least 1 attachment, got 0") ··· 334 334 originalBody 335 335 336 336 // First parse (simulating draft reopen) 337 - body1, _, _, _ := parseBody([]byte(draftMIME)) 337 + body1, _, _, _, _ := parseBody([]byte(draftMIME)) 338 338 339 339 // Verify the body matches exactly (no trailing spaces added) 340 340 if body1 != originalBody { ··· 351 351 "\r\n" + 352 352 body1 // Use the result from first parse 353 353 354 - body2, _, _, _ := parseBody([]byte(draftMIME2)) 354 + body2, _, _, _, _ := parseBody([]byte(draftMIME2)) 355 355 356 356 // Verify still matches exactly (no accumulation of trailing spaces) 357 357 if body2 != originalBody { ··· 378 378 "\r\n" + 379 379 originalBody 380 380 381 - body, _, _, _ := parseBody([]byte(regularMIME)) 381 + body, _, _, _, _ := parseBody([]byte(regularMIME)) 382 382 383 383 // Normalization should add two trailing spaces before the newline 384 384 expectedNormalized := "Line 1 \nLine 2"
+5 -5
internal/integration_test.go
··· 178 178 } 179 179 180 180 // Fetch body and verify content 181 - markdown, rawHTML, _, _, err := cli.FetchBody(context.Background(), "INBOX", email.UID) 181 + markdown, rawHTML, _, _, _, err := cli.FetchBody(context.Background(), "INBOX", email.UID) 182 182 if err != nil { 183 183 t.Fatalf("FetchBody: %v", err) 184 184 } ··· 214 214 defer cleanupEmail(t, cli, "INBOX", email.UID) 215 215 216 216 // Fetch raw body to check CC header 217 - markdown, _, _, _, err := cli.FetchBody(context.Background(), "INBOX", email.UID) 217 + markdown, _, _, _, _, err := cli.FetchBody(context.Background(), "INBOX", email.UID) 218 218 if err != nil { 219 219 t.Fatalf("FetchBody: %v", err) 220 220 } ··· 249 249 defer cleanupEmail(t, cli, "INBOX", email.UID) 250 250 251 251 // Fetch body — attachments should be listed 252 - _, _, _, attachments, err := cli.FetchBody(context.Background(), "INBOX", email.UID) 252 + _, _, _, attachments, _, err := cli.FetchBody(context.Background(), "INBOX", email.UID) 253 253 if err != nil { 254 254 t.Fatalf("FetchBody: %v", err) 255 255 } ··· 426 426 defer cleanupEmail(t, cli, "INBOX", email.UID) 427 427 428 428 // Fetch body — inline image should appear as attachment with image content type 429 - _, rawHTML, _, attachments, err := cli.FetchBody(context.Background(), "INBOX", email.UID) 429 + _, rawHTML, _, attachments, _, err := cli.FetchBody(context.Background(), "INBOX", email.UID) 430 430 if err != nil { 431 431 t.Fatalf("FetchBody: %v", err) 432 432 } ··· 476 476 email := waitForEmail(t, cli, "INBOX", subject, 30*time.Second) 477 477 defer cleanupEmail(t, cli, "INBOX", email.UID) 478 478 479 - markdown, rawHTML, _, _, err := cli.FetchBody(context.Background(), "INBOX", email.UID) 479 + markdown, rawHTML, _, _, _, err := cli.FetchBody(context.Background(), "INBOX", email.UID) 480 480 if err != nil { 481 481 t.Fatalf("FetchBody: %v", err) 482 482 }
+9 -3
internal/smtp/sender.go
··· 292 292 293 293 // BuildReactionMessage constructs a minimal reaction email with threading headers. 294 294 // Used for emoji reactions sent as replies to emails. 295 - // plainBody and htmlBody are pre-formatted reaction messages (emoji + footer). 295 + // markdownBody contains the reaction text with quoted original message in markdown format. 296 296 // inReplyTo is the Message-ID of the original email. 297 297 // references is the References chain from the original email (may be empty). 298 - func BuildReactionMessage(from, to, cc, subject, plainBody, htmlBody, inReplyTo, references string) ([]byte, error) { 298 + func BuildReactionMessage(from, to, cc, subject, markdownBody, inReplyTo, references string) ([]byte, error) { 299 + // Convert markdown to HTML (same as regular replies) 300 + htmlBody, err := render.ToHTML(markdownBody) 301 + if err != nil { 302 + return nil, fmt.Errorf("markdown to html: %w", err) 303 + } 304 + 299 305 // Build References chain: append inReplyTo to existing references 300 306 refChain := references 301 307 if inReplyTo != "" { ··· 306 312 } 307 313 } 308 314 // No attachments for reactions 309 - return buildMessageWithBCC(from, to, cc, "", subject, plainBody, htmlBody, nil, inReplyTo, refChain) 315 + return buildMessageWithBCC(from, to, cc, "", subject, markdownBody, htmlBody, nil, inReplyTo, refChain) 310 316 } 311 317 312 318 // inlineImage holds a local image path and its assigned Content-ID.
+32 -19
internal/ui/model.go
··· 51 51 rawHTML string // original HTML part, empty for plain-text emails 52 52 webURL string // canonical "view online" URL (List-Post header or plain-text preamble) 53 53 attachments []imap.Attachment 54 + references string // References header for email threading 54 55 } 55 56 sendDoneMsg struct { 56 57 err error ··· 729 730 730 731 func (m Model) fetchBodyCmd(e *imap.Email) tea.Cmd { 731 732 return func() tea.Msg { 732 - body, rawHTML, webURL, attachments, err := m.imapCli().FetchBody(nil, e.Folder, e.UID) 733 + body, rawHTML, webURL, attachments, references, err := m.imapCli().FetchBody(nil, e.Folder, e.UID) 733 734 if err != nil { 734 735 return errMsg{err} 735 736 } 736 - return bodyLoadedMsg{email: e, body: body, rawHTML: rawHTML, webURL: webURL, attachments: attachments} 737 + return bodyLoadedMsg{email: e, body: body, rawHTML: rawHTML, webURL: webURL, attachments: attachments, references: references} 737 738 } 738 739 } 739 740 ··· 808 809 fromName = extractEmailAddr(from) 809 810 } 810 811 811 - // Build reaction bodies 812 - bodyPlain := editor.ReactionBody(emoji.emoji, fromName) 813 - bodyHTML := editor.ReactionBodyHTML(emoji.emoji, fromName) 812 + // Build reaction body (markdown) with quoted original message 813 + // HTML will be generated from markdown by BuildReactionMessage (same as regular replies) 814 + bodyMarkdown := editor.ReactionBody(emoji.emoji, fromName, e.From, m.openBody) 814 815 815 816 // Get SMTP account 816 817 smtpAcct := m.activeAccount() ··· 833 834 834 835 return m, tea.Batch( 835 836 m.spinner.Tick, 836 - m.sendReactionCmd(smtpAcct, from, to, subject, bodyPlain, bodyHTML, e), 837 + m.sendReactionCmd(smtpAcct, from, to, subject, bodyMarkdown, e), 837 838 ) 838 839 } 839 840 840 - func (m Model) sendReactionCmd(smtpAcct config.AccountConfig, from, to, subject, bodyPlain, bodyHTML string, originalEmail *imap.Email) tea.Cmd { 841 + func (m Model) sendReactionCmd(smtpAcct config.AccountConfig, from, to, subject, bodyMarkdown string, originalEmail *imap.Email) tea.Cmd { 841 842 h, p := splitAddr(smtpAcct.SMTP) 842 843 cfg := smtp.Config{ 843 844 Host: h, ··· 854 855 replyCli := m.imapCli() 855 856 856 857 return func() tea.Msg { 857 - // Build reaction message with threading headers 858 + // Build References chain: use existing References or fall back to InReplyTo 859 + references := originalEmail.References 860 + if references == "" && originalEmail.InReplyTo != "" { 861 + references = originalEmail.InReplyTo 862 + } 863 + 864 + // Build reaction message with threading headers (markdown will be converted to HTML) 858 865 raw, err := smtp.BuildReactionMessage( 859 866 from, to, "", subject, 860 - bodyPlain, bodyHTML, 867 + bodyMarkdown, 861 868 originalEmail.MessageID, 862 - originalEmail.References, 869 + references, 863 870 ) 864 871 if err != nil { 865 872 return sendDoneMsg{err: fmt.Errorf("build reaction: %w", err)} ··· 1625 1632 m.openHTMLBody = msg.rawHTML 1626 1633 m.openWebURL = msg.webURL 1627 1634 m.openAttachments = msg.attachments 1635 + // Store References header in the email struct for threading 1636 + if msg.email != nil { 1637 + msg.email.References = msg.references 1638 + } 1628 1639 // Mark as seen in background (best-effort) 1629 1640 uid := msg.email.UID 1630 1641 folder := msg.email.Folder ··· 1962 1973 m.pendingSend.replyToAccount = m.activeAccount().Name 1963 1974 // Populate threading headers for proper email conversation threading 1964 1975 m.pendingSend.inReplyTo = m.openEmail.MessageID 1965 - m.pendingSend.references = m.openEmail.References 1976 + // Build References chain: use existing References or fall back to InReplyTo 1977 + if m.openEmail.References != "" { 1978 + m.pendingSend.references = m.openEmail.References 1979 + } else if m.openEmail.InReplyTo != "" { 1980 + // Fall back to InReplyTo if References not available 1981 + m.pendingSend.references = m.openEmail.InReplyTo 1982 + } 1966 1983 } 1967 1984 m.state = statePresend 1968 1985 m.status = "" ··· 2451 2468 if idx := m.matchFromIndex(e.To, e.CC); idx >= 0 { 2452 2469 m.presendFromI = idx 2453 2470 } 2454 - // Check if we have Message-ID (needed for threading headers) 2455 - if e.MessageID == "" { 2456 - // Need to fetch body/headers first 2457 - m.pendingReaction = true 2458 - m.loading = true 2459 - return m, tea.Batch(m.spinner.Tick, m.fetchBodyCmd(e)) 2460 - } 2461 - return m.enterReactionMode(e) 2471 + // Always fetch body first (needed for quoted message in reaction) 2472 + m.pendingReaction = true 2473 + m.loading = true 2474 + return m, tea.Batch(m.spinner.Tick, m.fetchBodyCmd(e)) 2462 2475 2463 2476 case "f": 2464 2477 e := selectedEmail(m.inbox)
+2 -2
internal/ui/reader.go
··· 126 126 // readerHelp returns the one-line help string for the reader view. 127 127 // When isDraft is true, "E draft" is shown so the user knows they can re-open in compose. 128 128 func readerHelp(isDraft bool, hasLinks bool) string { 129 - keys := []string{"j/k scroll", "h/q back", "r reply", "ctrl+r reply-all", "f fwd", "e nvim"} 129 + keys := []string{"j/k scroll", "h/q back", "r reply", "ctrl+r reply-all", "ctrl+e react", "f fwd", "e nvim"} 130 130 if isDraft { 131 131 keys = append(keys, "E draft") 132 132 } ··· 140 140 141 141 // inboxHelp returns the one-line help string for the inbox view. 142 142 func inboxHelp(folder string) string { 143 - base := []string{"enter/l open", "d/u page", "r reply", "ctrl+r reply-all", "f fwd", "c compose", "I/O/F/P/A screen", "g goto", "M move", ", sort", "/ filter", "R reload", ": cmds", "space more", "? help", "q quit"} 143 + base := []string{"enter/l open", "d/u page", "r reply", "ctrl+r reply-all", "ctrl+e react", "f fwd", "c compose", "I/O/F/P/A screen", "g goto", "M move", ", sort", "/ filter", "R reload", ": cmds", "space more", "? help", "q quit"} 144 144 _ = folder 145 145 if folder == "ToScreen" { 146 146 base = []string{"I approve", "O block", "F feed", "P papertrail", "q back"}