···33# 2026-04-13
44- **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)
55- **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
66-- **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
66+- **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)
778899# 2026-04-10
+13-5
internal/editor/editor.go
···134134}
135135136136// ReactionBody builds the plain text body for an emoji reaction.
137137-// Includes the quoted original message below the emoji and footer.
138138-// Uses the same quoting logic as regular replies.
139139-func ReactionBody(emoji, fromName, originalFrom, originalBody string) string {
137137+// Returns markdown format (for HTML rendering) and plain text format (for text/plain part).
138138+// Markdown version includes syntax for italics and links.
139139+// Plain text version is clean without markdown symbols.
140140+func ReactionBody(emoji, fromName, originalFrom, originalBody string) (markdown, plainText string) {
140141 quoted := buildQuotedReply(originalFrom, originalBody)
141141- return fmt.Sprintf("%s\n\n_%s reacted via [neomd](https://neomd.ssp.sh)_\n\n%s", emoji, fromName,
142142- quoted)
142142+143143+ // Markdown version (will be rendered to HTML)
144144+ markdown = fmt.Sprintf("%s\n\n_%s reacted via [neomd](https://neomd.ssp.sh)_\n\n%s", emoji, fromName, quoted)
145145+146146+ // Plain text version (no markdown syntax)
147147+ plainQuoted := fmt.Sprintf("---\n\n%s wrote:\n\n%s\n\n---\n\n", originalFrom, quoteLines(originalBody))
148148+ plainText = fmt.Sprintf("%s\n\n%s reacted via neomd (https://neomd.ssp.sh)\n\n%s", emoji, fromName, plainQuoted)
149149+150150+ return markdown, plainText
143151}
144152145153// ParseHeaders scans raw editor content for # [neomd: key: value] lines and
+43
internal/imap/client_test.go
···386386 t.Errorf("normalization not applied to regular email\ngot:\n%q\nwant:\n%q", body, expectedNormalized)
387387 }
388388}
389389+390390+func TestParseBody_ReferencesExtraction(t *testing.T) {
391391+ // Build a test message with References header
392392+ raw := "From: test@example.com\r\n" +
393393+ "To: recipient@example.com\r\n" +
394394+ "Subject: Test\r\n" +
395395+ "Message-ID: <msg3@example.com>\r\n" +
396396+ "In-Reply-To: <msg2@example.com>\r\n" +
397397+ "References: <msg1@example.com> <msg2@example.com>\r\n" +
398398+ "Content-Type: text/plain; charset=utf-8\r\n" +
399399+ "\r\n" +
400400+ "Test body"
401401+402402+ _, _, _, _, references := parseBody([]byte(raw))
403403+404404+ wantReferences := "<msg1@example.com> <msg2@example.com>"
405405+ if references != wantReferences {
406406+ t.Errorf("References = %q, want %q", references, wantReferences)
407407+ }
408408+}
409409+410410+func TestResetMailboxSelection(t *testing.T) {
411411+ // Verify that ResetMailboxSelection clears the cached selectedMailbox.
412412+ // This prevents stale mailbox state from suppressing new message visibility
413413+ // when refreshing (github.com/sspaeti/neomd#66 regression test).
414414+ c := &Client{
415415+ cfg: Config{
416416+ Host: "imap.example.com",
417417+ Port: "993",
418418+ TLS: true,
419419+ },
420420+ }
421421+422422+ // Simulate that a mailbox was previously selected
423423+ c.selectedMailbox = "INBOX"
424424+425425+ // Reset should clear it
426426+ c.ResetMailboxSelection()
427427+428428+ if c.selectedMailbox != "" {
429429+ t.Errorf("ResetMailboxSelection() did not clear selectedMailbox: got %q, want empty string", c.selectedMailbox)
430430+ }
431431+}
+5-3
internal/smtp/sender.go
···292292293293// BuildReactionMessage constructs a minimal reaction email with threading headers.
294294// Used for emoji reactions sent as replies to emails.
295295-// markdownBody contains the reaction text with quoted original message in markdown format.
295295+// plainTextBody contains the reaction for plain text email clients (no markdown syntax).
296296+// markdownBody contains the reaction text with markdown syntax for HTML rendering.
296297// inReplyTo is the Message-ID of the original email.
297298// references is the References chain from the original email (may be empty).
298298-func BuildReactionMessage(from, to, cc, subject, markdownBody, inReplyTo, references string) ([]byte, error) {
299299+func BuildReactionMessage(from, to, cc, subject, plainTextBody, markdownBody, inReplyTo, references string) ([]byte, error) {
299300 // Convert markdown to HTML (same as regular replies)
300301 htmlBody, err := render.ToHTML(markdownBody)
301302 if err != nil {
···312313 }
313314 }
314315 // No attachments for reactions
315315- return buildMessageWithBCC(from, to, cc, "", subject, markdownBody, htmlBody, nil, inReplyTo, refChain)
316316+ // Use plainTextBody for text/plain part (no markdown syntax), htmlBody for text/html part
317317+ return buildMessageWithBCC(from, to, cc, "", subject, plainTextBody, htmlBody, nil, inReplyTo, refChain)
316318}
317319318320// inlineImage holds a local image path and its assigned Content-ID.
+90
internal/smtp/sender_test.go
···686686 })
687687 }
688688}
689689+func TestBuildReactionMessage_ThreadingHeaders(t *testing.T) {
690690+ plainText := "👍\n\nSimon reacted via neomd (https://neomd.ssp.sh)\n\n---\n\nJohn wrote:\n\n> Hello"
691691+ markdown := "👍\n\n_Simon reacted via [neomd](https://neomd.ssp.sh)_\n\n---\n\n> **John** wrote:\n>\n> Hello"
692692+ inReplyTo := "<original@example.com>"
693693+ references := "<first@example.com> <second@example.com>"
694694+695695+ raw, err := BuildReactionMessage(
696696+ "simon@example.com",
697697+ "john@example.com",
698698+ "",
699699+ "Re: Test",
700700+ plainText,
701701+ markdown,
702702+ inReplyTo,
703703+ references,
704704+ )
705705+ if err != nil {
706706+ t.Fatalf("BuildReactionMessage: %v", err)
707707+ }
708708+709709+ msg, err := mail.ReadMessage(bytes.NewReader(raw))
710710+ if err != nil {
711711+ t.Fatalf("ReadMessage: %v", err)
712712+ }
713713+714714+ // Verify In-Reply-To header
715715+ gotInReplyTo := msg.Header.Get("In-Reply-To")
716716+ if gotInReplyTo != inReplyTo {
717717+ t.Errorf("In-Reply-To = %q, want %q", gotInReplyTo, inReplyTo)
718718+ }
719719+720720+ // Verify References header includes original references + inReplyTo
721721+ gotReferences := msg.Header.Get("References")
722722+ wantReferences := references + " " + inReplyTo
723723+ if gotReferences != wantReferences {
724724+ t.Errorf("References = %q, want %q", gotReferences, wantReferences)
725725+ }
726726+727727+ // Verify multipart/alternative structure
728728+ mediaType, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type"))
729729+ if err != nil {
730730+ t.Fatalf("ParseMediaType: %v", err)
731731+ }
732732+ if mediaType != "multipart/alternative" {
733733+ t.Errorf("Content-Type = %q, want multipart/alternative", mediaType)
734734+ }
735735+736736+ // Verify plain text part has no markdown syntax
737737+ mr := multipart.NewReader(msg.Body, params["boundary"])
738738+ var foundPlainText, foundHTML bool
739739+ for {
740740+ part, err := mr.NextPart()
741741+ if err == io.EOF {
742742+ break
743743+ }
744744+ if err != nil {
745745+ t.Fatalf("NextPart: %v", err)
746746+ }
747747+ ct := part.Header.Get("Content-Type")
748748+ body, _ := io.ReadAll(part)
749749+750750+ if strings.Contains(ct, "text/plain") {
751751+ foundPlainText = true
752752+ bodyStr := string(body)
753753+ // Plain text should not have markdown syntax
754754+ if strings.Contains(bodyStr, "_") || strings.Contains(bodyStr, "[neomd]") {
755755+ t.Errorf("text/plain part contains markdown syntax: %s", bodyStr)
756756+ }
757757+ // Should contain plain text version
758758+ if !strings.Contains(bodyStr, "Simon reacted via neomd") {
759759+ t.Errorf("text/plain missing footer, got: %s", bodyStr)
760760+ }
761761+ }
762762+ if strings.Contains(ct, "text/html") {
763763+ foundHTML = true
764764+ // HTML should be rendered (not raw markdown)
765765+ bodyStr := string(body)
766766+ if !strings.Contains(bodyStr, "<") || !strings.Contains(bodyStr, ">") {
767767+ t.Errorf("text/html part is not HTML: %s", bodyStr)
768768+ }
769769+ }
770770+ }
771771+772772+ if !foundPlainText {
773773+ t.Error("Missing text/plain part")
774774+ }
775775+ if !foundHTML {
776776+ t.Error("Missing text/html part")
777777+ }
778778+}
+8-7
internal/ui/model.go
···809809 fromName = extractEmailAddr(from)
810810 }
811811812812- // Build reaction body (markdown) with quoted original message
813813- // HTML will be generated from markdown by BuildReactionMessage (same as regular replies)
814814- bodyMarkdown := editor.ReactionBody(emoji.emoji, fromName, e.From, m.openBody)
812812+ // Build reaction bodies: markdown (for HTML rendering) and plain text (for text/plain part)
813813+ bodyMarkdown, bodyPlainText := editor.ReactionBody(emoji.emoji, fromName, e.From, m.openBody)
815814816815 // Get SMTP account
817816 smtpAcct := m.activeAccount()
···834833835834 return m, tea.Batch(
836835 m.spinner.Tick,
837837- m.sendReactionCmd(smtpAcct, from, to, subject, bodyMarkdown, e),
836836+ m.sendReactionCmd(smtpAcct, from, to, subject, bodyMarkdown, bodyPlainText, e),
838837 )
839838}
840839841841-func (m Model) sendReactionCmd(smtpAcct config.AccountConfig, from, to, subject, bodyMarkdown string, originalEmail *imap.Email) tea.Cmd {
840840+func (m Model) sendReactionCmd(smtpAcct config.AccountConfig, from, to, subject, bodyMarkdown, bodyPlainText string, originalEmail *imap.Email) tea.Cmd {
842841 h, p := splitAddr(smtpAcct.SMTP)
843842 cfg := smtp.Config{
844843 Host: h,
···861860 references = originalEmail.InReplyTo
862861 }
863862864864- // Build reaction message with threading headers (markdown will be converted to HTML)
863863+ // Build reaction message with threading headers
864864+ // markdown will be converted to HTML, plainText used for text/plain part
865865 raw, err := smtp.BuildReactionMessage(
866866 from, to, "", subject,
867867- bodyMarkdown,
867867+ bodyPlainText, bodyMarkdown,
868868 originalEmail.MessageID,
869869 references,
870870 )
···14341434// Errors are swallowed — a transient network hiccup shouldn't disrupt the UI.
14351435func (m Model) bgFetchInboxCmd() tea.Cmd {
14361436 return func() tea.Msg {
14371437+ m.imapCli().ResetMailboxSelection() // force fresh SELECT to see new messages
14371438 emails, err := m.imapCli().FetchHeaders(nil, m.cfg.Folders.Inbox, m.cfg.UI.InboxCount)
14381439 if err != nil {
14391440 return bgSyncTickMsg{} // reschedule retry on next tick instead of errMsg