feat(mail): RFC822 message parsing (headers, body, attachments)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 23:51:29 +01:00
parent b9d0b57f84
commit d73aabca96
5 changed files with 220 additions and 0 deletions
+98
View File
@@ -0,0 +1,98 @@
// Package mail provides IMAP reading and RFC822 message parsing.
package mail
import (
"bytes"
"io"
"strings"
"github.com/emersion/go-message/mail"
)
type Header struct {
UID uint32
From string
To string
Subject string
Date string
MessageID string
HasAttachments bool
}
type Attachment struct {
Name string
Size int
MIME string
Content []byte
}
type Message struct {
Header Header
BodyText string
Attachments []Attachment
}
func readHeader(mr *mail.Reader, uid uint32) Header {
h := Header{UID: uid}
hd := mr.Header
h.Subject, _ = hd.Subject()
if addrs, err := hd.AddressList("From"); err == nil && len(addrs) > 0 {
h.From = addrs[0].String()
}
if addrs, err := hd.AddressList("To"); err == nil && len(addrs) > 0 {
h.To = addrs[0].String()
}
if d, err := hd.Date(); err == nil {
h.Date = d.UTC().Format("Mon, 02 Jan 2006 15:04:05 -0700")
}
if msgID, err := hd.MessageID(); err == nil {
h.MessageID = msgID
} else {
h.MessageID = strings.Trim(hd.Get("Message-Id"), "<> ")
}
return h
}
// ParseMessage decodes the full message including attachment contents.
func ParseMessage(uid uint32, raw []byte) (Message, error) {
mr, err := mail.CreateReader(bytes.NewReader(raw))
if err != nil {
return Message{}, err
}
m := Message{Header: readHeader(mr, uid)}
for {
part, err := mr.NextPart()
if err == io.EOF {
break
}
if err != nil {
return Message{}, err
}
switch hdr := part.Header.(type) {
case *mail.InlineHeader:
ct, _, _ := hdr.ContentType()
if strings.HasPrefix(ct, "text/plain") && m.BodyText == "" {
b, _ := io.ReadAll(part.Body)
m.BodyText = string(b)
}
case *mail.AttachmentHeader:
name, _ := hdr.Filename()
ct, _, _ := hdr.ContentType()
b, _ := io.ReadAll(part.Body)
m.Attachments = append(m.Attachments, Attachment{
Name: name, Size: len(b), MIME: ct, Content: b,
})
}
}
m.Header.HasAttachments = len(m.Attachments) > 0
return m, nil
}
// ParseHeaderOnly decodes headers and detects attachments without keeping bodies.
func ParseHeaderOnly(uid uint32, raw []byte) (Header, error) {
m, err := ParseMessage(uid, raw)
if err != nil {
return Header{}, err
}
return m.Header, nil
}
+70
View File
@@ -0,0 +1,70 @@
package mail
import (
"os"
"path/filepath"
"testing"
)
func loadFixture(t *testing.T, name string) []byte {
t.Helper()
b, err := os.ReadFile(filepath.Join("testdata", name))
if err != nil {
t.Fatalf("read fixture: %v", err)
}
return b
}
func TestParseMessage(t *testing.T) {
raw := loadFixture(t, "with_attachment.eml")
m, err := ParseMessage(42, raw)
if err != nil {
t.Fatalf("ParseMessage: %v", err)
}
if m.Header.UID != 42 {
t.Fatalf("uid: %d", m.Header.UID)
}
if m.Header.Subject != "Your Invoice #5" {
t.Fatalf("subject: %q", m.Header.Subject)
}
if m.Header.From != `"Bob" <bob@trusted.com>` && m.Header.From != "Bob <bob@trusted.com>" {
t.Fatalf("from: %q", m.Header.From)
}
if m.Header.MessageID != "abc123@trusted.com" && m.Header.MessageID != "<abc123@trusted.com>" {
t.Fatalf("message-id: %q", m.Header.MessageID)
}
if want := "Hello, here is your invoice."; !contains(m.BodyText, want) {
t.Fatalf("body=%q want contains %q", m.BodyText, want)
}
if !m.Header.HasAttachments {
t.Fatal("HasAttachments should be true")
}
if len(m.Attachments) != 1 || m.Attachments[0].Name != "invoice.txt" {
t.Fatalf("attachments: %+v", m.Attachments)
}
if !contains(string(m.Attachments[0].Content), "LINE-ITEM 1") {
t.Fatalf("attachment content: %q", m.Attachments[0].Content)
}
}
func TestParseHeaderOnly(t *testing.T) {
raw := loadFixture(t, "with_attachment.eml")
h, err := ParseHeaderOnly(7, raw)
if err != nil {
t.Fatalf("ParseHeaderOnly: %v", err)
}
if h.Subject != "Your Invoice #5" || !h.HasAttachments {
t.Fatalf("header: %+v", h)
}
}
func contains(s, sub string) bool {
return len(s) >= len(sub) && (func() bool {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}())
}
+18
View File
@@ -0,0 +1,18 @@
From: "Bob" <bob@trusted.com>
To: me@example.com
Subject: Your Invoice #5
Date: Sat, 20 Jun 2026 10:00:00 +0000
Message-ID: <abc123@trusted.com>
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="BOUNDARY"
--BOUNDARY
Content-Type: text/plain; charset=utf-8
Hello, here is your invoice.
--BOUNDARY
Content-Type: text/plain; charset=utf-8
Content-Disposition: attachment; filename="invoice.txt"
LINE-ITEM 1: 100.00
--BOUNDARY--