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.

fix roborev review and errors

sspaeti 544fd471 fcfd1904

+160 -16
+1 -1
CHANGELOG.md
··· 3 3 # 2026-04-13 4 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 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 6 - - **Fix: refresh not showing new emails immediately** — pressing `R` now correctly displays new emails on first refresh; previously the IMAP client cached the selected mailbox state, so the first `R` would skip re-SELECT and use stale UID SEARCH results (showing the old unread count but no new messages in the list); required a second `R` or tab switch to see new emails; now forces a fresh SELECT to ensure mailbox state is current 6 + - **Fix: refresh not showing new emails immediately** — pressing `R` now correctly displays new emails on first refresh; previously the IMAP client cached the selected mailbox state, so the first `R` would skip re-SELECT and use stale UID SEARCH results (showing the old unread count but no new messages in the list); required a second `R` or tab switch to see new emails; now forces a fresh SELECT to ensure mailbox state is current; also fixed background sync path to prevent stale cache; added regression test (internal/imap/client_test.go:410) 7 7 8 8 9 9 # 2026-04-10
+13 -5
internal/editor/editor.go
··· 134 134 } 135 135 136 136 // ReactionBody builds the plain text body for an emoji reaction. 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 { 137 + // Returns markdown format (for HTML rendering) and plain text format (for text/plain part). 138 + // Markdown version includes syntax for italics and links. 139 + // Plain text version is clean without markdown symbols. 140 + func ReactionBody(emoji, fromName, originalFrom, originalBody string) (markdown, plainText string) { 140 141 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) 142 + 143 + // Markdown version (will be rendered to HTML) 144 + markdown = fmt.Sprintf("%s\n\n_%s reacted via [neomd](https://neomd.ssp.sh)_\n\n%s", emoji, fromName, quoted) 145 + 146 + // Plain text version (no markdown syntax) 147 + plainQuoted := fmt.Sprintf("---\n\n%s wrote:\n\n%s\n\n---\n\n", originalFrom, quoteLines(originalBody)) 148 + plainText = fmt.Sprintf("%s\n\n%s reacted via neomd (https://neomd.ssp.sh)\n\n%s", emoji, fromName, plainQuoted) 149 + 150 + return markdown, plainText 143 151 } 144 152 145 153 // ParseHeaders scans raw editor content for # [neomd: key: value] lines and
+43
internal/imap/client_test.go
··· 386 386 t.Errorf("normalization not applied to regular email\ngot:\n%q\nwant:\n%q", body, expectedNormalized) 387 387 } 388 388 } 389 + 390 + func TestParseBody_ReferencesExtraction(t *testing.T) { 391 + // Build a test message with References header 392 + raw := "From: test@example.com\r\n" + 393 + "To: recipient@example.com\r\n" + 394 + "Subject: Test\r\n" + 395 + "Message-ID: <msg3@example.com>\r\n" + 396 + "In-Reply-To: <msg2@example.com>\r\n" + 397 + "References: <msg1@example.com> <msg2@example.com>\r\n" + 398 + "Content-Type: text/plain; charset=utf-8\r\n" + 399 + "\r\n" + 400 + "Test body" 401 + 402 + _, _, _, _, references := parseBody([]byte(raw)) 403 + 404 + wantReferences := "<msg1@example.com> <msg2@example.com>" 405 + if references != wantReferences { 406 + t.Errorf("References = %q, want %q", references, wantReferences) 407 + } 408 + } 409 + 410 + func TestResetMailboxSelection(t *testing.T) { 411 + // Verify that ResetMailboxSelection clears the cached selectedMailbox. 412 + // This prevents stale mailbox state from suppressing new message visibility 413 + // when refreshing (github.com/sspaeti/neomd#66 regression test). 414 + c := &Client{ 415 + cfg: Config{ 416 + Host: "imap.example.com", 417 + Port: "993", 418 + TLS: true, 419 + }, 420 + } 421 + 422 + // Simulate that a mailbox was previously selected 423 + c.selectedMailbox = "INBOX" 424 + 425 + // Reset should clear it 426 + c.ResetMailboxSelection() 427 + 428 + if c.selectedMailbox != "" { 429 + t.Errorf("ResetMailboxSelection() did not clear selectedMailbox: got %q, want empty string", c.selectedMailbox) 430 + } 431 + }
+5 -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 - // markdownBody contains the reaction text with quoted original message in markdown format. 295 + // plainTextBody contains the reaction for plain text email clients (no markdown syntax). 296 + // markdownBody contains the reaction text with markdown syntax for HTML rendering. 296 297 // inReplyTo is the Message-ID of the original email. 297 298 // references is the References chain from the original email (may be empty). 298 - func BuildReactionMessage(from, to, cc, subject, markdownBody, inReplyTo, references string) ([]byte, error) { 299 + func BuildReactionMessage(from, to, cc, subject, plainTextBody, markdownBody, inReplyTo, references string) ([]byte, error) { 299 300 // Convert markdown to HTML (same as regular replies) 300 301 htmlBody, err := render.ToHTML(markdownBody) 301 302 if err != nil { ··· 312 313 } 313 314 } 314 315 // No attachments for reactions 315 - return buildMessageWithBCC(from, to, cc, "", subject, markdownBody, htmlBody, nil, inReplyTo, refChain) 316 + // Use plainTextBody for text/plain part (no markdown syntax), htmlBody for text/html part 317 + return buildMessageWithBCC(from, to, cc, "", subject, plainTextBody, htmlBody, nil, inReplyTo, refChain) 316 318 } 317 319 318 320 // inlineImage holds a local image path and its assigned Content-ID.
+90
internal/smtp/sender_test.go
··· 686 686 }) 687 687 } 688 688 } 689 + func TestBuildReactionMessage_ThreadingHeaders(t *testing.T) { 690 + plainText := "👍\n\nSimon reacted via neomd (https://neomd.ssp.sh)\n\n---\n\nJohn wrote:\n\n> Hello" 691 + markdown := "👍\n\n_Simon reacted via [neomd](https://neomd.ssp.sh)_\n\n---\n\n> **John** wrote:\n>\n> Hello" 692 + inReplyTo := "<original@example.com>" 693 + references := "<first@example.com> <second@example.com>" 694 + 695 + raw, err := BuildReactionMessage( 696 + "simon@example.com", 697 + "john@example.com", 698 + "", 699 + "Re: Test", 700 + plainText, 701 + markdown, 702 + inReplyTo, 703 + references, 704 + ) 705 + if err != nil { 706 + t.Fatalf("BuildReactionMessage: %v", err) 707 + } 708 + 709 + msg, err := mail.ReadMessage(bytes.NewReader(raw)) 710 + if err != nil { 711 + t.Fatalf("ReadMessage: %v", err) 712 + } 713 + 714 + // Verify In-Reply-To header 715 + gotInReplyTo := msg.Header.Get("In-Reply-To") 716 + if gotInReplyTo != inReplyTo { 717 + t.Errorf("In-Reply-To = %q, want %q", gotInReplyTo, inReplyTo) 718 + } 719 + 720 + // Verify References header includes original references + inReplyTo 721 + gotReferences := msg.Header.Get("References") 722 + wantReferences := references + " " + inReplyTo 723 + if gotReferences != wantReferences { 724 + t.Errorf("References = %q, want %q", gotReferences, wantReferences) 725 + } 726 + 727 + // Verify multipart/alternative structure 728 + mediaType, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type")) 729 + if err != nil { 730 + t.Fatalf("ParseMediaType: %v", err) 731 + } 732 + if mediaType != "multipart/alternative" { 733 + t.Errorf("Content-Type = %q, want multipart/alternative", mediaType) 734 + } 735 + 736 + // Verify plain text part has no markdown syntax 737 + mr := multipart.NewReader(msg.Body, params["boundary"]) 738 + var foundPlainText, foundHTML bool 739 + for { 740 + part, err := mr.NextPart() 741 + if err == io.EOF { 742 + break 743 + } 744 + if err != nil { 745 + t.Fatalf("NextPart: %v", err) 746 + } 747 + ct := part.Header.Get("Content-Type") 748 + body, _ := io.ReadAll(part) 749 + 750 + if strings.Contains(ct, "text/plain") { 751 + foundPlainText = true 752 + bodyStr := string(body) 753 + // Plain text should not have markdown syntax 754 + if strings.Contains(bodyStr, "_") || strings.Contains(bodyStr, "[neomd]") { 755 + t.Errorf("text/plain part contains markdown syntax: %s", bodyStr) 756 + } 757 + // Should contain plain text version 758 + if !strings.Contains(bodyStr, "Simon reacted via neomd") { 759 + t.Errorf("text/plain missing footer, got: %s", bodyStr) 760 + } 761 + } 762 + if strings.Contains(ct, "text/html") { 763 + foundHTML = true 764 + // HTML should be rendered (not raw markdown) 765 + bodyStr := string(body) 766 + if !strings.Contains(bodyStr, "<") || !strings.Contains(bodyStr, ">") { 767 + t.Errorf("text/html part is not HTML: %s", bodyStr) 768 + } 769 + } 770 + } 771 + 772 + if !foundPlainText { 773 + t.Error("Missing text/plain part") 774 + } 775 + if !foundHTML { 776 + t.Error("Missing text/html part") 777 + } 778 + }
+8 -7
internal/ui/model.go
··· 809 809 fromName = extractEmailAddr(from) 810 810 } 811 811 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) 812 + // Build reaction bodies: markdown (for HTML rendering) and plain text (for text/plain part) 813 + bodyMarkdown, bodyPlainText := editor.ReactionBody(emoji.emoji, fromName, e.From, m.openBody) 815 814 816 815 // Get SMTP account 817 816 smtpAcct := m.activeAccount() ··· 834 833 835 834 return m, tea.Batch( 836 835 m.spinner.Tick, 837 - m.sendReactionCmd(smtpAcct, from, to, subject, bodyMarkdown, e), 836 + m.sendReactionCmd(smtpAcct, from, to, subject, bodyMarkdown, bodyPlainText, e), 838 837 ) 839 838 } 840 839 841 - func (m Model) sendReactionCmd(smtpAcct config.AccountConfig, from, to, subject, bodyMarkdown string, originalEmail *imap.Email) tea.Cmd { 840 + func (m Model) sendReactionCmd(smtpAcct config.AccountConfig, from, to, subject, bodyMarkdown, bodyPlainText string, originalEmail *imap.Email) tea.Cmd { 842 841 h, p := splitAddr(smtpAcct.SMTP) 843 842 cfg := smtp.Config{ 844 843 Host: h, ··· 861 860 references = originalEmail.InReplyTo 862 861 } 863 862 864 - // Build reaction message with threading headers (markdown will be converted to HTML) 863 + // Build reaction message with threading headers 864 + // markdown will be converted to HTML, plainText used for text/plain part 865 865 raw, err := smtp.BuildReactionMessage( 866 866 from, to, "", subject, 867 - bodyMarkdown, 867 + bodyPlainText, bodyMarkdown, 868 868 originalEmail.MessageID, 869 869 references, 870 870 ) ··· 1434 1434 // Errors are swallowed — a transient network hiccup shouldn't disrupt the UI. 1435 1435 func (m Model) bgFetchInboxCmd() tea.Cmd { 1436 1436 return func() tea.Msg { 1437 + m.imapCli().ResetMailboxSelection() // force fresh SELECT to see new messages 1437 1438 emails, err := m.imapCli().FetchHeaders(nil, m.cfg.Folders.Inbox, m.cfg.UI.InboxCount) 1438 1439 if err != nil { 1439 1440 return bgSyncTickMsg{} // reschedule retry on next tick instead of errMsg