Skip to content

Commit 562f52d

Browse files
committed
emoji-reaction feature
1 parent 3b57e3f commit 562f52d

7 files changed

Lines changed: 367 additions & 8 deletions

File tree

docs/keybindings.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ Press `?` inside neomd to open the interactive help overlay. Start typing to fil
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) |
116+
| `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 |

internal/editor/editor.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,19 @@ func ForwardPrelude(subject, from, originalFrom, originalDate, originalTo, origi
129129
return s
130130
}
131131

132+
// ReactionBody builds the plain text body for an emoji reaction.
133+
func ReactionBody(emoji, fromName string) string {
134+
return fmt.Sprintf("%s\n\n%s reacted via neomd (https://neomd.ssp.sh)\n", emoji, fromName)
135+
}
136+
137+
// ReactionBodyHTML builds the HTML body for an emoji reaction.
138+
func ReactionBodyHTML(emoji, fromName string) string {
139+
return fmt.Sprintf(`<div style="font-size: 48px; margin: 20px 0;">%s</div>
140+
<p style="color: #666; font-size: 14px; margin-top: 40px; border-top: 1px solid #ddd; padding-top: 20px;">
141+
%s reacted via <a href="https://neomd.ssp.sh" style="color: #7E9CD8; text-decoration: none;">neomd</a>
142+
</p>`, emoji, fromName)
143+
}
144+
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 "".

internal/imap/client.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ type Email struct {
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)
52+
References string // References header (space-separated Message-IDs for threading)
5253
}
5354

5455
// Config holds connection parameters.
@@ -325,6 +326,8 @@ func (c *Client) FetchHeaders(ctx context.Context, folder string, n int) ([]Emai
325326
if len(m.Envelope.InReplyTo) > 0 {
326327
e.InReplyTo = m.Envelope.InReplyTo[0]
327328
}
329+
// Note: References header is fetched when the body is loaded (FetchBody)
330+
// because it's not available in the IMAP Envelope structure.
328331
}
329332
e.Size = uint32(m.RFC822Size)
330333
e.HasAttachment = hasAttachment(m.BodyStructure)

internal/smtp/sender.go

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,12 @@ func sendSTARTTLSWithConfig(addr, host string, tlsCfg *tls.Config, auth smtp.Aut
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.
251251
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) {
252258
htmlBody, err := render.ToHTML(markdownBody)
253259
if err != nil {
254260
return nil, fmt.Errorf("markdown to html: %w", err)
@@ -262,7 +268,16 @@ func BuildMessage(from, to, cc, subject, markdownBody string, attachments []stri
262268
htmlBody = htmlBody[:idx] + "\n" + htmlSignature + "\n" + htmlBody[idx:]
263269
}
264270
}
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)
266281
}
267282

268283
// BuildDraftMessage constructs a raw MIME draft for IMAP APPEND.
@@ -271,8 +286,27 @@ func BuildMessage(from, to, cc, subject, markdownBody string, attachments []stri
271286
// Drafts are stored as plain text only (no HTML conversion) to preserve the
272287
// original markdown formatting exactly during save/load cycles.
273288
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+
// plainBody and htmlBody are pre-formatted reaction messages (emoji + footer).
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, plainBody, htmlBody, inReplyTo, references string) ([]byte, error) {
299+
// Build References chain: append inReplyTo to existing references
300+
refChain := references
301+
if inReplyTo != "" {
302+
if refChain != "" {
303+
refChain = refChain + " " + inReplyTo
304+
} else {
305+
refChain = inReplyTo
306+
}
307+
}
308+
// No attachments for reactions
309+
return buildMessageWithBCC(from, to, cc, "", subject, plainBody, htmlBody, nil, inReplyTo, refChain)
276310
}
277311

278312
// inlineImage holds a local image path and its assigned Content-ID.
@@ -291,10 +325,10 @@ type inlineImage struct {
291325
// - images only → multipart/related > (multipart/alternative + inline images)
292326
// - images + files → multipart/mixed > (multipart/related > alt+images) + files
293327
func buildMessage(from, to, cc, subject, plainText, htmlBody string, attachments []string) ([]byte, error) {
294-
return buildMessageWithBCC(from, to, cc, "", subject, plainText, htmlBody, attachments)
328+
return buildMessageWithBCC(from, to, cc, "", subject, plainText, htmlBody, attachments, "", "")
295329
}
296330

297-
func buildMessageWithBCC(from, to, cc, bcc, subject, plainText, htmlBody string, attachments []string) ([]byte, error) {
331+
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 {
@@ -332,6 +366,13 @@ func buildMessageWithBCC(from, to, cc, bcc, subject, plainText, htmlBody string,
332366
hdr("Subject", mime.QEncoding.Encode("utf-8", subject))
333367
hdr("Date", time.Now().Format(time.RFC1123Z))
334368
hdr("Message-ID", "<"+msgID+"@neomd>")
369+
// Threading headers for replies
370+
if inReplyTo != "" {
371+
hdr("In-Reply-To", inReplyTo)
372+
}
373+
if references != "" {
374+
hdr("References", references)
375+
}
335376
hdr("MIME-Version", "1.0")
336377
hdr("Content-Type", contentType)
337378
hdr("X-Mailer", "neomd")
@@ -356,6 +397,13 @@ func buildMessageWithBCC(from, to, cc, bcc, subject, plainText, htmlBody string,
356397
hdr("Subject", mime.QEncoding.Encode("utf-8", subject))
357398
hdr("Date", time.Now().Format(time.RFC1123Z))
358399
hdr("Message-ID", "<"+msgID+"@neomd>")
400+
// Threading headers for replies
401+
if inReplyTo != "" {
402+
hdr("In-Reply-To", inReplyTo)
403+
}
404+
if references != "" {
405+
hdr("References", references)
406+
}
359407
hdr("MIME-Version", "1.0")
360408
hdr("Content-Type", "text/plain; charset=utf-8")
361409
hdr("Content-Transfer-Encoding", "quoted-printable")

internal/ui/keys.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ var HelpSections = []HelpSection{
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)"},
88+
{"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"},

0 commit comments

Comments
 (0)