···11# Changelog
2233-# 2026-04-10
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
465768# 2026-04-10
+1
README.md
···8181- **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
8282- **Link opener** — links in emails are numbered `[1]`-`[0]` in the reader header; press `space+digit` to open in `$BROWSER`
8383- **CC, BCC, Reply-all** — optional Cc/Bcc fields (toggle with `ctrl+b`); `R` in the reader replies to sender + all CC recipients
8484+- **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
8485- **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)
8586- **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
8687- **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
···28282929In 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).
30303131+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.
3232+3133Press `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.
3434+3535+## Emoji Reactions
3636+3737+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.
3838+3939+**Available reactions:**
4040+- 👍 Thumbs up
4141+- ❤️ Love
4242+- 😂 Laugh
4343+- 🎉 Celebrate
4444+- 🙏 Thanks
4545+- 💯 Perfect
4646+- 👀 Eyes
4747+- ✅ Check
4848+4949+**How it works:**
5050+5151+1. Press `ctrl+e` while viewing or selecting an email
5252+2. Choose an emoji by pressing `1`-`8` (instant send) or navigate with `j`/`k` and press `enter`
5353+3. Press `esc` to cancel
5454+5555+The reaction is sent immediately (no editor, no pre-send review) as a properly formatted email with:
5656+5757+**Plain text:**
5858+```
5959+👍
6060+6161+Simon Späti reacted via [neomd](https://neomd.ssp.sh)
6262+6363+---
6464+6565+> **John Doe** wrote:
6666+>
6767+> original email body quoted here
6868+6969+---
7070+```
7171+7272+**HTML:**
7373+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.
7474+7575+**Threading:**
7676+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.
7777+7878+**From address:**
7979+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.
8080+8181+Emoji reactions are perfect for quick acknowledgments, celebrating good news, or thanking someone without the overhead of composing a full reply.
32823383## Attachments
3484
+13-13
internal/editor/editor.go
···107107}
108108109109// ReplyPrelude builds a quote block for replies. cc and from may be empty.
110110+// buildQuotedReply builds the quoted "wrote:" section used in replies and reactions.
111111+func buildQuotedReply(originalFrom, originalBody string) string {
112112+ return fmt.Sprintf("---\n\n> **%s** wrote:\n>\n%s\n\n---\n\n",
113113+ originalFrom, quoteLines(originalBody))
114114+}
115115+110116func ReplyPrelude(to, cc, subject, from, originalFrom, originalBody string) string {
111111- return Prelude(to, cc, "", from, subject, "") +
112112- fmt.Sprintf("---\n\n> **%s** wrote:\n>\n%s\n\n---\n\n",
113113- originalFrom, quoteLines(originalBody))
117117+ return Prelude(to, cc, "", from, subject, "") + buildQuotedReply(originalFrom, originalBody)
114118}
115119116120// ForwardPrelude builds a quoted forward block. The To field is left empty for
···130134}
131135132136// ReactionBody builds the plain text body for an emoji reaction.
133133-func ReactionBody(emoji, fromName string) string {
134134- return fmt.Sprintf("%s\n\n%s reacted via neomd (https://neomd.ssp.sh)\n", emoji, fromName)
135135-}
136136-137137-// ReactionBodyHTML builds the HTML body for an emoji reaction.
138138-func ReactionBodyHTML(emoji, fromName string) string {
139139- return fmt.Sprintf(`<div style="font-size: 48px; margin: 20px 0;">%s</div>
140140-<p style="color: #666; font-size: 14px; margin-top: 40px; border-top: 1px solid #ddd; padding-top: 20px;">
141141-%s reacted via <a href="https://neomd.ssp.sh" style="color: #7E9CD8; text-decoration: none;">neomd</a>
142142-</p>`, emoji, fromName)
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 {
140140+ 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)
143143}
144144145145// ParseHeaders scans raw editor content for # [neomd: key: value] lines and
+14-11
internal/imap/client.go
···715715}
716716717717// FetchBody fetches the body of a single message.
718718-// Returns (markdownBody, rawHTML, webURL, attachments, error).
719719-func (c *Client) FetchBody(ctx context.Context, folder string, uid uint32) (string, string, string, []Attachment, error) {
718718+// Returns (markdownBody, rawHTML, webURL, attachments, references, error).
719719+func (c *Client) FetchBody(ctx context.Context, folder string, uid uint32) (string, string, string, []Attachment, string, error) {
720720 if ctx == nil {
721721 ctx = context.Background()
722722 }
723723- var markdown, rawHTML, webURL string
723723+ var markdown, rawHTML, webURL, references string
724724 var attachments []Attachment
725725 err := c.withConn(ctx, func(conn *imapclient.Client) error {
726726 if err := c.selectMailbox(folder); err != nil {
···742742 }
743743744744 if len(msgs[0].BodySection) > 0 {
745745- markdown, rawHTML, webURL, attachments = parseBody(msgs[0].BodySection[0].Bytes)
745745+ markdown, rawHTML, webURL, attachments, references = parseBody(msgs[0].BodySection[0].Bytes)
746746 }
747747 return nil
748748 })
749749- return markdown, rawHTML, webURL, attachments, err
749749+ return markdown, rawHTML, webURL, attachments, references, err
750750}
751751752752// MoveMessage moves uid from src to dst using the IMAP MOVE command (RFC 6851).
···972972// - rawHTML: original HTML part verbatim (empty for plain-text emails)
973973// - webURL: "view online" URL extracted from List-Post header or plain-text
974974// preamble (e.g. Substack's "View this post on the web at https://…")
975975-func parseBody(raw []byte) (markdown, rawHTML, webURL string, attachments []Attachment) {
975975+func parseBody(raw []byte) (markdown, rawHTML, webURL string, attachments []Attachment, references string) {
976976 e, err := message.Read(bytes.NewReader(raw))
977977 if err != nil && !message.IsUnknownCharset(err) {
978978- return string(raw), "", "", nil
978978+ return string(raw), "", "", nil, ""
979979 }
980980981981 // Check if this is a neomd-authored draft. Drafts use the X-Neomd-Draft header
982982 // to signal that the plain text body is already markdown and should not be
983983 // normalized (which adds trailing spaces and would mutate the draft on each save/load).
984984 isDraft := e.Header.Get("X-Neomd-Draft") == "true"
985985+986986+ // Extract References header for email threading
987987+ references = e.Header.Get("References")
985988986989 // List-Post header contains the canonical article URL on most newsletters:
987990 // List-Post: <https://newsletter.example.com/p/slug>
···10851088 // text/plain part is typically a stripped dump with raw redirect URLs.
10861089 // Fall back to plain text for plain-text-only emails (e.g. direct replies).
10871090 if htmlText != "" {
10881088- return htmlToMarkdown(htmlText), htmlText, webURL, attachments
10911091+ return htmlToMarkdown(htmlText), htmlText, webURL, attachments, references
10891092 }
10901093 if plainText != "" {
10911094 // For neomd drafts, return the raw markdown without normalization.
10921095 // Normalization adds trailing spaces for hard line breaks, which would
10931096 // mutate the draft content on each save/reopen cycle.
10941097 if isDraft {
10951095- return plainText, "", webURL, attachments
10981098+ return plainText, "", webURL, attachments, references
10961099 }
10971097- return normalizePlainText(plainText), "", webURL, attachments
11001100+ return normalizePlainText(plainText), "", webURL, attachments, references
10981101 }
10991099- return "(no body)", "", webURL, attachments
11021102+ return "(no body)", "", webURL, attachments, references
11001103}
1101110411021105// extractPlainTextWebURL looks for a "View … on the web at https://…" line
+5-5
internal/imap/client_test.go
···228228 "iVBORw0KGgo=\r\n" +
229229 "--" + boundary + "--\r\n"
230230231231- _, _, _, attachments := parseBody([]byte(raw))
231231+ _, _, _, attachments, _ := parseBody([]byte(raw))
232232233233 if len(attachments) == 0 {
234234 t.Fatal("expected at least 1 attachment, got 0")
···273273 "JVBERi0=\r\n" +
274274 "--" + boundary + "--\r\n"
275275276276- _, _, _, attachments := parseBody([]byte(raw))
276276+ _, _, _, attachments, _ := parseBody([]byte(raw))
277277278278 if len(attachments) == 0 {
279279 t.Fatal("expected at least 1 attachment, got 0")
···334334 originalBody
335335336336 // First parse (simulating draft reopen)
337337- body1, _, _, _ := parseBody([]byte(draftMIME))
337337+ body1, _, _, _, _ := parseBody([]byte(draftMIME))
338338339339 // Verify the body matches exactly (no trailing spaces added)
340340 if body1 != originalBody {
···351351 "\r\n" +
352352 body1 // Use the result from first parse
353353354354- body2, _, _, _ := parseBody([]byte(draftMIME2))
354354+ body2, _, _, _, _ := parseBody([]byte(draftMIME2))
355355356356 // Verify still matches exactly (no accumulation of trailing spaces)
357357 if body2 != originalBody {
···378378 "\r\n" +
379379 originalBody
380380381381- body, _, _, _ := parseBody([]byte(regularMIME))
381381+ body, _, _, _, _ := parseBody([]byte(regularMIME))
382382383383 // Normalization should add two trailing spaces before the newline
384384 expectedNormalized := "Line 1 \nLine 2"
···292292293293// BuildReactionMessage constructs a minimal reaction email with threading headers.
294294// Used for emoji reactions sent as replies to emails.
295295-// plainBody and htmlBody are pre-formatted reaction messages (emoji + footer).
295295+// markdownBody contains the reaction text with quoted original message in markdown format.
296296// inReplyTo is the Message-ID of the original email.
297297// references is the References chain from the original email (may be empty).
298298-func BuildReactionMessage(from, to, cc, subject, plainBody, htmlBody, inReplyTo, references string) ([]byte, error) {
298298+func BuildReactionMessage(from, to, cc, subject, markdownBody, inReplyTo, references string) ([]byte, error) {
299299+ // Convert markdown to HTML (same as regular replies)
300300+ htmlBody, err := render.ToHTML(markdownBody)
301301+ if err != nil {
302302+ return nil, fmt.Errorf("markdown to html: %w", err)
303303+ }
304304+299305 // Build References chain: append inReplyTo to existing references
300306 refChain := references
301307 if inReplyTo != "" {
···306312 }
307313 }
308314 // No attachments for reactions
309309- return buildMessageWithBCC(from, to, cc, "", subject, plainBody, htmlBody, nil, inReplyTo, refChain)
315315+ return buildMessageWithBCC(from, to, cc, "", subject, markdownBody, htmlBody, nil, inReplyTo, refChain)
310316}
311317312318// inlineImage holds a local image path and its assigned Content-ID.
+32-19
internal/ui/model.go
···5151 rawHTML string // original HTML part, empty for plain-text emails
5252 webURL string // canonical "view online" URL (List-Post header or plain-text preamble)
5353 attachments []imap.Attachment
5454+ references string // References header for email threading
5455 }
5556 sendDoneMsg struct {
5657 err error
···729730730731func (m Model) fetchBodyCmd(e *imap.Email) tea.Cmd {
731732 return func() tea.Msg {
732732- body, rawHTML, webURL, attachments, err := m.imapCli().FetchBody(nil, e.Folder, e.UID)
733733+ body, rawHTML, webURL, attachments, references, err := m.imapCli().FetchBody(nil, e.Folder, e.UID)
733734 if err != nil {
734735 return errMsg{err}
735736 }
736736- return bodyLoadedMsg{email: e, body: body, rawHTML: rawHTML, webURL: webURL, attachments: attachments}
737737+ return bodyLoadedMsg{email: e, body: body, rawHTML: rawHTML, webURL: webURL, attachments: attachments, references: references}
737738 }
738739}
739740···808809 fromName = extractEmailAddr(from)
809810 }
810811811811- // Build reaction bodies
812812- bodyPlain := editor.ReactionBody(emoji.emoji, fromName)
813813- bodyHTML := editor.ReactionBodyHTML(emoji.emoji, fromName)
812812+ // 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)
814815815816 // Get SMTP account
816817 smtpAcct := m.activeAccount()
···833834834835 return m, tea.Batch(
835836 m.spinner.Tick,
836836- m.sendReactionCmd(smtpAcct, from, to, subject, bodyPlain, bodyHTML, e),
837837+ m.sendReactionCmd(smtpAcct, from, to, subject, bodyMarkdown, e),
837838 )
838839}
839840840840-func (m Model) sendReactionCmd(smtpAcct config.AccountConfig, from, to, subject, bodyPlain, bodyHTML string, originalEmail *imap.Email) tea.Cmd {
841841+func (m Model) sendReactionCmd(smtpAcct config.AccountConfig, from, to, subject, bodyMarkdown string, originalEmail *imap.Email) tea.Cmd {
841842 h, p := splitAddr(smtpAcct.SMTP)
842843 cfg := smtp.Config{
843844 Host: h,
···854855 replyCli := m.imapCli()
855856856857 return func() tea.Msg {
857857- // Build reaction message with threading headers
858858+ // Build References chain: use existing References or fall back to InReplyTo
859859+ references := originalEmail.References
860860+ if references == "" && originalEmail.InReplyTo != "" {
861861+ references = originalEmail.InReplyTo
862862+ }
863863+864864+ // Build reaction message with threading headers (markdown will be converted to HTML)
858865 raw, err := smtp.BuildReactionMessage(
859866 from, to, "", subject,
860860- bodyPlain, bodyHTML,
867867+ bodyMarkdown,
861868 originalEmail.MessageID,
862862- originalEmail.References,
869869+ references,
863870 )
864871 if err != nil {
865872 return sendDoneMsg{err: fmt.Errorf("build reaction: %w", err)}
···16251632 m.openHTMLBody = msg.rawHTML
16261633 m.openWebURL = msg.webURL
16271634 m.openAttachments = msg.attachments
16351635+ // Store References header in the email struct for threading
16361636+ if msg.email != nil {
16371637+ msg.email.References = msg.references
16381638+ }
16281639 // Mark as seen in background (best-effort)
16291640 uid := msg.email.UID
16301641 folder := msg.email.Folder
···19621973 m.pendingSend.replyToAccount = m.activeAccount().Name
19631974 // Populate threading headers for proper email conversation threading
19641975 m.pendingSend.inReplyTo = m.openEmail.MessageID
19651965- m.pendingSend.references = m.openEmail.References
19761976+ // Build References chain: use existing References or fall back to InReplyTo
19771977+ if m.openEmail.References != "" {
19781978+ m.pendingSend.references = m.openEmail.References
19791979+ } else if m.openEmail.InReplyTo != "" {
19801980+ // Fall back to InReplyTo if References not available
19811981+ m.pendingSend.references = m.openEmail.InReplyTo
19821982+ }
19661983 }
19671984 m.state = statePresend
19681985 m.status = ""
···24512468 if idx := m.matchFromIndex(e.To, e.CC); idx >= 0 {
24522469 m.presendFromI = idx
24532470 }
24542454- // Check if we have Message-ID (needed for threading headers)
24552455- if e.MessageID == "" {
24562456- // Need to fetch body/headers first
24572457- m.pendingReaction = true
24582458- m.loading = true
24592459- return m, tea.Batch(m.spinner.Tick, m.fetchBodyCmd(e))
24602460- }
24612461- return m.enterReactionMode(e)
24712471+ // Always fetch body first (needed for quoted message in reaction)
24722472+ m.pendingReaction = true
24732473+ m.loading = true
24742474+ return m, tea.Batch(m.spinner.Tick, m.fetchBodyCmd(e))
2462247524632476 case "f":
24642477 e := selectedEmail(m.inbox)
+2-2
internal/ui/reader.go
···126126// readerHelp returns the one-line help string for the reader view.
127127// When isDraft is true, "E draft" is shown so the user knows they can re-open in compose.
128128func readerHelp(isDraft bool, hasLinks bool) string {
129129- keys := []string{"j/k scroll", "h/q back", "r reply", "ctrl+r reply-all", "f fwd", "e nvim"}
129129+ keys := []string{"j/k scroll", "h/q back", "r reply", "ctrl+r reply-all", "ctrl+e react", "f fwd", "e nvim"}
130130 if isDraft {
131131 keys = append(keys, "E draft")
132132 }
···140140141141// inboxHelp returns the one-line help string for the inbox view.
142142func inboxHelp(folder string) string {
143143- 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"}
143143+ 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"}
144144 _ = folder
145145 if folder == "ToScreen" {
146146 base = []string{"I approve", "O block", "F feed", "P papertrail", "q back"}