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.

Merge branch 'emoji-reply' - Closes #9

Add emoji reactions feature

- Implements emoji reactions with ctrl+e keybinding for fast
acknowledgments.
- Includes proper threading headers, quoted message history, and
consistent
- markdown-to-HTML rendering for both replies and reactions.

Closes #9

sspaeti a66bbe49 3b57e3fb

+721 -38
+4 -1
.claude/settings.local.json
··· 5 5 "Bash(go test:*)", 6 6 "Bash(make build:*)", 7 7 "Bash(make test:*)", 8 - "WebFetch(domain:www.ssp.sh)" 8 + "WebFetch(domain:www.ssp.sh)", 9 + "Bash(roborev --help:*)", 10 + "WebFetch(domain:www.roborev.io)", 11 + "Bash(roborev check-agents:*)" 9 12 ] 10 13 } 11 14 }
+4 -1
CHANGELOG.md
··· 1 1 # Changelog 2 2 3 - # 2026-04-10 3 + # 2026-04-13 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 + - **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; also fixed background sync path to prevent stale cache; added regression test (internal/imap/client_test.go:410) 4 7 5 8 6 9 # 2026-04-10
+1
README.md
··· 81 81 - **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 82 82 - **Link opener** — links in emails are numbered `[1]`-`[0]` in the reader header; press `space+digit` to open in `$BROWSER` 83 83 - **CC, BCC, Reply-all** — optional Cc/Bcc fields (toggle with `ctrl+b`); `R` in the reader replies to sender + all CC recipients 84 + - **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 84 85 - **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) 85 86 - **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 86 87 - **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
+1
docs/keybindings.md
··· 113 113 | `R` | reload / refresh folder | 114 114 | `r` | reply (from inbox or reader) | 115 115 | `ctrl+r` | reply-all — reply to sender + all CC recipients (from inbox or reader) | 116 + | `ctrl+e` | react with emoji (from inbox or reader) | 116 117 | `f` | forward email (from reader or inbox) | 117 118 | `T` | show full conversation thread across folders (from inbox or reader) | 118 119 | `c` | compose new email |
+50
docs/sending.md
··· 28 28 29 29 In 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). 30 30 31 + 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. 32 + 31 33 Press `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. 34 + 35 + ## Emoji Reactions 36 + 37 + 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. 38 + 39 + **Available reactions:** 40 + - 👍 Thumbs up 41 + - ❤️ Love 42 + - 😂 Laugh 43 + - 🎉 Celebrate 44 + - 🙏 Thanks 45 + - 💯 Perfect 46 + - 👀 Eyes 47 + - ✅ Check 48 + 49 + **How it works:** 50 + 51 + 1. Press `ctrl+e` while viewing or selecting an email 52 + 2. Choose an emoji by pressing `1`-`8` (instant send) or navigate with `j`/`k` and press `enter` 53 + 3. Press `esc` to cancel 54 + 55 + The reaction is sent immediately (no editor, no pre-send review) as a properly formatted email with: 56 + 57 + **Plain text:** 58 + ``` 59 + 👍 60 + 61 + Simon Späti reacted via [neomd](https://neomd.ssp.sh) 62 + 63 + --- 64 + 65 + > **John Doe** wrote: 66 + > 67 + > original email body quoted here 68 + 69 + --- 70 + ``` 71 + 72 + **HTML:** 73 + 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. 74 + 75 + **Threading:** 76 + 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. 77 + 78 + **From address:** 79 + 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. 80 + 81 + Emoji reactions are perfect for quick acknowledgments, celebrating good news, or thanking someone without the overhead of composing a full reply. 32 82 33 83 ## Attachments 34 84
+15 -3
internal/editor/editor.go
··· 107 107 } 108 108 109 109 // ReplyPrelude builds a quote block for replies. cc and from may be empty. 110 + // buildQuotedReply builds the quoted "wrote:" section used in replies and reactions. 111 + func buildQuotedReply(originalFrom, originalBody string) string { 112 + return fmt.Sprintf("---\n\n> **%s** wrote:\n>\n%s\n\n---\n\n", 113 + originalFrom, quoteLines(originalBody)) 114 + } 115 + 110 116 func ReplyPrelude(to, cc, subject, from, originalFrom, originalBody string) string { 111 - return Prelude(to, cc, "", from, subject, "") + 112 - fmt.Sprintf("---\n\n> **%s** wrote:\n>\n%s\n\n---\n\n", 113 - originalFrom, quoteLines(originalBody)) 117 + return Prelude(to, cc, "", from, subject, "") + buildQuotedReply(originalFrom, originalBody) 114 118 } 115 119 116 120 // ForwardPrelude builds a quoted forward block. The To field is left empty for ··· 127 131 s += fmt.Sprintf("To: %s\n\n", originalTo) 128 132 s += quoteLines(originalBody) + "\n" 129 133 return s 134 + } 135 + 136 + // ReactionBody builds the markdown body for an emoji reaction. 137 + // Returns markdown that will be used for both text/plain and text/html parts (same as regular replies). 138 + // Includes the quoted original message using the same quoting logic as regular replies. 139 + func ReactionBody(emoji, fromName, originalFrom, originalBody string) string { 140 + quoted := buildQuotedReply(originalFrom, originalBody) 141 + return fmt.Sprintf("%s\n\n_%s reacted via [neomd](https://neomd.ssp.sh)_\n\n%s", emoji, fromName, quoted) 130 142 } 131 143 132 144 // ParseHeaders scans raw editor content for # [neomd: key: value] lines and
+26 -11
internal/imap/client.go
··· 49 49 HasAttachment bool // true if BODYSTRUCTURE contains an attachment part 50 50 MessageID string // Message-ID from envelope (for threading) 51 51 InReplyTo string // first In-Reply-To message ID (for threading) 52 + References string // References header (space-separated Message-IDs for threading) 52 53 } 53 54 54 55 // Config holds connection parameters. ··· 195 196 } 196 197 } 197 198 199 + // ResetMailboxSelection clears the cached selected mailbox, forcing the next 200 + // FetchHeaders or similar operation to re-SELECT the mailbox and fetch fresh state. 201 + // This is useful when refreshing to ensure new messages are visible. 202 + func (c *Client) ResetMailboxSelection() { 203 + c.mu.Lock() 204 + defer c.mu.Unlock() 205 + c.selectedMailbox = "" 206 + } 207 + 198 208 // TokenSource returns the OAuth2 token source for this client, or nil for 199 209 // password-authenticated accounts. 200 210 func (c *Client) TokenSource() func() (string, error) { return c.cfg.TokenSource } ··· 325 335 if len(m.Envelope.InReplyTo) > 0 { 326 336 e.InReplyTo = m.Envelope.InReplyTo[0] 327 337 } 338 + // Note: References header is fetched when the body is loaded (FetchBody) 339 + // because it's not available in the IMAP Envelope structure. 328 340 } 329 341 e.Size = uint32(m.RFC822Size) 330 342 e.HasAttachment = hasAttachment(m.BodyStructure) ··· 712 724 } 713 725 714 726 // FetchBody fetches the body of a single message. 715 - // Returns (markdownBody, rawHTML, webURL, attachments, error). 716 - func (c *Client) FetchBody(ctx context.Context, folder string, uid uint32) (string, string, string, []Attachment, error) { 727 + // Returns (markdownBody, rawHTML, webURL, attachments, references, error). 728 + func (c *Client) FetchBody(ctx context.Context, folder string, uid uint32) (string, string, string, []Attachment, string, error) { 717 729 if ctx == nil { 718 730 ctx = context.Background() 719 731 } 720 - var markdown, rawHTML, webURL string 732 + var markdown, rawHTML, webURL, references string 721 733 var attachments []Attachment 722 734 err := c.withConn(ctx, func(conn *imapclient.Client) error { 723 735 if err := c.selectMailbox(folder); err != nil { ··· 739 751 } 740 752 741 753 if len(msgs[0].BodySection) > 0 { 742 - markdown, rawHTML, webURL, attachments = parseBody(msgs[0].BodySection[0].Bytes) 754 + markdown, rawHTML, webURL, attachments, references = parseBody(msgs[0].BodySection[0].Bytes) 743 755 } 744 756 return nil 745 757 }) 746 - return markdown, rawHTML, webURL, attachments, err 758 + return markdown, rawHTML, webURL, attachments, references, err 747 759 } 748 760 749 761 // MoveMessage moves uid from src to dst using the IMAP MOVE command (RFC 6851). ··· 969 981 // - rawHTML: original HTML part verbatim (empty for plain-text emails) 970 982 // - webURL: "view online" URL extracted from List-Post header or plain-text 971 983 // preamble (e.g. Substack's "View this post on the web at https://…") 972 - func parseBody(raw []byte) (markdown, rawHTML, webURL string, attachments []Attachment) { 984 + func parseBody(raw []byte) (markdown, rawHTML, webURL string, attachments []Attachment, references string) { 973 985 e, err := message.Read(bytes.NewReader(raw)) 974 986 if err != nil && !message.IsUnknownCharset(err) { 975 - return string(raw), "", "", nil 987 + return string(raw), "", "", nil, "" 976 988 } 977 989 978 990 // Check if this is a neomd-authored draft. Drafts use the X-Neomd-Draft header 979 991 // to signal that the plain text body is already markdown and should not be 980 992 // normalized (which adds trailing spaces and would mutate the draft on each save/load). 981 993 isDraft := e.Header.Get("X-Neomd-Draft") == "true" 994 + 995 + // Extract References header for email threading 996 + references = e.Header.Get("References") 982 997 983 998 // List-Post header contains the canonical article URL on most newsletters: 984 999 // List-Post: <https://newsletter.example.com/p/slug> ··· 1082 1097 // text/plain part is typically a stripped dump with raw redirect URLs. 1083 1098 // Fall back to plain text for plain-text-only emails (e.g. direct replies). 1084 1099 if htmlText != "" { 1085 - return htmlToMarkdown(htmlText), htmlText, webURL, attachments 1100 + return htmlToMarkdown(htmlText), htmlText, webURL, attachments, references 1086 1101 } 1087 1102 if plainText != "" { 1088 1103 // For neomd drafts, return the raw markdown without normalization. 1089 1104 // Normalization adds trailing spaces for hard line breaks, which would 1090 1105 // mutate the draft content on each save/reopen cycle. 1091 1106 if isDraft { 1092 - return plainText, "", webURL, attachments 1107 + return plainText, "", webURL, attachments, references 1093 1108 } 1094 - return normalizePlainText(plainText), "", webURL, attachments 1109 + return normalizePlainText(plainText), "", webURL, attachments, references 1095 1110 } 1096 - return "(no body)", "", webURL, attachments 1111 + return "(no body)", "", webURL, attachments, references 1097 1112 } 1098 1113 1099 1114 // extractPlainTextWebURL looks for a "View … on the web at https://…" line
+48 -5
internal/imap/client_test.go
··· 228 228 "iVBORw0KGgo=\r\n" + 229 229 "--" + boundary + "--\r\n" 230 230 231 - _, _, _, attachments := parseBody([]byte(raw)) 231 + _, _, _, attachments, _ := parseBody([]byte(raw)) 232 232 233 233 if len(attachments) == 0 { 234 234 t.Fatal("expected at least 1 attachment, got 0") ··· 273 273 "JVBERi0=\r\n" + 274 274 "--" + boundary + "--\r\n" 275 275 276 - _, _, _, attachments := parseBody([]byte(raw)) 276 + _, _, _, attachments, _ := parseBody([]byte(raw)) 277 277 278 278 if len(attachments) == 0 { 279 279 t.Fatal("expected at least 1 attachment, got 0") ··· 334 334 originalBody 335 335 336 336 // First parse (simulating draft reopen) 337 - body1, _, _, _ := parseBody([]byte(draftMIME)) 337 + body1, _, _, _, _ := parseBody([]byte(draftMIME)) 338 338 339 339 // Verify the body matches exactly (no trailing spaces added) 340 340 if body1 != originalBody { ··· 351 351 "\r\n" + 352 352 body1 // Use the result from first parse 353 353 354 - body2, _, _, _ := parseBody([]byte(draftMIME2)) 354 + body2, _, _, _, _ := parseBody([]byte(draftMIME2)) 355 355 356 356 // Verify still matches exactly (no accumulation of trailing spaces) 357 357 if body2 != originalBody { ··· 378 378 "\r\n" + 379 379 originalBody 380 380 381 - body, _, _, _ := parseBody([]byte(regularMIME)) 381 + body, _, _, _, _ := parseBody([]byte(regularMIME)) 382 382 383 383 // Normalization should add two trailing spaces before the newline 384 384 expectedNormalized := "Line 1 \nLine 2" ··· 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 -5
internal/integration_test.go
··· 178 178 } 179 179 180 180 // Fetch body and verify content 181 - markdown, rawHTML, _, _, err := cli.FetchBody(context.Background(), "INBOX", email.UID) 181 + markdown, rawHTML, _, _, _, err := cli.FetchBody(context.Background(), "INBOX", email.UID) 182 182 if err != nil { 183 183 t.Fatalf("FetchBody: %v", err) 184 184 } ··· 214 214 defer cleanupEmail(t, cli, "INBOX", email.UID) 215 215 216 216 // Fetch raw body to check CC header 217 - markdown, _, _, _, err := cli.FetchBody(context.Background(), "INBOX", email.UID) 217 + markdown, _, _, _, _, err := cli.FetchBody(context.Background(), "INBOX", email.UID) 218 218 if err != nil { 219 219 t.Fatalf("FetchBody: %v", err) 220 220 } ··· 249 249 defer cleanupEmail(t, cli, "INBOX", email.UID) 250 250 251 251 // Fetch body — attachments should be listed 252 - _, _, _, attachments, err := cli.FetchBody(context.Background(), "INBOX", email.UID) 252 + _, _, _, attachments, _, err := cli.FetchBody(context.Background(), "INBOX", email.UID) 253 253 if err != nil { 254 254 t.Fatalf("FetchBody: %v", err) 255 255 } ··· 426 426 defer cleanupEmail(t, cli, "INBOX", email.UID) 427 427 428 428 // Fetch body — inline image should appear as attachment with image content type 429 - _, rawHTML, _, attachments, err := cli.FetchBody(context.Background(), "INBOX", email.UID) 429 + _, rawHTML, _, attachments, _, err := cli.FetchBody(context.Background(), "INBOX", email.UID) 430 430 if err != nil { 431 431 t.Fatalf("FetchBody: %v", err) 432 432 } ··· 476 476 email := waitForEmail(t, cli, "INBOX", subject, 30*time.Second) 477 477 defer cleanupEmail(t, cli, "INBOX", email.UID) 478 478 479 - markdown, rawHTML, _, _, err := cli.FetchBody(context.Background(), "INBOX", email.UID) 479 + markdown, rawHTML, _, _, _, err := cli.FetchBody(context.Background(), "INBOX", email.UID) 480 480 if err != nil { 481 481 t.Fatalf("FetchBody: %v", err) 482 482 }
+60 -5
internal/smtp/sender.go
··· 249 249 // otherwise the structure is unchanged (multipart/alternative only). 250 250 // htmlSignature, if non-empty, is injected before the closing </body> tag in the HTML part. 251 251 func BuildMessage(from, to, cc, subject, markdownBody string, attachments []string, htmlSignature string) ([]byte, error) { 252 + return BuildMessageWithThreading(from, to, cc, subject, markdownBody, attachments, htmlSignature, "", "") 253 + } 254 + 255 + // BuildMessageWithThreading builds a MIME message with optional threading headers (In-Reply-To, References). 256 + // Used for replies and forwards to maintain proper email conversation threading. 257 + func BuildMessageWithThreading(from, to, cc, subject, markdownBody string, attachments []string, htmlSignature, inReplyTo, references string) ([]byte, error) { 252 258 htmlBody, err := render.ToHTML(markdownBody) 253 259 if err != nil { 254 260 return nil, fmt.Errorf("markdown to html: %w", err) ··· 262 268 htmlBody = htmlBody[:idx] + "\n" + htmlSignature + "\n" + htmlBody[idx:] 263 269 } 264 270 } 265 - return buildMessage(from, to, cc, subject, markdownBody, htmlBody, attachments) 271 + // Build References chain: append inReplyTo to existing references 272 + refChain := references 273 + if inReplyTo != "" { 274 + if refChain != "" { 275 + refChain = refChain + " " + inReplyTo 276 + } else { 277 + refChain = inReplyTo 278 + } 279 + } 280 + return buildMessageWithBCC(from, to, cc, "", subject, markdownBody, htmlBody, attachments, inReplyTo, refChain) 266 281 } 267 282 268 283 // BuildDraftMessage constructs a raw MIME draft for IMAP APPEND. ··· 271 286 // Drafts are stored as plain text only (no HTML conversion) to preserve the 272 287 // original markdown formatting exactly during save/load cycles. 273 288 func BuildDraftMessage(from, to, cc, bcc, subject, markdownBody string, attachments []string) ([]byte, error) { 274 - // Pass empty htmlBody to store plain text only 275 - return buildMessageWithBCC(from, to, cc, bcc, subject, markdownBody, "", attachments) 289 + // Pass empty htmlBody to store plain text only; no threading headers for drafts 290 + return buildMessageWithBCC(from, to, cc, bcc, subject, markdownBody, "", attachments, "", "") 291 + } 292 + 293 + // BuildReactionMessage constructs a minimal reaction email with threading headers. 294 + // Used for emoji reactions sent as replies to emails. 295 + // markdownBody is used for both text/plain and text/html parts (same as BuildMessageWithThreading). 296 + // inReplyTo is the Message-ID of the original email. 297 + // 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 + // Convert markdown to HTML (same as regular replies) 300 + htmlBody, err := render.ToHTML(markdownBody) 301 + if err != nil { 302 + return nil, fmt.Errorf("markdown to html: %w", err) 303 + } 304 + 305 + // Build References chain: append inReplyTo to existing references 306 + refChain := references 307 + if inReplyTo != "" { 308 + if refChain != "" { 309 + refChain = refChain + " " + inReplyTo 310 + } else { 311 + refChain = inReplyTo 312 + } 313 + } 314 + // No attachments for reactions 315 + // Use markdown for text/plain part, rendered HTML for text/html part (same as regular replies) 316 + return buildMessageWithBCC(from, to, cc, "", subject, markdownBody, htmlBody, nil, inReplyTo, refChain) 276 317 } 277 318 278 319 // inlineImage holds a local image path and its assigned Content-ID. ··· 291 332 // - images only → multipart/related > (multipart/alternative + inline images) 292 333 // - images + files → multipart/mixed > (multipart/related > alt+images) + files 293 334 func buildMessage(from, to, cc, subject, plainText, htmlBody string, attachments []string) ([]byte, error) { 294 - return buildMessageWithBCC(from, to, cc, "", subject, plainText, htmlBody, attachments) 335 + return buildMessageWithBCC(from, to, cc, "", subject, plainText, htmlBody, attachments, "", "") 295 336 } 296 337 297 - func buildMessageWithBCC(from, to, cc, bcc, subject, plainText, htmlBody string, attachments []string) ([]byte, error) { 338 + func buildMessageWithBCC(from, to, cc, bcc, subject, plainText, htmlBody string, attachments []string, inReplyTo, references string) ([]byte, error) { 298 339 // Find local image paths in htmlBody (<img src="/abs/path">), assign CIDs. 299 340 var inlines []inlineImage 300 341 processedHTML := imgSrcRe.ReplaceAllStringFunc(htmlBody, func(match string) string { ··· 332 373 hdr("Subject", mime.QEncoding.Encode("utf-8", subject)) 333 374 hdr("Date", time.Now().Format(time.RFC1123Z)) 334 375 hdr("Message-ID", "<"+msgID+"@neomd>") 376 + // Threading headers for replies 377 + if inReplyTo != "" { 378 + hdr("In-Reply-To", inReplyTo) 379 + } 380 + if references != "" { 381 + hdr("References", references) 382 + } 335 383 hdr("MIME-Version", "1.0") 336 384 hdr("Content-Type", contentType) 337 385 hdr("X-Mailer", "neomd") ··· 356 404 hdr("Subject", mime.QEncoding.Encode("utf-8", subject)) 357 405 hdr("Date", time.Now().Format(time.RFC1123Z)) 358 406 hdr("Message-ID", "<"+msgID+"@neomd>") 407 + // Threading headers for replies 408 + if inReplyTo != "" { 409 + hdr("In-Reply-To", inReplyTo) 410 + } 411 + if references != "" { 412 + hdr("References", references) 413 + } 359 414 hdr("MIME-Version", "1.0") 360 415 hdr("Content-Type", "text/plain; charset=utf-8") 361 416 hdr("Content-Transfer-Encoding", "quoted-printable")
+190
internal/smtp/sender_test.go
··· 686 686 }) 687 687 } 688 688 } 689 + func TestBuildReactionMessage_ThreadingHeaders(t *testing.T) { 690 + markdown := "👍\n\n_Simon reacted via [neomd](https://neomd.ssp.sh)_\n\n---\n\n> **John** wrote:\n>\n> Hello" 691 + inReplyTo := "<original@example.com>" 692 + references := "<first@example.com> <second@example.com>" 693 + 694 + raw, err := BuildReactionMessage( 695 + "simon@example.com", 696 + "john@example.com", 697 + "", 698 + "Re: Test", 699 + markdown, 700 + inReplyTo, 701 + references, 702 + ) 703 + if err != nil { 704 + t.Fatalf("BuildReactionMessage: %v", err) 705 + } 706 + 707 + msg, err := mail.ReadMessage(bytes.NewReader(raw)) 708 + if err != nil { 709 + t.Fatalf("ReadMessage: %v", err) 710 + } 711 + 712 + // Verify In-Reply-To header 713 + gotInReplyTo := msg.Header.Get("In-Reply-To") 714 + if gotInReplyTo != inReplyTo { 715 + t.Errorf("In-Reply-To = %q, want %q", gotInReplyTo, inReplyTo) 716 + } 717 + 718 + // Verify References header includes original references + inReplyTo 719 + gotReferences := msg.Header.Get("References") 720 + wantReferences := references + " " + inReplyTo 721 + if gotReferences != wantReferences { 722 + t.Errorf("References = %q, want %q", gotReferences, wantReferences) 723 + } 724 + 725 + // Verify multipart/alternative structure 726 + mediaType, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type")) 727 + if err != nil { 728 + t.Fatalf("ParseMediaType: %v", err) 729 + } 730 + if mediaType != "multipart/alternative" { 731 + t.Errorf("Content-Type = %q, want multipart/alternative", mediaType) 732 + } 733 + 734 + // Verify plain text part has no markdown syntax 735 + mr := multipart.NewReader(msg.Body, params["boundary"]) 736 + var foundPlainText, foundHTML bool 737 + for { 738 + part, err := mr.NextPart() 739 + if err == io.EOF { 740 + break 741 + } 742 + if err != nil { 743 + t.Fatalf("NextPart: %v", err) 744 + } 745 + ct := part.Header.Get("Content-Type") 746 + body, _ := io.ReadAll(part) 747 + 748 + if strings.Contains(ct, "text/plain") { 749 + foundPlainText = true 750 + bodyStr := string(body) 751 + // Plain text contains markdown syntax (same as regular replies) 752 + if !strings.Contains(bodyStr, "_Simon reacted via [neomd]") { 753 + t.Errorf("text/plain missing markdown footer, got: %s", bodyStr) 754 + } 755 + // Should contain quoted reply 756 + if !strings.Contains(bodyStr, "> **John** wrote:") { 757 + t.Errorf("text/plain missing quoted reply, got: %s", bodyStr) 758 + } 759 + } 760 + if strings.Contains(ct, "text/html") { 761 + foundHTML = true 762 + // HTML should be rendered (not raw markdown) 763 + bodyStr := string(body) 764 + if !strings.Contains(bodyStr, "<") || !strings.Contains(bodyStr, ">") { 765 + t.Errorf("text/html part is not HTML: %s", bodyStr) 766 + } 767 + } 768 + } 769 + 770 + if !foundPlainText { 771 + t.Error("Missing text/plain part") 772 + } 773 + if !foundHTML { 774 + t.Error("Missing text/html part") 775 + } 776 + } 777 + 778 + func TestPlainTextFormatting_ReplyVsReaction(t *testing.T) { 779 + // Test what plain text email clients actually see for both replies and reactions 780 + 781 + // 1. Build a regular reply 782 + replyMarkdown := "Thanks for your email!\n\n---\n\n> **John** wrote:\n>\n> Can you help with [this issue](https://example.com/issue)?" 783 + replyRaw, err := BuildMessageWithThreading( 784 + "simon@example.com", 785 + "john@example.com", 786 + "", 787 + "Re: Help needed", 788 + replyMarkdown, 789 + nil, // no attachments 790 + "", // no HTML signature 791 + "<original@example.com>", 792 + "<first@example.com>", 793 + ) 794 + if err != nil { 795 + t.Fatalf("BuildMessageWithThreading: %v", err) 796 + } 797 + 798 + // 2. Build an emoji reaction 799 + reactionMarkdown := "👍\n\n_Simon reacted via [neomd](https://neomd.ssp.sh)_\n\n---\n\n> **John** wrote:\n>\n> Can you help with [this issue](https://example.com/issue)?" 800 + reactionRaw, err := BuildReactionMessage( 801 + "simon@example.com", 802 + "john@example.com", 803 + "", 804 + "Re: Help needed", 805 + reactionMarkdown, 806 + "<original@example.com>", 807 + "<first@example.com>", 808 + ) 809 + if err != nil { 810 + t.Fatalf("BuildReactionMessage: %v", err) 811 + } 812 + 813 + // Extract text/plain parts from both 814 + replyPlainText := extractPlainTextPart(t, replyRaw) 815 + reactionPlainText := extractPlainTextPart(t, reactionRaw) 816 + 817 + t.Logf("=== REGULAR REPLY (text/plain part) ===\n%s\n", replyPlainText) 818 + t.Logf("=== EMOJI REACTION (text/plain part) ===\n%s\n", reactionPlainText) 819 + 820 + // Verify both contain markdown syntax (current behavior) 821 + if !strings.Contains(replyPlainText, "[this issue]") { 822 + t.Error("Regular reply text/plain part does not contain markdown link syntax") 823 + } 824 + if !strings.Contains(replyPlainText, "> **John** wrote:") { 825 + t.Error("Regular reply text/plain part does not contain markdown bold syntax") 826 + } 827 + 828 + if !strings.Contains(reactionPlainText, "[neomd]") { 829 + t.Error("Reaction text/plain part does not contain markdown link syntax") 830 + } 831 + if !strings.Contains(reactionPlainText, "_Simon reacted") { 832 + t.Error("Reaction text/plain part does not contain markdown italic syntax") 833 + } 834 + if !strings.Contains(reactionPlainText, "> **John** wrote:") { 835 + t.Error("Reaction text/plain part does not contain markdown bold syntax") 836 + } 837 + 838 + // Log findings 839 + t.Log("\n=== FINDINGS ===") 840 + t.Log("Both regular replies and emoji reactions send markdown in text/plain part.") 841 + t.Log("This is consistent behavior - if it's a problem for reactions, it's also a problem for replies.") 842 + } 843 + 844 + func extractPlainTextPart(t *testing.T, raw []byte) string { 845 + t.Helper() 846 + msg, err := mail.ReadMessage(bytes.NewReader(raw)) 847 + if err != nil { 848 + t.Fatalf("ReadMessage: %v", err) 849 + } 850 + 851 + mediaType, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type")) 852 + if err != nil { 853 + t.Fatalf("ParseMediaType: %v", err) 854 + } 855 + 856 + if !strings.HasPrefix(mediaType, "multipart/") { 857 + // Not multipart, read body directly 858 + body, _ := io.ReadAll(msg.Body) 859 + return string(body) 860 + } 861 + 862 + mr := multipart.NewReader(msg.Body, params["boundary"]) 863 + for { 864 + part, err := mr.NextPart() 865 + if err == io.EOF { 866 + break 867 + } 868 + if err != nil { 869 + t.Fatalf("NextPart: %v", err) 870 + } 871 + ct := part.Header.Get("Content-Type") 872 + if strings.Contains(ct, "text/plain") { 873 + body, _ := io.ReadAll(part) 874 + return string(body) 875 + } 876 + } 877 + return "" 878 + }
+1
internal/ui/keys.go
··· 85 85 {"R", "reload / refresh folder"}, 86 86 {"r", "reply (from inbox or reader)"}, 87 87 {"ctrl+r", "reply-all — reply to sender + all CC recipients (from inbox or reader)"}, 88 + {"ctrl+e", "react with emoji (from inbox or reader)"}, 88 89 {"f", "forward email (from reader or inbox)"}, 89 90 {"T", "show full conversation thread across folders (from inbox or reader)"}, 90 91 {"c", "compose new email"},
+229 -5
internal/ui/model.go
··· 36 36 statePresend // pre-send review: add attachments, then send or edit again 37 37 stateHelp // help overlay 38 38 stateWelcome // first-run welcome popup 39 + stateReaction // emoji reaction picker 39 40 ) 40 41 41 42 // async message types ··· 50 51 rawHTML string // original HTML part, empty for plain-text emails 51 52 webURL string // canonical "view online" URL (List-Post header or plain-text preamble) 52 53 attachments []imap.Attachment 54 + references string // References header for email threading 53 55 } 54 56 sendDoneMsg struct { 55 57 err error ··· 395 397 replyToUID uint32 396 398 replyToFolder string 397 399 replyToAccount string 400 + // Threading headers for proper email conversation threading 401 + inReplyTo string 402 + references string 398 403 } 399 404 400 405 // undoMove records one IMAP move so it can be reversed with u. ··· 452 457 pendingSend *pendingSendData 453 458 presendFromI int // index into presendFroms() for the From field cycle 454 459 460 + // Reaction 461 + reactionEmail *imap.Email // email being reacted to 462 + reactionSelected int // selected emoji index (0-7) 463 + pendingReaction bool // true if we need to fetch body before entering reaction mode 464 + 455 465 // Status / error 456 466 status string 457 467 isError bool ··· 720 730 721 731 func (m Model) fetchBodyCmd(e *imap.Email) tea.Cmd { 722 732 return func() tea.Msg { 723 - body, rawHTML, webURL, attachments, err := m.imapCli().FetchBody(nil, e.Folder, e.UID) 733 + body, rawHTML, webURL, attachments, references, err := m.imapCli().FetchBody(nil, e.Folder, e.UID) 724 734 if err != nil { 725 735 return errMsg{err} 726 736 } 727 - return bodyLoadedMsg{email: e, body: body, rawHTML: rawHTML, webURL: webURL, attachments: attachments} 737 + return bodyLoadedMsg{email: e, body: body, rawHTML: rawHTML, webURL: webURL, attachments: attachments, references: references} 728 738 } 729 739 } 730 740 731 - 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 { 741 + 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 { 732 742 h, p := splitAddr(smtpAcct.SMTP) 733 743 cfg := smtp.Config{ 734 744 Host: h, ··· 750 760 return func() tea.Msg { 751 761 // Build raw MIME once — reused for both SMTP delivery and Sent copy. 752 762 // BCC is intentionally excluded from headers but included in RCPT TO. 753 - raw, err := smtp.BuildMessage(from, to, cc, subject, body, attachments, htmlSignature) 763 + raw, err := smtp.BuildMessageWithThreading(from, to, cc, subject, body, attachments, htmlSignature, inReplyTo, references) 754 764 if err != nil { 755 765 return sendDoneMsg{err: fmt.Errorf("build message: %w", err)} 756 766 } ··· 770 780 } 771 781 } 772 782 783 + func (m Model) sendReaction(emojiIndex int) (tea.Model, tea.Cmd) { 784 + if m.reactionEmail == nil || emojiIndex < 0 || emojiIndex >= len(defaultReactions) { 785 + return m, nil 786 + } 787 + 788 + emoji := defaultReactions[emojiIndex] 789 + e := m.reactionEmail 790 + 791 + // Determine recipient (Reply-To takes precedence over From) 792 + to := e.ReplyTo 793 + if to == "" { 794 + to = e.From 795 + } 796 + 797 + // Build subject with "Re:" prefix 798 + subject := e.Subject 799 + low := strings.ToLower(subject) 800 + if !strings.HasPrefix(low, "re:") && !strings.HasPrefix(low, "aw:") && 801 + !strings.HasPrefix(low, "sv:") && !strings.HasPrefix(low, "vs:") { 802 + subject = "Re: " + subject 803 + } 804 + 805 + // Extract sender name for footer 806 + from := m.presendFrom() 807 + fromName := extractName(from) 808 + if fromName == "" { 809 + fromName = extractEmailAddr(from) 810 + } 811 + 812 + // Build reaction body in markdown (used for both text/plain and text/html parts, same as regular replies) 813 + bodyMarkdown := editor.ReactionBody(emoji.emoji, fromName, e.From, m.openBody) 814 + 815 + // Get SMTP account 816 + smtpAcct := m.activeAccount() 817 + if m.presendFromI > 0 && m.presendFromI-1 < len(m.cfg.Senders) { 818 + // Sender alias selected; find its SMTP account 819 + alias := m.cfg.Senders[m.presendFromI-1] 820 + for _, acc := range m.accounts { 821 + if acc.Name == alias.Account { 822 + smtpAcct = acc 823 + break 824 + } 825 + } 826 + } 827 + 828 + // Reset state before sending 829 + m.state = m.prevState 830 + m.loading = true 831 + m.status = fmt.Sprintf("Sending %s...", emoji.emoji) 832 + m.reactionEmail = nil 833 + 834 + return m, tea.Batch( 835 + m.spinner.Tick, 836 + m.sendReactionCmd(smtpAcct, from, to, subject, bodyMarkdown, e), 837 + ) 838 + } 839 + 840 + func (m Model) sendReactionCmd(smtpAcct config.AccountConfig, from, to, subject, bodyMarkdown string, originalEmail *imap.Email) tea.Cmd { 841 + h, p := splitAddr(smtpAcct.SMTP) 842 + cfg := smtp.Config{ 843 + Host: h, 844 + Port: p, 845 + User: smtpAcct.User, 846 + Password: smtpAcct.Password, 847 + From: from, 848 + STARTTLS: smtpAcct.STARTTLS, 849 + TLSCertFile: smtpAcct.TLSCertFile, 850 + TokenSource: m.tokenSourceFor(smtpAcct.Name), 851 + } 852 + cli := m.sentDraftsIMAPClient() 853 + sentFolder := m.cfg.Folders.Sent 854 + replyCli := m.imapCli() 855 + 856 + return func() tea.Msg { 857 + // Build References chain: use existing References or fall back to InReplyTo 858 + references := originalEmail.References 859 + if references == "" && originalEmail.InReplyTo != "" { 860 + references = originalEmail.InReplyTo 861 + } 862 + 863 + // Build reaction message with threading headers 864 + // markdown used for both text/plain and text/html parts (same as regular replies) 865 + raw, err := smtp.BuildReactionMessage( 866 + from, to, "", subject, 867 + bodyMarkdown, 868 + originalEmail.MessageID, 869 + references, 870 + ) 871 + if err != nil { 872 + return sendDoneMsg{err: fmt.Errorf("build reaction: %w", err)} 873 + } 874 + 875 + // Send via SMTP 876 + toAddrs := []string{extractEmailAddr(to)} 877 + if err := smtp.SendRaw(cfg, toAddrs, raw); err != nil { 878 + return sendDoneMsg{err: err} 879 + } 880 + 881 + // Save copy to Sent folder (non-fatal if it fails) 882 + if saveErr := cli.SaveSent(nil, sentFolder, raw); saveErr != nil { 883 + return sendDoneMsg{ 884 + warning: "Sent, but failed to save to Sent folder: " + saveErr.Error(), 885 + replyToUID: originalEmail.UID, 886 + replyToFolder: originalEmail.Folder, 887 + } 888 + } 889 + 890 + // Mark original email as \Answered (non-fatal) 891 + if originalEmail.UID > 0 && originalEmail.Folder != "" { 892 + _ = replyCli.MarkAnswered(nil, originalEmail.Folder, originalEmail.UID) 893 + } 894 + 895 + return sendDoneMsg{replyToUID: originalEmail.UID, replyToFolder: originalEmail.Folder} 896 + } 897 + } 898 + 773 899 // collectRcptTo returns deduplicated bare email addresses for SMTP RCPT TO. 774 900 func collectRcptTo(to, cc, bcc string) []string { 775 901 seen := make(map[string]bool) ··· 1308 1434 // Errors are swallowed — a transient network hiccup shouldn't disrupt the UI. 1309 1435 func (m Model) bgFetchInboxCmd() tea.Cmd { 1310 1436 return func() tea.Msg { 1437 + m.imapCli().ResetMailboxSelection() // force fresh SELECT to see new messages 1311 1438 emails, err := m.imapCli().FetchHeaders(nil, m.cfg.Folders.Inbox, m.cfg.UI.InboxCount) 1312 1439 if err != nil { 1313 1440 return bgSyncTickMsg{} // reschedule retry on next tick instead of errMsg ··· 1506 1633 m.openHTMLBody = msg.rawHTML 1507 1634 m.openWebURL = msg.webURL 1508 1635 m.openAttachments = msg.attachments 1636 + // Store References header in the email struct for threading 1637 + if msg.email != nil { 1638 + msg.email.References = msg.references 1639 + } 1509 1640 // Mark as seen in background (best-effort) 1510 1641 uid := msg.email.UID 1511 1642 folder := msg.email.Folder ··· 1522 1653 m.pendingReplyAll = false 1523 1654 return m.launchReplyAllCmd() 1524 1655 } 1656 + if m.pendingReaction { 1657 + m.pendingReaction = false 1658 + return m.enterReactionMode(msg.email) 1659 + } 1525 1660 m.openLinks = extractLinks(msg.body) 1526 1661 _ = loadEmailIntoReader(&m.reader, msg.email, msg.body, msg.attachments, m.openLinks, m.cfg.UI.Theme, m.width) 1527 1662 m.state = stateReading ··· 1837 1972 m.pendingSend.replyToUID = m.openEmail.UID 1838 1973 m.pendingSend.replyToFolder = m.openEmail.Folder 1839 1974 m.pendingSend.replyToAccount = m.activeAccount().Name 1975 + // Populate threading headers for proper email conversation threading 1976 + m.pendingSend.inReplyTo = m.openEmail.MessageID 1977 + // Build References chain: use existing References or fall back to InReplyTo 1978 + if m.openEmail.References != "" { 1979 + m.pendingSend.references = m.openEmail.References 1980 + } else if m.openEmail.InReplyTo != "" { 1981 + // Fall back to InReplyTo if References not available 1982 + m.pendingSend.references = m.openEmail.InReplyTo 1983 + } 1840 1984 } 1841 1985 m.state = statePresend 1842 1986 m.status = "" ··· 1879 2023 // Any key dismisses the welcome popup 1880 2024 m.state = stateInbox 1881 2025 return m, nil 2026 + case stateReaction: 2027 + return m.updateReaction(msg) 1882 2028 } 1883 2029 } 1884 2030 ··· 2279 2425 return m, nil 2280 2426 2281 2427 case "R": 2428 + m.imapCli().ResetMailboxSelection() // force fresh SELECT to see new messages 2282 2429 m.loading = true 2283 2430 return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.activeFolder())) 2284 2431 ··· 2315 2462 m.loading = true 2316 2463 return m, tea.Batch(m.spinner.Tick, m.fetchBodyCmd(e)) 2317 2464 2465 + case "ctrl+e": 2466 + e := selectedEmail(m.inbox) 2467 + if e == nil { 2468 + return m, nil 2469 + } 2470 + if idx := m.matchFromIndex(e.To, e.CC); idx >= 0 { 2471 + m.presendFromI = idx 2472 + } 2473 + // Always fetch body first (needed for quoted message in reaction) 2474 + m.pendingReaction = true 2475 + m.loading = true 2476 + return m, tea.Batch(m.spinner.Tick, m.fetchBodyCmd(e)) 2477 + 2318 2478 case "f": 2319 2479 e := selectedEmail(m.inbox) 2320 2480 if e == nil { ··· 2632 2792 return m.launchReplyCmd() 2633 2793 } 2634 2794 case "R": 2795 + m.imapCli().ResetMailboxSelection() // force fresh SELECT to see new messages 2635 2796 m.loading = true 2636 2797 return m, tea.Batch(m.spinner.Tick, m.fetchFolderCmd(m.activeFolder())) 2637 2798 case "ctrl+r": 2638 2799 if m.openEmail != nil { 2639 2800 return m.launchReplyAllCmd() 2640 2801 } 2802 + case "ctrl+e": 2803 + if m.openEmail != nil { 2804 + return m.enterReactionMode(m.openEmail) 2805 + } 2641 2806 case "f": 2642 2807 if m.openEmail != nil { 2643 2808 return m.launchForwardCmd() ··· 3112 3277 includeHTMLSig, cleanBody := extractHTMLSignatureMarker(ps.body) 3113 3278 m.attachments = nil 3114 3279 m.pendingSend = nil 3115 - 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)) 3280 + 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)) 3116 3281 case "ctrl+f": 3117 3282 froms := m.presendFroms() 3118 3283 if len(froms) <= 1 { ··· 3468 3633 return m.launchReplyWithCC("", true) 3469 3634 } 3470 3635 3636 + func (m Model) enterReactionMode(e *imap.Email) (tea.Model, tea.Cmd) { 3637 + m.prevState = m.state 3638 + m.state = stateReaction 3639 + m.reactionEmail = e 3640 + m.reactionSelected = 0 3641 + m.pendingReaction = false 3642 + 3643 + // Pre-select the correct From address (same logic as reply) 3644 + if idx := m.matchFromIndex(e.To, e.CC); idx >= 0 { 3645 + m.presendFromI = idx 3646 + } 3647 + 3648 + return m, nil 3649 + } 3650 + 3471 3651 func (m Model) launchForwardCmd() (tea.Model, tea.Cmd) { 3472 3652 e := m.openEmail 3473 3653 if e == nil { ··· 3677 3857 return strings.TrimSpace(s) 3678 3858 } 3679 3859 3860 + // extractName extracts the name part from "Name <email@example.com>" format. 3861 + // Returns empty string if there's no name part. 3862 + func extractName(s string) string { 3863 + if i := strings.IndexByte(s, '<'); i >= 0 { 3864 + name := strings.TrimSpace(s[:i]) 3865 + // Remove quotes if present 3866 + name = strings.Trim(name, "\"") 3867 + return name 3868 + } 3869 + return "" 3870 + } 3871 + 3680 3872 // splitAddrs splits a comma-separated address list, skipping empty entries. 3681 3873 func splitAddrs(s string) []string { 3682 3874 var out []string ··· 3785 3977 return m.viewHelp() 3786 3978 case stateWelcome: 3787 3979 return m.viewWelcome() 3980 + case stateReaction: 3981 + return m.viewReaction() 3788 3982 } 3789 3983 return "" 3790 3984 } ··· 3941 4135 b.WriteString(composeHelp(int(m.compose.step), len(m.presendFroms()) > 1)) 3942 4136 } 3943 4137 return b.String() 4138 + } 4139 + 4140 + func (m Model) updateReaction(msg tea.KeyMsg) (tea.Model, tea.Cmd) { 4141 + switch msg.String() { 4142 + case "esc", "q": 4143 + m.state = m.prevState 4144 + m.reactionEmail = nil 4145 + return m, nil 4146 + 4147 + case "j", "down": 4148 + if m.reactionSelected < len(defaultReactions)-1 { 4149 + m.reactionSelected++ 4150 + } 4151 + 4152 + case "k", "up": 4153 + if m.reactionSelected > 0 { 4154 + m.reactionSelected-- 4155 + } 4156 + 4157 + case "1", "2", "3", "4", "5", "6", "7", "8": 4158 + idx, _ := strconv.Atoi(msg.String()) 4159 + if idx >= 1 && idx <= len(defaultReactions) { 4160 + return m.sendReaction(idx - 1) 4161 + } 4162 + 4163 + case "enter": 4164 + return m.sendReaction(m.reactionSelected) 4165 + } 4166 + 4167 + return m, nil 3944 4168 } 3945 4169 3946 4170 func (m Model) updateHelp(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+85
internal/ui/reaction.go
··· 1 + package ui 2 + 3 + import ( 4 + "fmt" 5 + "strings" 6 + 7 + "github.com/charmbracelet/lipgloss" 8 + ) 9 + 10 + // reactionEmoji represents a single emoji reaction option. 11 + type reactionEmoji struct { 12 + emoji string 13 + label string 14 + } 15 + 16 + // defaultReactions is the list of emoji reactions available to the user. 17 + var defaultReactions = []reactionEmoji{ 18 + {"👍", "Thumbs up"}, 19 + {"❤️", "Love"}, 20 + {"😂", "Laugh"}, 21 + {"🎉", "Celebrate"}, 22 + {"🙏", "Thanks"}, 23 + {"💯", "Perfect"}, 24 + {"👀", "Eyes"}, 25 + {"✅", "Check"}, 26 + } 27 + 28 + // viewReaction renders the emoji picker overlay. 29 + func (m Model) viewReaction() string { 30 + if m.reactionEmail == nil { 31 + return "no email selected" 32 + } 33 + 34 + // Header: subject and from 35 + title := lipgloss.NewStyle(). 36 + Bold(true). 37 + Foreground(lipgloss.Color("#7E9CD8")). 38 + Render("React to: " + truncate(m.reactionEmail.Subject, 40)) 39 + 40 + from := lipgloss.NewStyle(). 41 + Foreground(lipgloss.Color("#727169")). 42 + Render("From: " + m.reactionEmail.From) 43 + 44 + // Emoji list 45 + var items []string 46 + for i, r := range defaultReactions { 47 + style := lipgloss.NewStyle() 48 + if i == m.reactionSelected { 49 + style = style.Background(lipgloss.Color("#2D4F67")) 50 + } 51 + 52 + line := fmt.Sprintf(" %d %s %s", i+1, r.emoji, r.label) 53 + items = append(items, style.Render(line)) 54 + } 55 + 56 + help := lipgloss.NewStyle(). 57 + Foreground(lipgloss.Color("#727169")). 58 + Render("Press 1-8 or j/k + enter • esc cancel") 59 + 60 + // Combine all elements 61 + content := lipgloss.JoinVertical( 62 + lipgloss.Left, 63 + title, 64 + from, 65 + "", 66 + strings.Join(items, "\n"), 67 + "", 68 + help, 69 + ) 70 + 71 + // Box around the content 72 + box := lipgloss.NewStyle(). 73 + Border(lipgloss.RoundedBorder()). 74 + BorderForeground(lipgloss.Color("#54546D")). 75 + Padding(1, 2). 76 + Width(50). 77 + Render(content) 78 + 79 + // Center the box 80 + return lipgloss.Place( 81 + m.width, m.height, 82 + lipgloss.Center, lipgloss.Center, 83 + box, 84 + ) 85 + }
+2 -2
internal/ui/reader.go
··· 126 126 // readerHelp returns the one-line help string for the reader view. 127 127 // When isDraft is true, "E draft" is shown so the user knows they can re-open in compose. 128 128 func readerHelp(isDraft bool, hasLinks bool) string { 129 - keys := []string{"j/k scroll", "h/q back", "r reply", "ctrl+r reply-all", "f fwd", "e nvim"} 129 + keys := []string{"j/k scroll", "h/q back", "r reply", "ctrl+r reply-all", "ctrl+e react", "f fwd", "e nvim"} 130 130 if isDraft { 131 131 keys = append(keys, "E draft") 132 132 } ··· 140 140 141 141 // inboxHelp returns the one-line help string for the inbox view. 142 142 func inboxHelp(folder string) string { 143 - 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"} 143 + 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"} 144 144 _ = folder 145 145 if folder == "ToScreen" { 146 146 base = []string{"I approve", "O block", "F feed", "P papertrail", "q back"}