···113113| `R` | reload / refresh folder |
114114| `r` | reply (from inbox or reader) |
115115| `ctrl+r` | reply-all — reply to sender + all CC recipients (from inbox or reader) |
116116+| `ctrl+e` | react with emoji (from inbox or reader) |
116117| `f` | forward email (from reader or inbox) |
117118| `T` | show full conversation thread across folders (from inbox or reader) |
118119| `c` | compose new email |
+13
internal/editor/editor.go
···129129 return s
130130}
131131132132+// 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)
143143+}
144144+132145// ParseHeaders scans raw editor content for # [neomd: key: value] lines and
133146// returns the extracted to, cc, bcc, from, subject values and the remaining body
134147// (with header lines stripped). Any field not found is returned as "".
+3
internal/imap/client.go
···4949 HasAttachment bool // true if BODYSTRUCTURE contains an attachment part
5050 MessageID string // Message-ID from envelope (for threading)
5151 InReplyTo string // first In-Reply-To message ID (for threading)
5252+ References string // References header (space-separated Message-IDs for threading)
5253}
53545455// Config holds connection parameters.
···325326 if len(m.Envelope.InReplyTo) > 0 {
326327 e.InReplyTo = m.Envelope.InReplyTo[0]
327328 }
329329+ // Note: References header is fetched when the body is loaded (FetchBody)
330330+ // because it's not available in the IMAP Envelope structure.
328331 }
329332 e.Size = uint32(m.RFC822Size)
330333 e.HasAttachment = hasAttachment(m.BodyStructure)
+53-5
internal/smtp/sender.go
···249249// otherwise the structure is unchanged (multipart/alternative only).
250250// htmlSignature, if non-empty, is injected before the closing </body> tag in the HTML part.
251251func BuildMessage(from, to, cc, subject, markdownBody string, attachments []string, htmlSignature string) ([]byte, error) {
252252+ return BuildMessageWithThreading(from, to, cc, subject, markdownBody, attachments, htmlSignature, "", "")
253253+}
254254+255255+// BuildMessageWithThreading builds a MIME message with optional threading headers (In-Reply-To, References).
256256+// Used for replies and forwards to maintain proper email conversation threading.
257257+func BuildMessageWithThreading(from, to, cc, subject, markdownBody string, attachments []string, htmlSignature, inReplyTo, references string) ([]byte, error) {
252258 htmlBody, err := render.ToHTML(markdownBody)
253259 if err != nil {
254260 return nil, fmt.Errorf("markdown to html: %w", err)
···262268 htmlBody = htmlBody[:idx] + "\n" + htmlSignature + "\n" + htmlBody[idx:]
263269 }
264270 }
265265- return buildMessage(from, to, cc, subject, markdownBody, htmlBody, attachments)
271271+ // Build References chain: append inReplyTo to existing references
272272+ refChain := references
273273+ if inReplyTo != "" {
274274+ if refChain != "" {
275275+ refChain = refChain + " " + inReplyTo
276276+ } else {
277277+ refChain = inReplyTo
278278+ }
279279+ }
280280+ return buildMessageWithBCC(from, to, cc, "", subject, markdownBody, htmlBody, attachments, inReplyTo, refChain)
266281}
267282268283// BuildDraftMessage constructs a raw MIME draft for IMAP APPEND.
···271286// Drafts are stored as plain text only (no HTML conversion) to preserve the
272287// original markdown formatting exactly during save/load cycles.
273288func BuildDraftMessage(from, to, cc, bcc, subject, markdownBody string, attachments []string) ([]byte, error) {
274274- // Pass empty htmlBody to store plain text only
275275- return buildMessageWithBCC(from, to, cc, bcc, subject, markdownBody, "", attachments)
289289+ // Pass empty htmlBody to store plain text only; no threading headers for drafts
290290+ return buildMessageWithBCC(from, to, cc, bcc, subject, markdownBody, "", attachments, "", "")
291291+}
292292+293293+// 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).
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) {
299299+ // Build References chain: append inReplyTo to existing references
300300+ refChain := references
301301+ if inReplyTo != "" {
302302+ if refChain != "" {
303303+ refChain = refChain + " " + inReplyTo
304304+ } else {
305305+ refChain = inReplyTo
306306+ }
307307+ }
308308+ // No attachments for reactions
309309+ return buildMessageWithBCC(from, to, cc, "", subject, plainBody, htmlBody, nil, inReplyTo, refChain)
276310}
277311278312// inlineImage holds a local image path and its assigned Content-ID.
···291325// - images only → multipart/related > (multipart/alternative + inline images)
292326// - images + files → multipart/mixed > (multipart/related > alt+images) + files
293327func buildMessage(from, to, cc, subject, plainText, htmlBody string, attachments []string) ([]byte, error) {
294294- return buildMessageWithBCC(from, to, cc, "", subject, plainText, htmlBody, attachments)
328328+ return buildMessageWithBCC(from, to, cc, "", subject, plainText, htmlBody, attachments, "", "")
295329}
296330297297-func buildMessageWithBCC(from, to, cc, bcc, subject, plainText, htmlBody string, attachments []string) ([]byte, error) {
331331+func buildMessageWithBCC(from, to, cc, bcc, subject, plainText, htmlBody string, attachments []string, inReplyTo, references string) ([]byte, error) {
298332 // Find local image paths in htmlBody (<img src="/abs/path">), assign CIDs.
299333 var inlines []inlineImage
300334 processedHTML := imgSrcRe.ReplaceAllStringFunc(htmlBody, func(match string) string {
···332366 hdr("Subject", mime.QEncoding.Encode("utf-8", subject))
333367 hdr("Date", time.Now().Format(time.RFC1123Z))
334368 hdr("Message-ID", "<"+msgID+"@neomd>")
369369+ // Threading headers for replies
370370+ if inReplyTo != "" {
371371+ hdr("In-Reply-To", inReplyTo)
372372+ }
373373+ if references != "" {
374374+ hdr("References", references)
375375+ }
335376 hdr("MIME-Version", "1.0")
336377 hdr("Content-Type", contentType)
337378 hdr("X-Mailer", "neomd")
···356397 hdr("Subject", mime.QEncoding.Encode("utf-8", subject))
357398 hdr("Date", time.Now().Format(time.RFC1123Z))
358399 hdr("Message-ID", "<"+msgID+"@neomd>")
400400+ // Threading headers for replies
401401+ if inReplyTo != "" {
402402+ hdr("In-Reply-To", inReplyTo)
403403+ }
404404+ if references != "" {
405405+ hdr("References", references)
406406+ }
359407 hdr("MIME-Version", "1.0")
360408 hdr("Content-Type", "text/plain; charset=utf-8")
361409 hdr("Content-Transfer-Encoding", "quoted-printable")
+1
internal/ui/keys.go
···8585 {"R", "reload / refresh folder"},
8686 {"r", "reply (from inbox or reader)"},
8787 {"ctrl+r", "reply-all — reply to sender + all CC recipients (from inbox or reader)"},
8888+ {"ctrl+e", "react with emoji (from inbox or reader)"},
8889 {"f", "forward email (from reader or inbox)"},
8990 {"T", "show full conversation thread across folders (from inbox or reader)"},
9091 {"c", "compose new email"},
+211-3
internal/ui/model.go
···3636 statePresend // pre-send review: add attachments, then send or edit again
3737 stateHelp // help overlay
3838 stateWelcome // first-run welcome popup
3939+ stateReaction // emoji reaction picker
3940)
40414142// async message types
···395396 replyToUID uint32
396397 replyToFolder string
397398 replyToAccount string
399399+ // Threading headers for proper email conversation threading
400400+ inReplyTo string
401401+ references string
398402}
399403400404// undoMove records one IMAP move so it can be reversed with u.
···452456 pendingSend *pendingSendData
453457 presendFromI int // index into presendFroms() for the From field cycle
454458459459+ // Reaction
460460+ reactionEmail *imap.Email // email being reacted to
461461+ reactionSelected int // selected emoji index (0-7)
462462+ pendingReaction bool // true if we need to fetch body before entering reaction mode
463463+455464 // Status / error
456465 status string
457466 isError bool
···728737 }
729738}
730739731731-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 {
740740+func (m Model) sendEmailCmd(smtpAcct config.AccountConfig, from, to, cc, bcc, subject, body string, attachments []string, includeHTMLSig bool, replyToUID uint32, replyToFolder, replyToAccount, inReplyTo, references string) tea.Cmd {
732741 h, p := splitAddr(smtpAcct.SMTP)
733742 cfg := smtp.Config{
734743 Host: h,
···750759 return func() tea.Msg {
751760 // Build raw MIME once — reused for both SMTP delivery and Sent copy.
752761 // BCC is intentionally excluded from headers but included in RCPT TO.
753753- raw, err := smtp.BuildMessage(from, to, cc, subject, body, attachments, htmlSignature)
762762+ raw, err := smtp.BuildMessageWithThreading(from, to, cc, subject, body, attachments, htmlSignature, inReplyTo, references)
754763 if err != nil {
755764 return sendDoneMsg{err: fmt.Errorf("build message: %w", err)}
756765 }
···770779 }
771780}
772781782782+func (m Model) sendReaction(emojiIndex int) (tea.Model, tea.Cmd) {
783783+ if m.reactionEmail == nil || emojiIndex < 0 || emojiIndex >= len(defaultReactions) {
784784+ return m, nil
785785+ }
786786+787787+ emoji := defaultReactions[emojiIndex]
788788+ e := m.reactionEmail
789789+790790+ // Determine recipient (Reply-To takes precedence over From)
791791+ to := e.ReplyTo
792792+ if to == "" {
793793+ to = e.From
794794+ }
795795+796796+ // Build subject with "Re:" prefix
797797+ subject := e.Subject
798798+ low := strings.ToLower(subject)
799799+ if !strings.HasPrefix(low, "re:") && !strings.HasPrefix(low, "aw:") &&
800800+ !strings.HasPrefix(low, "sv:") && !strings.HasPrefix(low, "vs:") {
801801+ subject = "Re: " + subject
802802+ }
803803+804804+ // Extract sender name for footer
805805+ from := m.presendFrom()
806806+ fromName := extractName(from)
807807+ if fromName == "" {
808808+ fromName = extractEmailAddr(from)
809809+ }
810810+811811+ // Build reaction bodies
812812+ bodyPlain := editor.ReactionBody(emoji.emoji, fromName)
813813+ bodyHTML := editor.ReactionBodyHTML(emoji.emoji, fromName)
814814+815815+ // Get SMTP account
816816+ smtpAcct := m.activeAccount()
817817+ if m.presendFromI > 0 && m.presendFromI-1 < len(m.cfg.Senders) {
818818+ // Sender alias selected; find its SMTP account
819819+ alias := m.cfg.Senders[m.presendFromI-1]
820820+ for _, acc := range m.accounts {
821821+ if acc.Name == alias.Account {
822822+ smtpAcct = acc
823823+ break
824824+ }
825825+ }
826826+ }
827827+828828+ // Reset state before sending
829829+ m.state = m.prevState
830830+ m.loading = true
831831+ m.status = fmt.Sprintf("Sending %s...", emoji.emoji)
832832+ m.reactionEmail = nil
833833+834834+ return m, tea.Batch(
835835+ m.spinner.Tick,
836836+ m.sendReactionCmd(smtpAcct, from, to, subject, bodyPlain, bodyHTML, e),
837837+ )
838838+}
839839+840840+func (m Model) sendReactionCmd(smtpAcct config.AccountConfig, from, to, subject, bodyPlain, bodyHTML string, originalEmail *imap.Email) tea.Cmd {
841841+ h, p := splitAddr(smtpAcct.SMTP)
842842+ cfg := smtp.Config{
843843+ Host: h,
844844+ Port: p,
845845+ User: smtpAcct.User,
846846+ Password: smtpAcct.Password,
847847+ From: from,
848848+ STARTTLS: smtpAcct.STARTTLS,
849849+ TLSCertFile: smtpAcct.TLSCertFile,
850850+ TokenSource: m.tokenSourceFor(smtpAcct.Name),
851851+ }
852852+ cli := m.sentDraftsIMAPClient()
853853+ sentFolder := m.cfg.Folders.Sent
854854+ replyCli := m.imapCli()
855855+856856+ return func() tea.Msg {
857857+ // Build reaction message with threading headers
858858+ raw, err := smtp.BuildReactionMessage(
859859+ from, to, "", subject,
860860+ bodyPlain, bodyHTML,
861861+ originalEmail.MessageID,
862862+ originalEmail.References,
863863+ )
864864+ if err != nil {
865865+ return sendDoneMsg{err: fmt.Errorf("build reaction: %w", err)}
866866+ }
867867+868868+ // Send via SMTP
869869+ toAddrs := []string{extractEmailAddr(to)}
870870+ if err := smtp.SendRaw(cfg, toAddrs, raw); err != nil {
871871+ return sendDoneMsg{err: err}
872872+ }
873873+874874+ // Save copy to Sent folder (non-fatal if it fails)
875875+ if saveErr := cli.SaveSent(nil, sentFolder, raw); saveErr != nil {
876876+ return sendDoneMsg{
877877+ warning: "Sent, but failed to save to Sent folder: " + saveErr.Error(),
878878+ replyToUID: originalEmail.UID,
879879+ replyToFolder: originalEmail.Folder,
880880+ }
881881+ }
882882+883883+ // Mark original email as \Answered (non-fatal)
884884+ if originalEmail.UID > 0 && originalEmail.Folder != "" {
885885+ _ = replyCli.MarkAnswered(nil, originalEmail.Folder, originalEmail.UID)
886886+ }
887887+888888+ return sendDoneMsg{replyToUID: originalEmail.UID, replyToFolder: originalEmail.Folder}
889889+ }
890890+}
891891+773892// collectRcptTo returns deduplicated bare email addresses for SMTP RCPT TO.
774893func collectRcptTo(to, cc, bcc string) []string {
775894 seen := make(map[string]bool)
···15221641 m.pendingReplyAll = false
15231642 return m.launchReplyAllCmd()
15241643 }
16441644+ if m.pendingReaction {
16451645+ m.pendingReaction = false
16461646+ return m.enterReactionMode(msg.email)
16471647+ }
15251648 m.openLinks = extractLinks(msg.body)
15261649 _ = loadEmailIntoReader(&m.reader, msg.email, msg.body, msg.attachments, m.openLinks, m.cfg.UI.Theme, m.width)
15271650 m.state = stateReading
···18371960 m.pendingSend.replyToUID = m.openEmail.UID
18381961 m.pendingSend.replyToFolder = m.openEmail.Folder
18391962 m.pendingSend.replyToAccount = m.activeAccount().Name
19631963+ // Populate threading headers for proper email conversation threading
19641964+ m.pendingSend.inReplyTo = m.openEmail.MessageID
19651965+ m.pendingSend.references = m.openEmail.References
18401966 }
18411967 m.state = statePresend
18421968 m.status = ""
···18792005 // Any key dismisses the welcome popup
18802006 m.state = stateInbox
18812007 return m, nil
20082008+ case stateReaction:
20092009+ return m.updateReaction(msg)
18822010 }
18832011 }
18842012···23152443 m.loading = true
23162444 return m, tea.Batch(m.spinner.Tick, m.fetchBodyCmd(e))
2317244524462446+ case "ctrl+e":
24472447+ e := selectedEmail(m.inbox)
24482448+ if e == nil {
24492449+ return m, nil
24502450+ }
24512451+ if idx := m.matchFromIndex(e.To, e.CC); idx >= 0 {
24522452+ m.presendFromI = idx
24532453+ }
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)
24622462+23182463 case "f":
23192464 e := selectedEmail(m.inbox)
23202465 if e == nil {
···26372782 case "ctrl+r":
26382783 if m.openEmail != nil {
26392784 return m.launchReplyAllCmd()
27852785+ }
27862786+ case "ctrl+e":
27872787+ if m.openEmail != nil {
27882788+ return m.enterReactionMode(m.openEmail)
26402789 }
26412790 case "f":
26422791 if m.openEmail != nil {
···31123261 includeHTMLSig, cleanBody := extractHTMLSignatureMarker(ps.body)
31133262 m.attachments = nil
31143263 m.pendingSend = nil
31153115- 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))
32643264+ 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, ps.inReplyTo, ps.references))
31163265 case "ctrl+f":
31173266 froms := m.presendFroms()
31183267 if len(froms) <= 1 {
···34683617 return m.launchReplyWithCC("", true)
34693618}
3470361936203620+func (m Model) enterReactionMode(e *imap.Email) (tea.Model, tea.Cmd) {
36213621+ m.prevState = m.state
36223622+ m.state = stateReaction
36233623+ m.reactionEmail = e
36243624+ m.reactionSelected = 0
36253625+ m.pendingReaction = false
36263626+36273627+ // Pre-select the correct From address (same logic as reply)
36283628+ if idx := m.matchFromIndex(e.To, e.CC); idx >= 0 {
36293629+ m.presendFromI = idx
36303630+ }
36313631+36323632+ return m, nil
36333633+}
36343634+34713635func (m Model) launchForwardCmd() (tea.Model, tea.Cmd) {
34723636 e := m.openEmail
34733637 if e == nil {
···36773841 return strings.TrimSpace(s)
36783842}
3679384338443844+// extractName extracts the name part from "Name <email@example.com>" format.
38453845+// Returns empty string if there's no name part.
38463846+func extractName(s string) string {
38473847+ if i := strings.IndexByte(s, '<'); i >= 0 {
38483848+ name := strings.TrimSpace(s[:i])
38493849+ // Remove quotes if present
38503850+ name = strings.Trim(name, "\"")
38513851+ return name
38523852+ }
38533853+ return ""
38543854+}
38553855+36803856// splitAddrs splits a comma-separated address list, skipping empty entries.
36813857func splitAddrs(s string) []string {
36823858 var out []string
···37853961 return m.viewHelp()
37863962 case stateWelcome:
37873963 return m.viewWelcome()
39643964+ case stateReaction:
39653965+ return m.viewReaction()
37883966 }
37893967 return ""
37903968}
···39414119 b.WriteString(composeHelp(int(m.compose.step), len(m.presendFroms()) > 1))
39424120 }
39434121 return b.String()
41224122+}
41234123+41244124+func (m Model) updateReaction(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
41254125+ switch msg.String() {
41264126+ case "esc", "q":
41274127+ m.state = m.prevState
41284128+ m.reactionEmail = nil
41294129+ return m, nil
41304130+41314131+ case "j", "down":
41324132+ if m.reactionSelected < len(defaultReactions)-1 {
41334133+ m.reactionSelected++
41344134+ }
41354135+41364136+ case "k", "up":
41374137+ if m.reactionSelected > 0 {
41384138+ m.reactionSelected--
41394139+ }
41404140+41414141+ case "1", "2", "3", "4", "5", "6", "7", "8":
41424142+ idx, _ := strconv.Atoi(msg.String())
41434143+ if idx >= 1 && idx <= len(defaultReactions) {
41444144+ return m.sendReaction(idx - 1)
41454145+ }
41464146+41474147+ case "enter":
41484148+ return m.sendReaction(m.reactionSelected)
41494149+ }
41504150+41514151+ return m, nil
39444152}
3945415339464154func (m Model) updateHelp(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+85
internal/ui/reaction.go
···11+package ui
22+33+import (
44+ "fmt"
55+ "strings"
66+77+ "github.com/charmbracelet/lipgloss"
88+)
99+1010+// reactionEmoji represents a single emoji reaction option.
1111+type reactionEmoji struct {
1212+ emoji string
1313+ label string
1414+}
1515+1616+// defaultReactions is the list of emoji reactions available to the user.
1717+var defaultReactions = []reactionEmoji{
1818+ {"👍", "Thumbs up"},
1919+ {"❤️", "Love"},
2020+ {"😂", "Laugh"},
2121+ {"🎉", "Celebrate"},
2222+ {"🙏", "Thanks"},
2323+ {"💯", "Perfect"},
2424+ {"👀", "Eyes"},
2525+ {"✅", "Check"},
2626+}
2727+2828+// viewReaction renders the emoji picker overlay.
2929+func (m Model) viewReaction() string {
3030+ if m.reactionEmail == nil {
3131+ return "no email selected"
3232+ }
3333+3434+ // Header: subject and from
3535+ title := lipgloss.NewStyle().
3636+ Bold(true).
3737+ Foreground(lipgloss.Color("#7E9CD8")).
3838+ Render("React to: " + truncate(m.reactionEmail.Subject, 40))
3939+4040+ from := lipgloss.NewStyle().
4141+ Foreground(lipgloss.Color("#727169")).
4242+ Render("From: " + m.reactionEmail.From)
4343+4444+ // Emoji list
4545+ var items []string
4646+ for i, r := range defaultReactions {
4747+ style := lipgloss.NewStyle()
4848+ if i == m.reactionSelected {
4949+ style = style.Background(lipgloss.Color("#2D4F67"))
5050+ }
5151+5252+ line := fmt.Sprintf(" %d %s %s", i+1, r.emoji, r.label)
5353+ items = append(items, style.Render(line))
5454+ }
5555+5656+ help := lipgloss.NewStyle().
5757+ Foreground(lipgloss.Color("#727169")).
5858+ Render("Press 1-8 or j/k + enter • esc cancel")
5959+6060+ // Combine all elements
6161+ content := lipgloss.JoinVertical(
6262+ lipgloss.Left,
6363+ title,
6464+ from,
6565+ "",
6666+ strings.Join(items, "\n"),
6767+ "",
6868+ help,
6969+ )
7070+7171+ // Box around the content
7272+ box := lipgloss.NewStyle().
7373+ Border(lipgloss.RoundedBorder()).
7474+ BorderForeground(lipgloss.Color("#54546D")).
7575+ Padding(1, 2).
7676+ Width(50).
7777+ Render(content)
7878+7979+ // Center the box
8080+ return lipgloss.Place(
8181+ m.width, m.height,
8282+ lipgloss.Center, lipgloss.Center,
8383+ box,
8484+ )
8585+}