Implement internal mail skill MVP

This commit is contained in:
赵义仑
2026-06-06 22:28:36 +08:00
commit 097df73495
22 changed files with 4059 additions and 0 deletions

228
internal/parser/parser.go Normal file
View File

@@ -0,0 +1,228 @@
package parser
import (
"bytes"
"encoding/base64"
"fmt"
"io"
"mime"
"mime/multipart"
"mime/quotedprintable"
"net/mail"
"path/filepath"
"strings"
"time"
"golang.org/x/net/html/charset"
"internal-mail-skill/internal/model"
)
func Parse(raw []byte) (model.ParsedEmail, error) {
msg, err := mail.ReadMessage(bytes.NewReader(raw))
if err != nil {
return model.ParsedEmail{}, fmt.Errorf("read message: %w", err)
}
parsed := model.ParsedEmail{
MessageIDHeader: strings.TrimSpace(msg.Header.Get("Message-ID")),
InReplyTo: strings.TrimSpace(msg.Header.Get("In-Reply-To")),
References: strings.TrimSpace(msg.Header.Get("References")),
Subject: decodeHeader(msg.Header.Get("Subject")),
DateHeader: strings.TrimSpace(msg.Header.Get("Date")),
}
if sentAt, err := mail.ParseDate(parsed.DateHeader); err == nil {
parsed.SentAt = &sentAt
}
if from, err := mail.ParseAddress(msg.Header.Get("From")); err == nil {
parsed.SenderName = decodeHeader(from.Name)
parsed.SenderEmail = strings.ToLower(from.Address)
}
parsed.To = parseAddressList(msg.Header.Get("To"))
parsed.Cc = parseAddressList(msg.Header.Get("Cc"))
if err := parsePart(mail.Header(msg.Header), msg.Body, &parsed); err != nil {
return model.ParsedEmail{}, err
}
return parsed, nil
}
func parsePart(header mail.Header, body io.Reader, out *model.ParsedEmail) error {
mediaType, params, err := mime.ParseMediaType(header.Get("Content-Type"))
if err != nil || mediaType == "" {
mediaType = "text/plain"
}
mediaType = strings.ToLower(mediaType)
if strings.HasPrefix(mediaType, "multipart/") {
boundary := params["boundary"]
if boundary == "" {
return fmt.Errorf("multipart message missing boundary")
}
mr := multipart.NewReader(body, boundary)
for {
part, err := mr.NextPart()
if err == io.EOF {
return nil
}
if err != nil {
return fmt.Errorf("read multipart: %w", err)
}
if err := parsePart(mail.Header(part.Header), part, out); err != nil {
return err
}
}
}
decoded := transferDecodedReader(header, body)
filename := partFilename(header)
disposition := strings.ToLower(header.Get("Content-Disposition"))
isAttachment := filename != "" || strings.Contains(disposition, "attachment")
if !isAttachment && strings.HasPrefix(mediaType, "text/") {
text, err := readText(decoded, params["charset"])
if err != nil {
return fmt.Errorf("read text part: %w", err)
}
switch mediaType {
case "text/html":
appendText(&out.BodyHTML, text)
default:
appendText(&out.BodyText, text)
}
return nil
}
data, err := io.ReadAll(decoded)
if err != nil {
return fmt.Errorf("read attachment: %w", err)
}
if filename == "" {
filename = "attachment.bin"
}
out.Attachments = append(out.Attachments, model.ParsedAttachment{
Filename: sanitizeFilename(filename),
ContentType: mediaType,
ContentID: trimAngle(header.Get("Content-ID")),
SizeBytes: int64(len(data)),
Data: data,
})
return nil
}
func decodeHeader(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
decoder := &mime.WordDecoder{CharsetReader: charset.NewReaderLabel}
decoded, err := decoder.DecodeHeader(value)
if err != nil {
return value
}
return decoded
}
func parseAddressList(value string) []model.Address {
value = strings.TrimSpace(value)
if value == "" {
return nil
}
list, err := mail.ParseAddressList(value)
if err != nil {
decoded := decodeHeader(value)
list, err = mail.ParseAddressList(decoded)
if err != nil {
return nil
}
}
out := make([]model.Address, 0, len(list))
for _, addr := range list {
out = append(out, model.Address{
Name: decodeHeader(addr.Name),
Email: strings.ToLower(addr.Address),
})
}
return out
}
func transferDecodedReader(header mail.Header, r io.Reader) io.Reader {
switch strings.ToLower(strings.TrimSpace(header.Get("Content-Transfer-Encoding"))) {
case "base64":
return base64.NewDecoder(base64.StdEncoding, r)
case "quoted-printable":
return quotedprintable.NewReader(r)
default:
return r
}
}
func readText(r io.Reader, label string) (string, error) {
if label != "" {
cr, err := charset.NewReaderLabel(label, r)
if err == nil {
r = cr
}
}
data, err := io.ReadAll(r)
if err != nil {
return "", err
}
return string(data), nil
}
func partFilename(header mail.Header) string {
if disp := header.Get("Content-Disposition"); disp != "" {
_, params, err := mime.ParseMediaType(disp)
if err == nil && params["filename"] != "" {
return decodeHeader(params["filename"])
}
}
if ct := header.Get("Content-Type"); ct != "" {
_, params, err := mime.ParseMediaType(ct)
if err == nil && params["name"] != "" {
return decodeHeader(params["name"])
}
}
return ""
}
func appendText(dst *string, value string) {
if strings.TrimSpace(value) == "" {
return
}
if strings.TrimSpace(*dst) == "" {
*dst = value
return
}
*dst += "\n" + value
}
func sanitizeFilename(name string) string {
name = filepath.Base(strings.TrimSpace(name))
name = strings.ReplaceAll(name, "\x00", "")
name = strings.ReplaceAll(name, "/", "_")
name = strings.ReplaceAll(name, "\\", "_")
if name == "." || name == "" {
return "attachment.bin"
}
return name
}
func trimAngle(value string) string {
value = strings.TrimSpace(value)
value = strings.TrimPrefix(value, "<")
value = strings.TrimSuffix(value, ">")
return value
}
func ParseTimeRFC3339(value string) *time.Time {
if value == "" {
return nil
}
t, err := time.Parse(time.RFC3339Nano, value)
if err != nil {
return nil
}
return &t
}

