6c81df17ec
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
152 lines
4.7 KiB
Go
152 lines
4.7 KiB
Go
package email
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func fixture(t *testing.T, name string) []byte {
|
|
t.Helper()
|
|
b, err := os.ReadFile(filepath.Join("testdata", name))
|
|
if err != nil {
|
|
t.Fatalf("read fixture %s: %v", name, err)
|
|
}
|
|
return b
|
|
}
|
|
|
|
func TestParsePlainRussian(t *testing.T) {
|
|
msg, err := ParseMessage(7, fixture(t, "plain_ru.eml"))
|
|
if err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
if msg.UID != 7 {
|
|
t.Errorf("uid = %d, want 7", msg.UID)
|
|
}
|
|
if want := "Нужно закрыть задачу"; msg.Subject != want {
|
|
t.Errorf("subject = %q, want %q", msg.Subject, want)
|
|
}
|
|
if !strings.Contains(msg.From, "Антон") {
|
|
t.Errorf("from = %q, want the decoded display name", msg.From)
|
|
}
|
|
if !strings.Contains(msg.Body, "Надо отправить акт до пятницы.") {
|
|
t.Errorf("body = %q, want the quoted-printable text decoded", msg.Body)
|
|
}
|
|
if msg.Junk {
|
|
t.Errorf("a personal mail must not be junk (%s)", msg.JunkReason)
|
|
}
|
|
}
|
|
|
|
func TestParseHTMLOnlyIsStripped(t *testing.T) {
|
|
msg, err := ParseMessage(1, fixture(t, "html_only.eml"))
|
|
if err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
if strings.Contains(msg.Body, "<") || strings.Contains(msg.Body, "color:red") || strings.Contains(msg.Body, "x()") {
|
|
t.Errorf("body still has markup/script/style: %q", msg.Body)
|
|
}
|
|
for _, want := range []string{"Счёт за интернет: 700", "Оплатить до 5 августа."} {
|
|
if !strings.Contains(msg.Body, want) {
|
|
t.Errorf("body = %q, want it to contain %q", msg.Body, want)
|
|
}
|
|
}
|
|
// must have become a real space, not vanished into the number.
|
|
if strings.Contains(msg.Body, " ") {
|
|
t.Errorf("entity left unescaped: %q", msg.Body)
|
|
}
|
|
}
|
|
|
|
func TestParsePrefersPlainAndSkipsAttachments(t *testing.T) {
|
|
msg, err := ParseMessage(2, fixture(t, "mixed_attachment.eml"))
|
|
if err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
if got := strings.TrimSpace(msg.Body); got != "Sign the contract before Monday." {
|
|
t.Errorf("body = %q, want the text/plain alternative only", got)
|
|
}
|
|
if strings.Contains(msg.Body, "PDF") {
|
|
t.Errorf("attachment bytes leaked into the body: %q", msg.Body)
|
|
}
|
|
}
|
|
|
|
// windows-1251 is what older Russian senders still emit. Subject-only for those
|
|
// mails meant they could never produce a task candidate.
|
|
func TestParseCP1251(t *testing.T) {
|
|
msg, err := ParseMessage(3, fixture(t, "cp1251.eml"))
|
|
if err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
if want := "Счёт за интернет"; msg.Subject != want {
|
|
t.Errorf("subject = %q, want %q", msg.Subject, want)
|
|
}
|
|
if want := "Оплати счёт до пятницы."; !strings.Contains(msg.Body, want) {
|
|
t.Errorf("body = %q, want it to contain %q", msg.Body, want)
|
|
}
|
|
}
|
|
|
|
// A charset with no table here must degrade to headers-only rather than to
|
|
// mojibake the model would then extract a task from.
|
|
func TestParseUnsupportedCharsetKeepsHeaders(t *testing.T) {
|
|
msg, err := ParseMessage(3, fixture(t, "koi8r.eml"))
|
|
if err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
if msg.Subject != "Legacy" {
|
|
t.Errorf("subject = %q, want Legacy", msg.Subject)
|
|
}
|
|
if msg.Body != "" {
|
|
t.Errorf("body = %q, want empty for an undecodable charset", msg.Body)
|
|
}
|
|
}
|
|
|
|
// A nested multipart/alternative that only had HTML must not fill the plain
|
|
// bucket: a real text/plain sibling later in the message is the better text and
|
|
// used to be discarded.
|
|
func TestParseNestedHTMLDoesNotShadowLaterPlain(t *testing.T) {
|
|
raw := "Subject: nested\r\n" +
|
|
"Content-Type: multipart/mixed; boundary=OUT\r\n\r\n" +
|
|
"--OUT\r\n" +
|
|
"Content-Type: multipart/alternative; boundary=IN\r\n\r\n" +
|
|
"--IN\r\n" +
|
|
"Content-Type: text/html; charset=utf-8\r\n\r\n" +
|
|
"<p>from the html part</p>\r\n" +
|
|
"--IN--\r\n" +
|
|
"--OUT\r\n" +
|
|
"Content-Type: text/plain; charset=utf-8\r\n\r\n" +
|
|
"the real plain text\r\n" +
|
|
"--OUT--\r\n"
|
|
msg, err := ParseMessage(5, []byte(raw))
|
|
if err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
if got := strings.TrimSpace(msg.Body); got != "the real plain text" {
|
|
t.Errorf("body = %q, want the text/plain part to win", got)
|
|
}
|
|
}
|
|
|
|
func TestParseTruncatesLongBody(t *testing.T) {
|
|
var b strings.Builder
|
|
b.WriteString("Subject: long\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n")
|
|
for i := 0; i < 2000; i++ {
|
|
b.WriteString("длинная строка ")
|
|
}
|
|
msg, err := ParseMessage(4, []byte(b.String()))
|
|
if err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
if len(msg.Body) > MaxBodyBytes+8 {
|
|
t.Errorf("body kept %d bytes, want ≤ %d", len(msg.Body), MaxBodyBytes)
|
|
}
|
|
if !strings.HasSuffix(msg.Body, "…") {
|
|
t.Errorf("truncated body should be marked: %q", msg.Body[len(msg.Body)-20:])
|
|
}
|
|
}
|
|
|
|
func TestCollapseSqueezesBlankLines(t *testing.T) {
|
|
got := collapse(" a b \r\n\r\n\r\n\r\n c \r\n")
|
|
if got != "a b\n\nc" {
|
|
t.Errorf("collapse = %q, want %q", got, "a b\n\nc")
|
|
}
|
|
}
|