diff --git a/docs/keybindings.md b/docs/keybindings.md index 17b9eee..cf513ce 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -113,6 +113,7 @@ Press `?` inside neomd to open the interactive help overlay. Start typing to fil | `R` | reload / refresh folder | | `r` | reply (from inbox or reader) | | `ctrl+r` | reply-all — reply to sender + all CC recipients (from inbox or reader) | +| `ctrl+e` | react with emoji (from inbox or reader) | | `f` | forward email (from reader or inbox) | | `T` | show full conversation thread across folders (from inbox or reader) | | `c` | compose new email | diff --git a/internal/editor/editor.go b/internal/editor/editor.go index 9cf87a1..967ff3b 100644 --- a/internal/editor/editor.go +++ b/internal/editor/editor.go @@ -129,6 +129,19 @@ func ForwardPrelude(subject, from, originalFrom, originalDate, originalTo, origi return s } +// ReactionBody builds the plain text body for an emoji reaction. +func ReactionBody(emoji, fromName string) string { + return fmt.Sprintf("%s\n\n%s reacted via neomd (https://neomd.ssp.sh)\n", emoji, fromName) +} + +// ReactionBodyHTML builds the HTML body for an emoji reaction. +func ReactionBodyHTML(emoji, fromName string) string { + return fmt.Sprintf(`
%s
+

+%s reacted via neomd +

