4f96bbd6ec
The MIME tree walk had no depth limit, and the nesting comes off the wire. A boundary line is a few bytes, so one message inside MaxMessageBytes can declare tens of thousands of multipart levels and pick the recursion depth of a daemon reading his mail. MaxMIMEDepth stops the walk at 12, well past the three levels real mail uses, and the headers still come through. ParseMessage converted the raw message to a string to read it, which copied up to 2 MiB per mail on a box already holding the resident model. It reads the bytes directly now. decodeCP1251 collected runes and then copied them into a string, four bytes a character for the whole body, and writes into a Builder instead. No behaviour change to what is read: EXAMINE and BODY.PEEK are still the only mailbox commands, and no credential reaches a log line. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
171 lines
5.4 KiB
Go
171 lines
5.4 KiB
Go
package email
|
|
|
|
import (
|
|
"fmt"
|
|
"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:])
|
|
}
|
|
}
|
|
|
|
// Nesting depth comes off the wire, so a hostile message must not get to pick
|
|
// the recursion depth. The walk stops and the headers still come through.
|
|
func TestParseMessageBoundsMIMEDepth(t *testing.T) {
|
|
var b strings.Builder
|
|
b.WriteString("Subject: deep\r\nMIME-Version: 1.0\r\n")
|
|
for i := 0; i < MaxMIMEDepth+20; i++ {
|
|
fmt.Fprintf(&b, "Content-Type: multipart/mixed; boundary=\"b%d\"\r\n\r\n--b%d\r\n", i, i)
|
|
}
|
|
b.WriteString("Content-Type: text/plain\r\n\r\nглубоко\r\n")
|
|
msg, err := ParseMessage(7, []byte(b.String()))
|
|
if err != nil {
|
|
t.Fatalf("ParseMessage: %v", err)
|
|
}
|
|
if msg.Subject != "deep" {
|
|
t.Errorf("Subject = %q, want the headers to survive", msg.Subject)
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|