b4646155b4
internal/email is the reading half of the email reader: a ~200-line IMAP client (LOGIN, EXAMINE, UID SEARCH SINCE, UID FETCH BODY.PEEK, LOGOUT), a MIME-to-plaintext converter, and a header-only junk filter. Two protocol choices are the design, not shortcuts. EXAMINE instead of SELECT means the session is read-only at the protocol level, so no command in it can flip a flag or expunge anything by mistake. BODY.PEEK instead of BODY means reading a message does not mark it \Seen — Maven reads his mail and leaves no trace of having done so, and the unread state in his own client stays his. Hand-rolled rather than go-imap because this is the one path that holds his mailbox credential and reads his private mail: five commands with no dependencies is auditable in a sitting. No IDLE and no cleartext/STARTTLS either — an option to send his password over a plain socket is an option to get it wrong once. Junk is decided by headers alone, before any model is involved: List-Unsubscribe/List-Id, Precedence: bulk, Auto-Submitted, the spam headers, and Gmail's own category labels. Sender lists and subject keywords are deliberately absent — they age badly and they would put his contacts in a config file. A junk verdict only means "do not spend the model on this"; nothing is deleted and no server flag is touched. Nothing here logs a body, a subject or an address, the junk reason names a header rather than content, and an undecodable charset degrades to headers-only instead of feeding the model mojibake. Verified against recorded .eml fixtures and an in-process fake IMAP server.
112 lines
3.3 KiB
Go
112 lines
3.3 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)
|
|
}
|
|
}
|
|
|
|
// An unsupported charset 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, "cp1251.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)
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|