Skip to content

Commit a7196ce

Browse files
committed
fix(gmail): preserve HTML reply alternatives
1 parent 6c743c6 commit a7196ce

6 files changed

Lines changed: 108 additions & 14 deletions

File tree

internal/cmd/gmail_reply.go

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -263,9 +263,19 @@ func applyQuoteToBodies(plainBody string, htmlBody string, quote bool, info *rep
263263
}
264264

265265
userPlain := plainBody
266-
outPlain := plainBody
267-
if info.Body != "" {
268-
outPlain += formatQuotedMessage(info.FromAddr, info.Date, info.Body)
266+
hasHTMLReply := strings.TrimSpace(htmlBody) != ""
267+
if strings.TrimSpace(userPlain) == "" && hasHTMLReply {
268+
userPlain = htmlToPlainText(htmlBody)
269+
}
270+
271+
quotedPlain := info.Body
272+
if strings.TrimSpace(quotedPlain) == "" && strings.TrimSpace(info.BodyHTML) != "" {
273+
quotedPlain = htmlToPlainText(info.BodyHTML)
274+
}
275+
276+
outPlain := userPlain
277+
if quotedPlain != "" && (!hasHTMLReply || strings.TrimSpace(userPlain) != "") {
278+
outPlain += formatQuotedMessage(info.FromAddr, info.Date, quotedPlain)
269279
}
270280

271281
quoteContent := info.BodyHTML

internal/cmd/gmail_send_quote_test.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,3 +70,86 @@ func TestReplyInfoFromMessage_IncludeBody_DoesNotTreatHTMLAsPlain(t *testing.T)
7070
t.Fatalf("expected BodyHTML to be set")
7171
}
7272
}
73+
74+
func TestApplyQuoteToBodiesDerivesPlainReplyFromHTML(t *testing.T) {
75+
plain, html := applyQuoteToBodies(
76+
"",
77+
"<p>HTML <strong>reply</strong></p>",
78+
true,
79+
&replyInfo{
80+
FromAddr: "sender@example.com",
81+
Date: "Mon, 1 Jan 2024 00:00:00 +0000",
82+
Body: "Original plain",
83+
BodyHTML: "<p>Original HTML</p>",
84+
},
85+
)
86+
87+
if !strings.Contains(plain, "HTML reply") {
88+
t.Fatalf("plain body omitted derived reply text: %q", plain)
89+
}
90+
if !strings.Contains(plain, "> Original plain") {
91+
t.Fatalf("plain body omitted quoted original: %q", plain)
92+
}
93+
if !strings.Contains(html, "<p>HTML <strong>reply</strong></p>") || !strings.Contains(html, "gmail_quote") {
94+
t.Fatalf("HTML body missing reply or quote: %q", html)
95+
}
96+
}
97+
98+
func TestApplyQuoteToBodiesOmitsNonVisibleHTMLFromPlainReply(t *testing.T) {
99+
plain, _ := applyQuoteToBodies(
100+
"",
101+
`<!doctype html><html><head><title>Hidden title</title><style>.secret { color: red; }</style><script>alert("hidden")</script></head><body><p>Visible reply</p></body></html>`,
102+
true,
103+
&replyInfo{
104+
Body: "Original plain",
105+
BodyHTML: "<p>Original HTML</p>",
106+
},
107+
)
108+
109+
if !strings.Contains(plain, "Visible reply") || !strings.Contains(plain, "> Original plain") {
110+
t.Fatalf("plain body missing visible reply or quote: %q", plain)
111+
}
112+
for _, hidden := range []string{"Hidden title", ".secret", `alert("hidden")`} {
113+
if strings.Contains(plain, hidden) {
114+
t.Fatalf("plain body included non-visible HTML %q: %q", hidden, plain)
115+
}
116+
}
117+
}
118+
119+
func TestApplyQuoteToBodiesDerivesPlainQuoteFromHTMLOriginal(t *testing.T) {
120+
plain, html := applyQuoteToBodies(
121+
"",
122+
"<p>HTML reply</p>",
123+
true,
124+
&replyInfo{
125+
FromAddr: "sender@example.com",
126+
BodyHTML: "<p>HTML-only original</p>",
127+
},
128+
)
129+
130+
if !strings.Contains(plain, "HTML reply") || !strings.Contains(plain, "> HTML-only original") {
131+
t.Fatalf("plain body missing derived reply or quote: %q", plain)
132+
}
133+
if !strings.Contains(html, "<p>HTML reply</p>") || !strings.Contains(html, "HTML-only original") {
134+
t.Fatalf("HTML body missing reply or quote: %q", html)
135+
}
136+
}
137+
138+
func TestApplyQuoteToBodiesKeepsImageOnlyReplyHTMLOnly(t *testing.T) {
139+
plain, html := applyQuoteToBodies(
140+
"",
141+
`<img src="cid:reply-image">`,
142+
true,
143+
&replyInfo{
144+
Body: "Original plain",
145+
BodyHTML: "<p>Original HTML</p>",
146+
},
147+
)
148+
149+
if strings.TrimSpace(plain) != "" {
150+
t.Fatalf("image-only HTML reply produced quote-only plain alternative: %q", plain)
151+
}
152+
if !strings.Contains(html, `cid:reply-image`) || !strings.Contains(html, "gmail_quote") {
153+
t.Fatalf("HTML body missing reply image or quote: %q", html)
154+
}
155+
}

