feat(mail): RFC822 message parsing (headers, body, attachments)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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--
|
||||
Reference in New Issue
Block a user