Skip to content

Commit 185cb77

Browse files
steipetekesslerio
andcommitted
fix(gmail): preserve HTML signature fragments (#879)
Co-authored-by: Martin Kessler <[email protected]>
1 parent 57e3283 commit 185cb77

7 files changed

Lines changed: 151 additions & 19 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## 0.30.1 - Unreleased
44

5+
- Gmail: preserve HTML fragments from `--signature-file` instead of escaping their markup, without broadening HTML detection for message display or reply quoting. (#879) — thanks @kesslerio.
56
- Docs: honor `--tab` when setting document layout so `page-layout --tab` (and `write --pageless --tab`) target the specified tab instead of always the default tab. Page layout is per-tab; previously these silently no-opped on secondary tabs of multi-tab documents. (#878) — thanks @atmasphere.
67
- Auth: recover from corrupt stored OAuth token payloads by routing only classified decode corruption through the normal re-authentication flow while preserving operational keyring errors. (#872, #874) — thanks @KrasimirKralev.
78
- Calendar: add repeatable or comma-separated `events --event-types` filtering across single, selected, and all-calendar listings while preserving the existing unfiltered default. (#870) — thanks @malob.

internal/cmd/backup_export_gmail.go

Lines changed: 1 addition & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,7 @@ func backupEmailMarkdownText(value string) string {
231231
if value == "" {
232232
return ""
233233
}
234-
if gmailcontent.LooksLikeHTML(value) || looksLikeHTMLFragment(value) {
234+
if gmailcontent.LooksLikeHTMLFragment(value) {
235235
return cleanBackupHTMLBody(value)
236236
}
237237
return value
@@ -242,23 +242,6 @@ func cleanBackupHTMLBody(value string) string {
242242
return strings.Join(strings.Fields(cleaned), " ")
243243
}
244244

245-
func looksLikeHTMLFragment(value string) bool {
246-
trimmed := strings.ToLower(strings.TrimSpace(value))
247-
if trimmed == "" {
248-
return false
249-
}
250-
for _, marker := range []string{
251-
"<p", "</p", "<br", "<div", "</div", "<span", "</span", "<table", "</table",
252-
"<tr", "</tr", "<td", "</td", "<section", "</section", "<blockquote",
253-
"</blockquote", "<a ", "</a", "<img", "<font", "</font", "<style", "<!--",
254-
} {
255-
if strings.Contains(trimmed, marker) {
256-
return true
257-
}
258-
}
259-
return false
260-
}
261-
262245
func renderGmailMessageMarkdown(message gmailBackupMessage, parsed backupEmail, body string, attachmentRels []string) string {
263246
var b strings.Builder
264247
b.WriteString("---\n")

internal/cmd/backup_export_gmail_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,4 +192,9 @@ func TestBackupEmailMarkdownBodyCleansHTMLFragments(t *testing.T) {
192192
if got != "Hi there" {
193193
t.Fatalf("html body = %q, want %q", got, "Hi there")
194194
}
195+
196+
got = backupEmailMarkdownBody(backupEmail{TextBody: "Use <code>foo</code> or <kbd>Ctrl-C</kbd>"})
197+
if got != "Use <code>foo</code> or <kbd>Ctrl-C</kbd>" {
198+
t.Fatalf("literal tag-like text changed: %q", got)
199+
}
195200
}

internal/cmd/gmail_send_signature.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ func readComposeSignatureFile(path string) (composeSignature, error) {
8383
if value == "" {
8484
return composeSignature{}, nil
8585
}
86-
if gmailcontent.LooksLikeHTML(value) {
86+
if gmailcontent.LooksLikeHTMLFragment(value) {
8787
return composeSignature{
8888
Plain: htmlToPlainText(value),
8989
HTML: value,

internal/cmd/gmail_send_signature_test.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,30 @@ func TestGmailSendCmd_Run_WithSignatureFile(t *testing.T) {
104104
}
105105
}
106106

107+
func TestReadComposeSignatureFile_HTMLFragment(t *testing.T) {
108+
t.Parallel()
109+
110+
path := filepath.Join(t.TempDir(), "signature.html")
111+
fragment := `<style type="text/css">p { color: red; }</style><table><tr><td>Local Sig</td></tr></table>`
112+
if err := os.WriteFile(path, []byte(fragment), 0o600); err != nil {
113+
t.Fatalf("write signature file: %v", err)
114+
}
115+
116+
signature, err := readComposeSignatureFile(path)
117+
if err != nil {
118+
t.Fatalf("read signature file: %v", err)
119+
}
120+
if signature.HTML != fragment {
121+
t.Fatalf("HTML signature was escaped or changed:\n%s", signature.HTML)
122+
}
123+
if strings.Contains(signature.HTML, "&lt;") {
124+
t.Fatalf("HTML signature contains escaped markup: %s", signature.HTML)
125+
}
126+
if signature.Plain != "Local Sig" {
127+
t.Fatalf("plain signature = %q, want Local Sig", signature.Plain)
128+
}
129+
}
130+
107131
func TestGmailSendCmd_Run_EmptySignatureWarnsAndSends(t *testing.T) {
108132
var raw string
109133
svc, cleanup := newGmailServiceForTest(t, func(w http.ResponseWriter, r *http.Request) {

internal/gmailcontent/content.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,26 @@ func LooksLikeHTML(value string) bool {
139139
strings.Contains(trimmed, "<html")
140140
}
141141

142+
// LooksLikeHTMLFragment reports whether value appears to contain an HTML
143+
// document or recognized fragment. Use it only where both plain text and HTML
144+
// fragments are part of the input contract.
145+
func LooksLikeHTMLFragment(value string) bool {
146+
trimmed := strings.TrimSpace(strings.ToLower(value))
147+
if trimmed == "" {
148+
return false
149+
}
150+
151+
return LooksLikeHTML(trimmed) || htmlFragmentPattern.MatchString(trimmed)
152+
}
153+
154+
// htmlFragmentPattern matches real HTML tags and comments, not angle-bracketed plain text
155+
// like <path>, <project>, or <[email protected]>. After the tag name, a real HTML boundary
156+
// is required: > (close tag), / (self-closing), or whitespace (start of attributes).
157+
// Punctuation like @, ., : does not count as a boundary.
158+
var htmlFragmentPattern = regexp.MustCompile(
159+
`<!--|</?(?:p|br|div|span|table|tr|td|section|blockquote|a|img|font|style)[\s/>]`,
160+
)
161+
142162
func decodePartBody(part *gmail.MessagePart) (string, error) {
143163
if part == nil || part.Body == nil || part.Body.Data == "" {
144164
return "", nil
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package gmailcontent
2+
3+
import "testing"
4+
5+
func TestLooksLikeHTML_DocumentPrefixes(t *testing.T) {
6+
cases := []struct {
7+
name string
8+
html string
9+
}{
10+
{"doctype", `<!doctype html><html><body>Hi</body></html>`},
11+
{"html", `<html><body>Hi</body></html>`},
12+
{"head", `<head><title>X</title></head>`},
13+
{"body", `<body>Hi</body>`},
14+
{"meta", `<meta charset="utf-8">`},
15+
{"html-contained", `Hello <html> world`},
16+
}
17+
for _, tc := range cases {
18+
t.Run(tc.name, func(t *testing.T) {
19+
if !LooksLikeHTML(tc.html) {
20+
t.Fatalf("expected true for %s", tc.name)
21+
}
22+
})
23+
}
24+
}
25+
26+
func TestLooksLikeHTMLFragment_FragmentMarkers(t *testing.T) {
27+
cases := []struct {
28+
name string
29+
html string
30+
}{
31+
{"style-block", `<style type="text/css">a { color: red; }</style><table><tr><td>Hi</td></tr></table>`},
32+
{"table", `<table><tr><td>Hi</td></tr></table>`},
33+
{"div", `<div>Hello</div>`},
34+
{"p", `<p>Hello</p>`},
35+
{"br", `<br>`},
36+
{"span", `<span>text</span>`},
37+
{"section", `<section>content</section>`},
38+
{"blockquote", `<blockquote>quoted</blockquote>`},
39+
{"anchor", `<a href="https://example.com">link</a>`},
40+
{"img", `<img src="x.png" alt="x">`},
41+
{"font", `<font face="Arial">text</font>`},
42+
{"comment", `<!-- comment --><div>x</div>`},
43+
}
44+
for _, tc := range cases {
45+
t.Run(tc.name, func(t *testing.T) {
46+
if !LooksLikeHTMLFragment(tc.html) {
47+
t.Fatalf("expected true for fragment %s: %q", tc.name, tc.html)
48+
}
49+
})
50+
}
51+
}
52+
53+
func TestLooksLikeHTML_PlainText(t *testing.T) {
54+
cases := []struct {
55+
name string
56+
text string
57+
}{
58+
{"empty", ""},
59+
{"whitespace", " "},
60+
{"plain", "Best,\nMartin Kessler"},
61+
{"url", "see <https://example.com> for details"},
62+
{"less-than", "a < b"},
63+
{"angle-path", "<path>"},
64+
{"angle-project", "<project>"},
65+
{"angle-email", "<[email protected]>"},
66+
{"angle-private", "<PRIVATE_PERSON>"},
67+
{"angle-in-sentence", "see <path> for details"},
68+
{"angle-name", "Best,\n<PRIVATE_PERSON>"},
69+
{"angle-multiword", "the <quick brown> fox"},
70+
{"angle-a-email", "<[email protected]>"},
71+
{"angle-b-email", "<[email protected]>"},
72+
{"angle-i-email", "<[email protected]>"},
73+
{"angle-p-email", "<[email protected]>"},
74+
{"angle-div-class", "<div.class>"},
75+
{"angle-a-colon", "<a:hello>"},
76+
{"angle-b-dot", "<b.foo>"},
77+
}
78+
for _, tc := range cases {
79+
t.Run(tc.name, func(t *testing.T) {
80+
if LooksLikeHTMLFragment(tc.text) {
81+
t.Fatalf("expected false for %s: %q", tc.name, tc.text)
82+
}
83+
})
84+
}
85+
}
86+
87+
func TestLooksLikeHTML_DoesNotBroadenToFragments(t *testing.T) {
88+
t.Parallel()
89+
90+
for _, value := range []string{"<style>p { color: red }</style>", "<table><tr><td>Hi</td></tr></table>", "<p>Hi</p>"} {
91+
if LooksLikeHTML(value) {
92+
t.Errorf("LooksLikeHTML(%q) = true, want legacy document-only classification", value)
93+
}
94+
95+
if !LooksLikeHTMLFragment(value) {
96+
t.Errorf("LooksLikeHTMLFragment(%q) = false, want true", value)
97+
}
98+
}
99+
}

0 commit comments

Comments
 (0)