Skip to content

Commit 23af78d

Browse files
Add quoted original message to reply drafts
- Include both plain text (with > prefix) and HTML versions - HTML uses Fastmail-style structure with <div> tags and <blockquote> - Strip DOCTYPE/html/head/body from quoted HTML to avoid Fastmail's defanging applying 0 padding styles to the whole editor - Add attribution line with sender and date
1 parent 4c74276 commit 23af78d

File tree

2 files changed

+328
-2
lines changed

2 files changed

+328
-2
lines changed

internal/jmap/draft.go

Lines changed: 140 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package jmap
33
import (
44
"encoding/json"
55
"fmt"
6+
"html"
7+
"strings"
68
)
79

810
// DraftEmail contains data for creating a draft.
@@ -71,7 +73,16 @@ func (c *Client) SaveDraft(draft DraftEmail) (string, error) {
7173
emailObject["references"] = draft.References
7274
}
7375

74-
if draft.HTMLBody != "" {
76+
// Set up body - prefer both HTML and text if available
77+
if draft.HTMLBody != "" && draft.TextBody != "" {
78+
// Both HTML and plain text (best compatibility)
79+
emailObject["htmlBody"] = []map[string]string{{"partId": "html", "type": "text/html"}}
80+
emailObject["textBody"] = []map[string]string{{"partId": "text", "type": "text/plain"}}
81+
emailObject["bodyValues"] = map[string]interface{}{
82+
"html": map[string]string{"value": draft.HTMLBody},
83+
"text": map[string]string{"value": draft.TextBody},
84+
}
85+
} else if draft.HTMLBody != "" {
7586
emailObject["htmlBody"] = []map[string]string{{"partId": "html", "type": "text/html"}}
7687
emailObject["bodyValues"] = map[string]interface{}{"html": map[string]string{"value": draft.HTMLBody}}
7788
} else {
@@ -185,16 +196,143 @@ func (c *Client) CreateReplyDraft(emailID, body string, replyAll bool) (string,
185196
inReplyTo = original.MessageID[0]
186197
}
187198

199+
// Get original body content
200+
var originalTextBody, originalHTMLBody string
201+
if original.BodyValues != nil {
202+
for _, part := range original.TextBody {
203+
if bv, ok := original.BodyValues[part.PartID]; ok {
204+
originalTextBody = bv.Value
205+
break
206+
}
207+
}
208+
for _, part := range original.HTMLBody {
209+
if bv, ok := original.BodyValues[part.PartID]; ok {
210+
originalHTMLBody = bv.Value
211+
break
212+
}
213+
}
214+
}
215+
216+
// Build attribution line
217+
fromStr := FormatAddresses(original.From)
218+
dateStr := original.ReceivedAt.Format("Mon, Jan 2, 2006 at 3:04 PM")
219+
attribution := fmt.Sprintf("On %s, %s wrote:", dateStr, fromStr)
220+
221+
// Build plain text reply with quoted original
222+
textBody := body + "\n\n" + attribution + "\n" + quoteText(originalTextBody)
223+
224+
// Build HTML reply with quoted original
225+
htmlBody := formatReplyHTML(body, attribution, originalHTMLBody, originalTextBody)
226+
188227
return c.SaveDraft(DraftEmail{
189228
To: to,
190229
CC: cc,
191230
Subject: subject,
192-
TextBody: body,
231+
TextBody: textBody,
232+
HTMLBody: htmlBody,
193233
InReplyTo: inReplyTo,
194234
References: references,
195235
})
196236
}
197237

238+
// quoteText prefixes each line with "> " for plain text quoting.
239+
func quoteText(text string) string {
240+
if text == "" {
241+
return ""
242+
}
243+
lines := strings.Split(text, "\n")
244+
for i, line := range lines {
245+
lines[i] = "> " + line
246+
}
247+
return strings.Join(lines, "\n")
248+
}
249+
250+
// formatReplyHTML creates an HTML reply body with blockquoted original.
251+
func formatReplyHTML(replyText, attribution, originalHTML, originalText string) string {
252+
// Convert reply text to HTML divs (Fastmail style)
253+
replyHTML := textToHTMLDivs(replyText)
254+
255+
// Use original HTML if available, otherwise convert text to HTML divs
256+
// Strip outer document tags from HTML to avoid style conflicts
257+
var quotedContent string
258+
if originalHTML != "" {
259+
quotedContent = extractHTMLBody(originalHTML)
260+
} else if originalText != "" {
261+
quotedContent = textToHTMLDivs(originalText)
262+
}
263+
264+
// Match Fastmail's exact format with #qt styles
265+
return fmt.Sprintf(`<!DOCTYPE html><html><head><title></title></head><body>%s<div><br></div><div>%s</div><blockquote type="cite" id="qt">%s</blockquote><div><br></div></body></html>`,
266+
replyHTML, html.EscapeString(attribution), quotedContent)
267+
}
268+
269+
// extractHTMLBody extracts just the body content from an HTML document,
270+
// stripping DOCTYPE, html, head, and body tags to avoid style conflicts.
271+
func extractHTMLBody(htmlContent string) string {
272+
content := htmlContent
273+
274+
// Remove DOCTYPE
275+
if idx := strings.Index(strings.ToLower(content), "<!doctype"); idx != -1 {
276+
if end := strings.Index(content[idx:], ">"); end != -1 {
277+
content = content[:idx] + content[idx+end+1:]
278+
}
279+
}
280+
281+
// Remove <html> and </html>
282+
content = removeTag(content, "html")
283+
284+
// Remove <head>...</head> entirely
285+
if start := strings.Index(strings.ToLower(content), "<head"); start != -1 {
286+
if end := strings.Index(strings.ToLower(content[start:]), "</head>"); end != -1 {
287+
content = content[:start] + content[start+end+7:]
288+
}
289+
}
290+
291+
// Remove <body> and </body> but keep the content
292+
content = removeTag(content, "body")
293+
294+
return strings.TrimSpace(content)
295+
}
296+
297+
// removeTag removes opening and closing tags but keeps inner content.
298+
func removeTag(content, tagName string) string {
299+
lower := strings.ToLower(content)
300+
301+
// Remove opening tag (may have attributes)
302+
if start := strings.Index(lower, "<"+tagName); start != -1 {
303+
if end := strings.Index(content[start:], ">"); end != -1 {
304+
content = content[:start] + content[start+end+1:]
305+
lower = strings.ToLower(content)
306+
}
307+
}
308+
309+
// Remove closing tag
310+
closeTag := "</" + tagName + ">"
311+
if idx := strings.Index(lower, closeTag); idx != -1 {
312+
content = content[:idx] + content[idx+len(closeTag):]
313+
}
314+
315+
return content
316+
}
317+
318+
// textToHTMLDivs converts plain text to HTML with each line in a <div>.
319+
// Empty lines become <div><br></div> (Fastmail style).
320+
func textToHTMLDivs(text string) string {
321+
if text == "" {
322+
return ""
323+
}
324+
lines := strings.Split(text, "\n")
325+
var result []string
326+
for _, line := range lines {
327+
if line == "" {
328+
result = append(result, "<div><br></div>")
329+
} else {
330+
result = append(result, fmt.Sprintf("<div>%s</div>", html.EscapeString(line)))
331+
}
332+
}
333+
return strings.Join(result, "")
334+
}
335+
198336
// CreateForwardDraft creates a forward draft with the original message.
199337
func (c *Client) CreateForwardDraft(opts ForwardOptions) (string, error) {
200338
original, err := c.GetEmailByID(opts.EmailID)

internal/jmap/draft_test.go

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
package jmap
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
)
9+
10+
func TestQuoteText(t *testing.T) {
11+
tests := []struct {
12+
name string
13+
input string
14+
expected string
15+
}{
16+
{
17+
name: "empty string",
18+
input: "",
19+
expected: "",
20+
},
21+
{
22+
name: "single line",
23+
input: "Hello world",
24+
expected: "> Hello world",
25+
},
26+
{
27+
name: "multiple lines",
28+
input: "Line 1\nLine 2\nLine 3",
29+
expected: "> Line 1\n> Line 2\n> Line 3",
30+
},
31+
{
32+
name: "already quoted text",
33+
input: "> Previously quoted\nNew line",
34+
expected: "> > Previously quoted\n> New line",
35+
},
36+
{
37+
name: "empty lines preserved",
38+
input: "First\n\nThird",
39+
expected: "> First\n> \n> Third",
40+
},
41+
}
42+
43+
for _, tt := range tests {
44+
t.Run(tt.name, func(t *testing.T) {
45+
result := quoteText(tt.input)
46+
assert.Equal(t, tt.expected, result)
47+
})
48+
}
49+
}
50+
51+
func TestFormatReplyHTML(t *testing.T) {
52+
t.Run("with HTML original", func(t *testing.T) {
53+
result := formatReplyHTML(
54+
"Thanks!",
55+
"On Jan 30, 2026, alice@example.com wrote:",
56+
"<p>Original <b>HTML</b> content</p>",
57+
"Original text content",
58+
)
59+
60+
// Should be a full HTML document
61+
assert.Contains(t, result, "<!DOCTYPE html>")
62+
assert.Contains(t, result, "<html>")
63+
assert.Contains(t, result, "</html>")
64+
// Should contain reply text in div
65+
assert.Contains(t, result, "<div>Thanks!</div>")
66+
// Should contain attribution
67+
assert.Contains(t, result, "On Jan 30, 2026, alice@example.com wrote:")
68+
// Should use HTML original in blockquote
69+
assert.Contains(t, result, "<blockquote type=\"cite\" id=\"qt\">")
70+
assert.Contains(t, result, "<p>Original <b>HTML</b> content</p>")
71+
// Should NOT use text version when HTML is available
72+
assert.NotContains(t, result, "Original text content")
73+
// Should have blank line before attribution
74+
assert.Contains(t, result, "</div><div><br></div><div>On Jan 30")
75+
})
76+
77+
t.Run("with text only original", func(t *testing.T) {
78+
result := formatReplyHTML(
79+
"Thanks!",
80+
"On Jan 30, 2026, bob@example.com wrote:",
81+
"", // no HTML
82+
"Plain text original",
83+
)
84+
85+
assert.Contains(t, result, "<div>Thanks!</div>")
86+
assert.Contains(t, result, "<blockquote type=\"cite\" id=\"qt\">")
87+
assert.Contains(t, result, "<div>Plain text original</div>")
88+
})
89+
90+
t.Run("escapes HTML in reply text", func(t *testing.T) {
91+
result := formatReplyHTML(
92+
"Check <script>alert('xss')</script>",
93+
"On Jan 30, 2026, test@example.com wrote:",
94+
"",
95+
"Original",
96+
)
97+
98+
// Should escape dangerous HTML
99+
assert.Contains(t, result, "&lt;script&gt;")
100+
assert.NotContains(t, result, "<script>alert")
101+
})
102+
103+
t.Run("converts line breaks to divs", func(t *testing.T) {
104+
result := formatReplyHTML(
105+
"Line 1\nLine 2",
106+
"Attribution",
107+
"",
108+
"Original",
109+
)
110+
111+
assert.Contains(t, result, "<div>Line 1</div><div>Line 2</div>")
112+
})
113+
114+
t.Run("converts empty lines to br divs", func(t *testing.T) {
115+
result := formatReplyHTML(
116+
"Line 1\n\nLine 3",
117+
"Attribution",
118+
"",
119+
"Original",
120+
)
121+
122+
assert.Contains(t, result, "<div>Line 1</div><div><br></div><div>Line 3</div>")
123+
})
124+
125+
t.Run("escapes attribution", func(t *testing.T) {
126+
result := formatReplyHTML(
127+
"Reply",
128+
"On Jan 30, <attacker@evil.com> wrote:",
129+
"",
130+
"Original",
131+
)
132+
133+
assert.Contains(t, result, "&lt;attacker@evil.com&gt;")
134+
})
135+
136+
t.Run("has blank line after blockquote", func(t *testing.T) {
137+
result := formatReplyHTML(
138+
"Reply",
139+
"Attribution",
140+
"<p>Original</p>",
141+
"",
142+
)
143+
144+
assert.Contains(t, result, "</blockquote><div><br></div></body>")
145+
})
146+
}
147+
148+
func TestTextToHTMLDivs(t *testing.T) {
149+
t.Run("empty string", func(t *testing.T) {
150+
assert.Equal(t, "", textToHTMLDivs(""))
151+
})
152+
153+
t.Run("single line", func(t *testing.T) {
154+
assert.Equal(t, "<div>Hello</div>", textToHTMLDivs("Hello"))
155+
})
156+
157+
t.Run("multiple lines", func(t *testing.T) {
158+
result := textToHTMLDivs("Line 1\nLine 2\nLine 3")
159+
assert.Equal(t, "<div>Line 1</div><div>Line 2</div><div>Line 3</div>", result)
160+
})
161+
162+
t.Run("empty lines become br divs", func(t *testing.T) {
163+
result := textToHTMLDivs("First\n\nThird")
164+
assert.Equal(t, "<div>First</div><div><br></div><div>Third</div>", result)
165+
})
166+
167+
t.Run("escapes HTML", func(t *testing.T) {
168+
result := textToHTMLDivs("<script>alert('xss')</script>")
169+
assert.Equal(t, "<div>&lt;script&gt;alert(&#39;xss&#39;)&lt;/script&gt;</div>", result)
170+
})
171+
}
172+
173+
func TestCreateReplyDraftQuoting(t *testing.T) {
174+
// Test that the plain text body includes quoted original
175+
t.Run("plain text includes attribution and quoted text", func(t *testing.T) {
176+
// This is a unit test for the text formatting logic
177+
body := "My reply"
178+
attribution := "On Mon, Jan 30, 2026 at 3:04 PM, alice@example.com wrote:"
179+
originalText := "Original message\nSecond line"
180+
181+
textBody := body + "\n\n" + attribution + "\n" + quoteText(originalText)
182+
183+
assert.True(t, strings.HasPrefix(textBody, "My reply"))
184+
assert.Contains(t, textBody, attribution)
185+
assert.Contains(t, textBody, "> Original message")
186+
assert.Contains(t, textBody, "> Second line")
187+
})
188+
}

0 commit comments

Comments
 (0)