`, emoji, fromName) +} + // ParseHeaders scans raw editor content for # [neomd: key: value] lines and // returns the extracted to, cc, bcc, from, subject values and the remaining body // (with header lines stripped). Any field not found is returned as "". diff --git a/internal/imap/client.go b/internal/imap/client.go index c5edfa7..efa79dc 100644 --- a/internal/imap/client.go +++ b/internal/imap/client.go @@ -49,6 +49,7 @@ type Email struct { HasAttachment bool // true if BODYSTRUCTURE contains an attachment part MessageID string // Message-ID from envelope (for threading) InReplyTo string // first In-Reply-To message ID (for threading) + References string // References header (space-separated Message-IDs for threading) } // Config holds connection parameters. @@ -325,6 +326,8 @@ func (c *Client) FetchHeaders(ctx context.Context, folder string, n int) ([]Emai if len(m.Envelope.InReplyTo) > 0 { e.InReplyTo = m.Envelope.InReplyTo[0] } + // Note: References header is fetched when the body is loaded (FetchBody) + // because it's not available in the IMAP Envelope structure. } e.Size = uint32(m.RFC822Size) e.HasAttachment = hasAttachment(m.BodyStructure) diff --git a/internal/smtp/sender.go b/internal/smtp/sender.go index d7772f4..5d48dd8 100644 --- a/internal/smtp/sender.go +++ b/internal/smtp/sender.go @@ -249,6 +249,12 @@ func sendSTARTTLSWithConfig(addr, host string, tlsCfg *tls.Config, auth smtp.Aut // otherwise the structure is unchanged (multipart/alternative only). // htmlSignature, if non-empty, is injected before the closing tag in the HTML part. func BuildMessage(from, to, cc, subject, markdownBody string, attachments []string, htmlSignature string) ([]byte, error) { + return BuildMessageWithThreading(from, to, cc, subject, markdownBody, attachments, htmlSignature, "", "") +} + +// BuildMessageWithThreading builds a MIME message with optional threading headers (In-Reply-To, References). +// Used for replies and forwards to maintain proper email conversation threading. +func BuildMessageWithThreading(from, to, cc, subject, markdownBody string, attachments []string, htmlSignature, inReplyTo, references string) ([]byte, error) { htmlBody, err := render.ToHTML(markdownBody) if err != nil { return nil, fmt.Errorf("markdown to html: %w", err) @@ -262,7 +268,16 @@ func BuildMessage(from, to, cc, subject, markdownBody string, attachments []stri htmlBody = htmlBody[:idx] + "\n" + htmlSignature + "\n" + htmlBody[idx:] } } - return buildMessage(from, to, cc, subject, markdownBody, htmlBody, attachments) + // Build References chain: append inReplyTo to existing references + refChain := references + if inReplyTo != "" { + if refChain != "" { + refChain = refChain + " " + inReplyTo + } else { + refChain = inReplyTo + } + } + return buildMessageWithBCC(from, to, cc, "", subject, markdownBody, htmlBody, attachments, inReplyTo, refChain) } // BuildDraftMessage constructs a raw MIME draft for IMAP APPEND. @@ -271,8 +286,27 @@ func BuildMessage(from, to, cc, subject, markdownBody string, attachments []stri // Drafts are stored as plain text only (no HTML conversion) to preserve the // original markdown formatting exactly during save/load cycles. func BuildDraftMessage(from, to, cc, bcc, subject, markdownBody string, attachments []string) ([]byte, error) { - // Pass empty htmlBody to store plain text only - return buildMessageWithBCC(from, to, cc, bcc, subject, markdownBody, "", attachments) + // Pass empty htmlBody to store plain text only; no threading headers for drafts + return buildMessageWithBCC(from, to, cc, bcc, subject, markdownBody, "", attachments, "", "") +} + +// BuildReactionMessage constructs a minimal reaction email with threading headers. +// Used for emoji reactions sent as replies to emails. +// plainBody and htmlBody are pre-formatted reaction messages (emoji + footer). +// inReplyTo is the Message-ID of the original email. +// references is the References chain from the original email (may be empty). +func BuildReactionMessage(from, to, cc, subject, plainBody, htmlBody, inReplyTo, references string) ([]byte, error) { + // Build References chain: append inReplyTo to existing references + refChain := references + if inReplyTo != "" { + if refChain != "" { + refChain = refChain + " " + inReplyTo + } else { + refChain = inReplyTo + } + } + // No attachments for reactions + return buildMessageWithBCC(from, to, cc, "", subject, plainBody, htmlBody, nil, inReplyTo, refChain) } // inlineImage holds a local image path and its assigned Content-ID. @@ -291,10 +325,10 @@ type inlineImage struct { // - images only → multipart/related > (multipart/alternative + inline images) // - images + files → multipart/mixed > (multipart/related > alt+images) + files func buildMessage(from, to, cc, subject, plainText, htmlBody string, attachments []string) ([]byte, error) { - return buildMessageWithBCC(from, to, cc, "", subject, plainText, htmlBody, attachments) + return buildMessageWithBCC(from, to, cc, "", subject, plainText, htmlBody, attachments, "", "") } -func buildMessageWithBCC(from, to, cc, bcc, subject, plainText, htmlBody string, attachments []string) ([]byte, error) { +func buildMessageWithBCC(from, to, cc, bcc, subject, plainText, htmlBody string, attachments []string, inReplyTo, references string) ([]byte, error) { // Find local image paths in htmlBody (), assign CIDs. var inlines []inlineImage processedHTML := imgSrcRe.ReplaceAllStringFunc(htmlBody, func(match string) string { @@ -332,6 +366,13 @@ func buildMessageWithBCC(from, to, cc, bcc, subject, plainText, htmlBody string, hdr("Subject", mime.QEncoding.Encode("utf-8", subject)) hdr("Date", time.Now().Format(time.RFC1123Z)) hdr("Message-ID", "<"+msgID+"@neomd>") + // Threading headers for replies + if inReplyTo != "" { + hdr("In-Reply-To", inReplyTo) + } + if references != "" { + hdr("References", references) + } hdr("MIME-Version", "1.0") hdr("Content-Type", contentType) hdr("X-Mailer", "neomd") @@ -356,6 +397,13 @@ func buildMessageWithBCC(from, to, cc, bcc, subject, plainText, htmlBody string, hdr("Subject", mime.QEncoding.Encode("utf-8", subject)) hdr("Date", time.Now().Format(time.RFC1123Z)) hdr("Message-ID", "<"+msgID+"@neomd>") + // Threading headers for replies + if inReplyTo != "" { + hdr("In-Reply-To", inReplyTo) + } + if references != "" { + hdr("References", references) + } hdr("MIME-Version", "1.0") hdr("Content-Type", "text/plain; charset=utf-8") hdr("Content-Transfer-Encoding", "quoted-printable") diff --git a/internal/ui/keys.go b/internal/ui/keys.go index 7577856..1524458 100644 --- a/internal/ui/keys.go +++ b/internal/ui/keys.go @@ -85,6 +85,7 @@ var HelpSections = []HelpSection{ {"R", "reload / refresh folder"}, {"r", "reply (from inbox or reader)"}, {"ctrl+r", "reply-all — reply to sender + all CC recipients (from inbox or reader)"}, + {"ctrl+e", "react with emoji (from inbox or reader)"}, {"f", "forward email (from reader or inbox)"}, {"T", "show full conversation thread across folders (from inbox or reader)"}, {"c", "compose new email"}, diff --git a/internal/ui/model.go b/internal/ui/model.go index ce7a0c4..fb47682 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -36,6 +36,7 @@ const ( statePresend // pre-send review: add attachments, then send or edit again stateHelp // help overlay stateWelcome // first-run welcome popup + stateReaction // emoji reaction picker ) // async message types @@ -395,6 +396,9 @@ type pendingSendData struct { replyToUID uint32 replyToFolder string replyToAccount string + // Threading headers for proper email conversation threading + inReplyTo string + references string } // undoMove records one IMAP move so it can be reversed with u. @@ -452,6 +456,11 @@ type Model struct { pendingSend *pendingSendData presendFromI int // index into presendFroms() for the From field cycle + // Reaction + reactionEmail *imap.Email // email being reacted to + reactionSelected int // selected emoji index (0-7) + pendingReaction bool // true if we need to fetch body before entering reaction mode + // Status / error status string isError bool @@ -728,7 +737,7 @@ func (m Model) fetchBodyCmd(e *imap.Email) tea.Cmd { } } -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 { +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 { h, p := splitAddr(smtpAcct.SMTP) cfg := smtp.Config{ Host: h, @@ -750,7 +759,7 @@ func (m Model) sendEmailCmd(smtpAcct config.AccountConfig, from, to, cc, bcc, su return func() tea.Msg { // Build raw MIME once — reused for both SMTP delivery and Sent copy. // BCC is intentionally excluded from headers but included in RCPT TO. - raw, err := smtp.BuildMessage(from, to, cc, subject, body, attachments, htmlSignature) + raw, err := smtp.BuildMessageWithThreading(from, to, cc, subject, body, attachments, htmlSignature, inReplyTo, references) if err != nil { return sendDoneMsg{err: fmt.Errorf("build message: %w", err)} } @@ -770,6 +779,116 @@ func (m Model) sendEmailCmd(smtpAcct config.AccountConfig, from, to, cc, bcc, su } } +func (m Model) sendReaction(emojiIndex int) (tea.Model, tea.Cmd) { + if m.reactionEmail == nil || emojiIndex < 0 || emojiIndex >= len(defaultReactions) { + return m, nil + } + + emoji := defaultReactions[emojiIndex] + e := m.reactionEmail + + // Determine recipient (Reply-To takes precedence over From) + to := e.ReplyTo + if to == "" { + to = e.From + } + + // Build subject with "Re:" prefix + subject := e.Subject + low := strings.ToLower(subject) + if !strings.HasPrefix(low, "re:") && !strings.HasPrefix(low, "aw:") && + !strings.HasPrefix(low, "sv:") && !strings.HasPrefix(low, "vs:") { + subject = "Re: " + subject + } + + // Extract sender name for footer + from := m.presendFrom() + fromName := extractName(from) + if fromName == "" { + fromName = extractEmailAddr(from) + } + + // Build reaction bodies + bodyPlain := editor.ReactionBody(emoji.emoji, fromName) + bodyHTML := editor.ReactionBodyHTML(emoji.emoji, fromName) + + // Get SMTP account + smtpAcct := m.activeAccount() + if m.presendFromI > 0 && m.presendFromI-1 < len(m.cfg.Senders) { + // Sender alias selected; find its SMTP account + alias := m.cfg.Senders[m.presendFromI-1] + for _, acc := range m.accounts { + if acc.Name == alias.Account { + smtpAcct = acc + break + } + } + } + + // Reset state before sending + m.state = m.prevState + m.loading = true + m.status = fmt.Sprintf("Sending %s...", emoji.emoji) + m.reactionEmail = nil + + return m, tea.Batch( + m.spinner.Tick, + m.sendReactionCmd(smtpAcct, from, to, subject, bodyPlain, bodyHTML, e), + ) +} + +func (m Model) sendReactionCmd(smtpAcct config.AccountConfig, from, to, subject, bodyPlain, bodyHTML string, originalEmail *imap.Email) tea.Cmd { + h, p := splitAddr(smtpAcct.SMTP) + cfg := smtp.Config{ + Host: h, + Port: p, + User: smtpAcct.User, + Password: smtpAcct.Password, + From: from, + STARTTLS: smtpAcct.STARTTLS, + TLSCertFile: smtpAcct.TLSCertFile, + TokenSource: m.tokenSourceFor(smtpAcct.Name), + } + cli := m.sentDraftsIMAPClient() + sentFolder := m.cfg.Folders.Sent + replyCli := m.imapCli() + + return func() tea.Msg { + // Build reaction message with threading headers + raw, err := smtp.BuildReactionMessage( + from, to, "", subject, + bodyPlain, bodyHTML, + originalEmail.MessageID, + originalEmail.References, + ) + if err != nil { + return sendDoneMsg{err: fmt.Errorf("build reaction: %w", err)} + } + + // Send via SMTP + toAddrs := []string{extractEmailAddr(to)} + if err := smtp.SendRaw(cfg, toAddrs, raw); err != nil { + return sendDoneMsg{err: err} + } + + // Save copy to Sent folder (non-fatal if it fails) + if saveErr := cli.SaveSent(nil, sentFolder, raw); saveErr != nil { + return sendDoneMsg{ + warning: "Sent, but failed to save to Sent folder: " + saveErr.Error(), + replyToUID: originalEmail.UID, + replyToFolder: originalEmail.Folder, + } + } + + // Mark original email as \Answered (non-fatal) + if originalEmail.UID > 0 && originalEmail.Folder != "" { + _ = replyCli.MarkAnswered(nil, originalEmail.Folder, originalEmail.UID) + } + + return sendDoneMsg{replyToUID: originalEmail.UID, replyToFolder: originalEmail.Folder} + } +} + // collectRcptTo returns deduplicated bare email addresses for SMTP RCPT TO. func collectRcptTo(to, cc, bcc string) []string { seen := make(map[string]bool) @@ -1522,6 +1641,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.pendingReplyAll = false return m.launchReplyAllCmd() } + if m.pendingReaction { + m.pendingReaction = false + return m.enterReactionMode(msg.email) + } m.openLinks = extractLinks(msg.body) _ = loadEmailIntoReader(&m.reader, msg.email, msg.body, msg.attachments, m.openLinks, m.cfg.UI.Theme, m.width) m.state = stateReading @@ -1837,6 +1960,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.pendingSend.replyToUID = m.openEmail.UID m.pendingSend.replyToFolder = m.openEmail.Folder m.pendingSend.replyToAccount = m.activeAccount().Name + // Populate threading headers for proper email conversation threading + m.pendingSend.inReplyTo = m.openEmail.MessageID + m.pendingSend.references = m.openEmail.References } m.state = statePresend m.status = "" @@ -1879,6 +2005,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Any key dismisses the welcome popup m.state = stateInbox return m, nil + case stateReaction: + return m.updateReaction(msg) } } @@ -2315,6 +2443,23 @@ func (m Model) updateInbox(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.loading = true return m, tea.Batch(m.spinner.Tick, m.fetchBodyCmd(e)) + case "ctrl+e": + e := selectedEmail(m.inbox) + if e == nil { + return m, nil + } + if idx := m.matchFromIndex(e.To, e.CC); idx >= 0 { + m.presendFromI = idx + } + // Check if we have Message-ID (needed for threading headers) + if e.MessageID == "" { + // Need to fetch body/headers first + m.pendingReaction = true + m.loading = true + return m, tea.Batch(m.spinner.Tick, m.fetchBodyCmd(e)) + } + return m.enterReactionMode(e) + case "f": e := selectedEmail(m.inbox) if e == nil { @@ -2638,6 +2783,10 @@ func (m Model) updateReader(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.openEmail != nil { return m.launchReplyAllCmd() } + case "ctrl+e": + if m.openEmail != nil { + return m.enterReactionMode(m.openEmail) + } case "f": if m.openEmail != nil { return m.launchForwardCmd() @@ -3112,7 +3261,7 @@ func (m Model) updatePresend(msg tea.KeyMsg) (tea.Model, tea.Cmd) { includeHTMLSig, cleanBody := extractHTMLSignatureMarker(ps.body) m.attachments = nil m.pendingSend = nil - 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)) + 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)) case "ctrl+f": froms := m.presendFroms() if len(froms) <= 1 { @@ -3468,6 +3617,21 @@ func (m Model) launchReplyAllCmd() (tea.Model, tea.Cmd) { return m.launchReplyWithCC("", true) } +func (m Model) enterReactionMode(e *imap.Email) (tea.Model, tea.Cmd) { + m.prevState = m.state + m.state = stateReaction + m.reactionEmail = e + m.reactionSelected = 0 + m.pendingReaction = false + + // Pre-select the correct From address (same logic as reply) + if idx := m.matchFromIndex(e.To, e.CC); idx >= 0 { + m.presendFromI = idx + } + + return m, nil +} + func (m Model) launchForwardCmd() (tea.Model, tea.Cmd) { e := m.openEmail if e == nil { @@ -3677,6 +3841,18 @@ func extractEmailAddr(s string) string { return strings.TrimSpace(s) } +// extractName extracts the name part from "Name " format. +// Returns empty string if there's no name part. +func extractName(s string) string { + if i := strings.IndexByte(s, '<'); i >= 0 { + name := strings.TrimSpace(s[:i]) + // Remove quotes if present + name = strings.Trim(name, "\"") + return name + } + return "" +} + // splitAddrs splits a comma-separated address list, skipping empty entries. func splitAddrs(s string) []string { var out []string @@ -3785,6 +3961,8 @@ func (m Model) View() string { return m.viewHelp() case stateWelcome: return m.viewWelcome() + case stateReaction: + return m.viewReaction() } return "" } @@ -3943,6 +4121,36 @@ func (m Model) viewCompose() string { return b.String() } +func (m Model) updateReaction(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc", "q": + m.state = m.prevState + m.reactionEmail = nil + return m, nil + + case "j", "down": + if m.reactionSelected < len(defaultReactions)-1 { + m.reactionSelected++ + } + + case "k", "up": + if m.reactionSelected > 0 { + m.reactionSelected-- + } + + case "1", "2", "3", "4", "5", "6", "7", "8": + idx, _ := strconv.Atoi(msg.String()) + if idx >= 1 && idx <= len(defaultReactions) { + return m.sendReaction(idx - 1) + } + + case "enter": + return m.sendReaction(m.reactionSelected) + } + + return m, nil +} + func (m Model) updateHelp(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "esc": diff --git a/internal/ui/reaction.go b/internal/ui/reaction.go new file mode 100644 index 0000000..3bec49b --- /dev/null +++ b/internal/ui/reaction.go @@ -0,0 +1,85 @@ +package ui + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// reactionEmoji represents a single emoji reaction option. +type reactionEmoji struct { + emoji string + label string +} + +// defaultReactions is the list of emoji reactions available to the user. +var defaultReactions = []reactionEmoji{ + {"👍", "Thumbs up"}, + {"❤️", "Love"}, + {"😂", "Laugh"}, + {"🎉", "Celebrate"}, + {"🙏", "Thanks"}, + {"💯", "Perfect"}, + {"👀", "Eyes"}, + {"✅", "Check"}, +} + +// viewReaction renders the emoji picker overlay. +func (m Model) viewReaction() string { + if m.reactionEmail == nil { + return "no email selected" + } + + // Header: subject and from + title := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#7E9CD8")). + Render("React to: " + truncate(m.reactionEmail.Subject, 40)) + + from := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#727169")). + Render("From: " + m.reactionEmail.From) + + // Emoji list + var items []string + for i, r := range defaultReactions { + style := lipgloss.NewStyle() + if i == m.reactionSelected { + style = style.Background(lipgloss.Color("#2D4F67")) + } + + line := fmt.Sprintf(" %d %s %s", i+1, r.emoji, r.label) + items = append(items, style.Render(line)) + } + + help := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#727169")). + Render("Press 1-8 or j/k + enter • esc cancel") + + // Combine all elements + content := lipgloss.JoinVertical( + lipgloss.Left, + title, + from, + "", + strings.Join(items, "\n"), + "", + help, + ) + + // Box around the content + box := lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#54546D")). + Padding(1, 2). + Width(50). + Render(content) + + // Center the box + return lipgloss.Place( + m.width, m.height, + lipgloss.Center, lipgloss.Center, + box, + ) +}