internal/cmd/gmail_send_signature.go

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ func (c *GmailSendCmd) resolveComposeSignature(ctx context.Context, svc *gmail.S
5555
}
5656
htmlSignature := strings.TrimSpace(sendAs.Signature)
5757
return composeSignature{
58-
Plain: signatureHTMLToText(htmlSignature),
58+
Plain: htmlToPlainText(htmlSignature),
5959
HTML: htmlSignature,
6060
}, email, nil
6161
}
@@ -84,7 +84,7 @@ func readComposeSignatureFile(path string) (composeSignature, error) {
8484
}
8585
if looksLikeHTML(value) {
8686
return composeSignature{
87-
Plain: signatureHTMLToText(value),
87+
Plain: htmlToPlainText(value),
8888
HTML: value,
8989
}, nil
9090
}
@@ -109,7 +109,7 @@ func appendBodyBlock(body, block string) string {
109109
return body + "\n\n" + block
110110
}
111111

112-
func signatureHTMLToText(value string) string {
112+
func htmlToPlainText(value string) string {
113113
value = strings.TrimSpace(value)
114114
if value == "" {
115115
return ""
@@ -130,15 +130,17 @@ func signatureHTMLToText(value string) string {
130130
out.WriteString(n.Data)
131131
case nethtml.ElementNode:
132132
switch strings.ToLower(n.Data) {
133+
case "head", "style", "script", "template", "noscript", literalTitle:
134+
return
133135
case "br":
134-
writeSignatureNewline(&out)
136+
writeHTMLNewline(&out)
135137
return
136138
case "div", "p", "li":
137-
writeSignatureNewline(&out)
139+
writeHTMLNewline(&out)
138140
for child := n.FirstChild; child != nil; child = child.NextSibling {
139141
walk(child)
140142
}
141-
writeSignatureNewline(&out)
143+
writeHTMLNewline(&out)
142144
return
143145
}
144146
}
@@ -158,7 +160,7 @@ func signatureHTMLToText(value string) string {
158160
return strings.Join(kept, "\n")
159161
}
160162

161-
func writeSignatureNewline(out *strings.Builder) {
163+
func writeHTMLNewline(out *strings.Builder) {
162164
if out.Len() == 0 {
163165
return
164166
}

internal/cmd/literals.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const (
1010
literalAuto = "auto"
1111
literalDefault = "default"
1212
literalError = "error"
13+
literalTitle = "title"
1314
literalWindows = "windows"
1415

1516
// literalMarkdownTripleDash is the three-dash token used for YAML

internal/cmd/slides_layout.go

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,13 @@ const (
1111
LayoutKindThreeCols
1212
)
1313

14-
const slideyLayoutTitle = "title"
15-
1614
// MapSlideyLayout maps a slidey frontmatter layout name to a LayoutKind.
1715
// Unknown values fall back to LayoutKindDefault.
1816
func MapSlideyLayout(name string) LayoutKind {
1917
switch name {
2018
case "center":
2119
return LayoutKindCenter
22-
case slideyLayoutTitle, "hero", "statement":
20+
case literalTitle, "hero", "statement":
2321
return LayoutKindSectionHeader
2422
case "two-cols":
2523
return LayoutKindTwoCols

internal/cmd/slides_markdown.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ func inlinesToText(inlines []Inline) string {
109109

110110
func layoutSkipsTitleHoist(layout string) bool {
111111
switch layout {
112-
case slideyLayoutTitle, "hero", "statement":
112+
case literalTitle, "hero", "statement":
113113
return true
114114
}
115115
return false

0 commit comments

Comments
 (0)