View File

@@ -0,0 +1,63 @@
package parser
import (
"strings"
"testing"
)
func TestParseMultipartEmailWithAttachment(t *testing.T) {
raw := strings.Join([]string{
"Message-ID: <m1@example.com>",
"Subject: =?UTF-8?B?5rWL6K+V6YKu5Lu2?=",
"From: =?UTF-8?B?5byg5LiJ?= <sender@example.com>",
"To: receiver@example.com",
"Cc: cc@example.com",
"Date: Tue, 04 Jun 2024 12:00:00 +0800",
"MIME-Version: 1.0",
"Content-Type: multipart/mixed; boundary=mix",
"",
"--mix",
"Content-Type: multipart/alternative; boundary=alt",
"",
"--alt",
"Content-Type: text/plain; charset=utf-8",
"",
"plain body",
"--alt",
"Content-Type: text/html; charset=utf-8",
"",
"<p>html body</p>",
"--alt--",
"--mix",
"Content-Type: text/plain; name=\"note.txt\"",
"Content-Disposition: attachment; filename=\"note.txt\"",
"Content-Transfer-Encoding: base64",
"",
"YXR0YWNobWVudA==",
"--mix--",
"",
}, "\r\n")
msg, err := Parse([]byte(raw))
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
if msg.Subject != "测试邮件" {
t.Fatalf("Subject = %q", msg.Subject)
}
if msg.SenderName != "张三" || msg.SenderEmail != "sender@example.com" {
t.Fatalf("sender = %q <%s>", msg.SenderName, msg.SenderEmail)
}
if strings.TrimSpace(msg.BodyText) != "plain body" {
t.Fatalf("BodyText = %q", msg.BodyText)
}
if !strings.Contains(msg.BodyHTML, "html body") {
t.Fatalf("BodyHTML = %q", msg.BodyHTML)
}
if len(msg.Attachments) != 1 {
t.Fatalf("attachments = %d", len(msg.Attachments))
}
if msg.Attachments[0].Filename != "note.txt" || string(msg.Attachments[0].Data) != "attachment" {
t.Fatalf("attachment = %#v", msg.Attachments[0])
}
}