email: drop the dead Gmail rule, fix nested MIME, decode windows-1251

The Gmail category rule matched X-GM-LABELS and X-Gmail-Labels against the
parsed header block. Neither is a header. X-GM-LABELS is a Gmail FETCH data
item and never appears in the message source, and X-Gmail-Labels only exists in
a Takeout export, so the branch could not fire against a real mailbox while its
doc comment promised a Promotions filter. Its test built the header by hand and
therefore asserted the matcher rather than the plumbing. The rule is removed and
the comment says what bringing it back would take.

multipartText folded a nested multipart's answer into one string, so HTML
derived text landed in the plain bucket and a real text/plain sibling later in
the message was discarded by the guard on plain being set. The two buckets now
stay separate through the recursion.

windows-1251 returned an unsupported-charset error and the message degraded to
subject only. That is the charset older Russian senders still use, so those
mails could never produce a task candidate. It is decoded from a 128 entry table
here rather than by vendoring x/text, for the body and for encoded words in the
subject. Every other unknown charset still degrades to subject only.
Found in review of #63.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
This commit is contained in:
kami
2026-08-01 14:01:04 +04:00
parent d69a1f8076
commit 6c81df17ec
7 changed files with 182 additions and 55 deletions
+66 -27
View File
@@ -94,7 +94,14 @@ func plaintextBody(contentType, encoding string, body io.Reader) (string, error)
if boundary == "" {
return "", fmt.Errorf("email: multipart without boundary")
}
return multipartText(multipart.NewReader(body, boundary))
plain, html, err := multipartText(multipart.NewReader(body, boundary))
if err != nil {
return "", err
}
if strings.TrimSpace(plain) != "" {
return plain, nil
}
return html, nil
case mediaType == "text/html":
raw, err := decodeBody(body, encoding, params["charset"])
if err != nil {
@@ -109,10 +116,15 @@ func plaintextBody(contentType, encoding string, body io.Reader) (string, error)
}
}
// multipartText reads one multipart level, recursing into nested multiparts.
// Returns the plain part if any part yielded one, else the stripped HTML.
func multipartText(mr *multipart.Reader) (string, error) {
var plain, html string
// multipartText reads one multipart level, recursing into nested multiparts,
// and returns the two buckets separately: real text/plain, and text stripped
// out of HTML.
//
// The buckets stay separate all the way up because a nested multipart can
// contribute either kind. Folding a nested level's answer into one string put
// HTML-derived text in the plain bucket, and a real text/plain sibling later in
// the message was then thrown away by the "plain is already set" guard.
func multipartText(mr *multipart.Reader) (plain, html string, err error) {
for {
part, err := mr.NextPart()
if err == io.EOF {
@@ -127,35 +139,47 @@ func multipartText(mr *multipart.Reader) (string, error) {
continue // attachment
}
ct := part.Header.Get("Content-Type")
mediaType, _, _ := mime.ParseMediaType(ct)
text, err := plaintextBody(ct, part.Header.Get("Content-Transfer-Encoding"), part)
part.Close()
if err != nil || strings.TrimSpace(text) == "" {
continue
}
if mediaType == "text/html" && !strings.HasPrefix(mediaType, "multipart/") {
if html == "" {
html = text
mediaType, params, _ := mime.ParseMediaType(ct)
switch {
case strings.HasPrefix(mediaType, "multipart/"):
var np, nh string
if b := params["boundary"]; b != "" {
np, nh, _ = multipartText(multipart.NewReader(part, b))
}
part.Close()
if plain == "" {
plain = np
}
if html == "" {
html = nh
}
default:
text, terr := plaintextBody(ct, part.Header.Get("Content-Transfer-Encoding"), part)
part.Close()
if terr != nil || strings.TrimSpace(text) == "" {
continue
}
if mediaType == "text/html" {
if html == "" {
html = text
}
continue
}
if plain == "" {
plain = text
}
continue
}
if plain == "" {
plain = text
}
}
if strings.TrimSpace(plain) != "" {
return plain, nil
}
return html, nil
return plain, html, nil
}
// decodeBody applies the transfer encoding, then the charset.
//
// Charset support is UTF-8 (and ASCII, its subset) only, on purpose: x/text's
// encoding tables are not vendored here, and guessing at windows-1251 bytes
// would feed the model mojibake it would happily extract a task from. An
// unsupported charset returns an error, which ParseMessage turns into an empty
// body — subject-only, which is honest.
// Charset support is UTF-8 (and ASCII, its subset) plus windows-1251, which is
// decoded from a table in charset.go — see the reasoning there. Everything else
// returns an error, which ParseMessage turns into an empty body: subject-only,
// which is honest. Guessing at unknown bytes would feed the model mojibake it
// would happily extract a task from.
func decodeBody(r io.Reader, encoding, charset string) (string, error) {
switch strings.ToLower(strings.TrimSpace(encoding)) {
case "quoted-printable":
@@ -170,6 +194,8 @@ func decodeBody(r io.Reader, encoding, charset string) (string, error) {
switch cs := strings.ToLower(strings.TrimSpace(charset)); cs {
case "", "utf-8", "utf8", "us-ascii", "ascii":
return string(b), nil
case "windows-1251", "cp1251", "windows1251", "x-cp1251":
return decodeCP1251(b), nil
default:
return "", fmt.Errorf("email: unsupported charset %q", cs)
}
@@ -181,6 +207,19 @@ func decodeBody(r io.Reader, encoding, charset string) (string, error) {
// shown to him as evidence.
func decodeHeader(v string) string {
dec := new(mime.WordDecoder)
// Same charset support as the body: an old Russian sender encodes the
// subject in windows-1251 too, and a subject is often the whole task.
dec.CharsetReader = func(charset string, r io.Reader) (io.Reader, error) {
switch strings.ToLower(strings.TrimSpace(charset)) {
case "windows-1251", "cp1251", "windows1251", "x-cp1251":
b, err := io.ReadAll(io.LimitReader(r, 1<<16))
if err != nil && len(b) == 0 {
return nil, err
}
return strings.NewReader(decodeCP1251(b)), nil
}
return nil, fmt.Errorf("email: unsupported charset %q", charset)
}
out, err := dec.DecodeHeader(v)
if err != nil {
return collapse(v)