Files
NewSzxcn-Email/apps/api/internal/app/maildir_sync.go
T

883 lines
27 KiB
Go
Raw Normal View History

2026-06-14 01:07:48 +08:00
package app
import (
"bytes"
"context"
"database/sql"
"encoding/base64"
"errors"
"fmt"
"io"
"mime"
"mime/multipart"
"mime/quotedprintable"
netmail "net/mail"
"net/textproto"
"os"
"path/filepath"
"regexp"
2026-06-14 01:07:48 +08:00
"strings"
"time"
2026-06-19 22:15:00 +08:00
"golang.org/x/text/encoding"
"golang.org/x/text/encoding/ianaindex"
2026-06-14 01:07:48 +08:00
)
type maildirMailbox struct {
ID string
Address string
LocalPart string
Domain string
Unregistered bool
RecipientDomain string
2026-06-14 01:07:48 +08:00
}
type maildirFolder struct {
ID string
Name string
Role string
}
type parsedMail struct {
Text string
HTML string
Attachments []AttachmentInput
}
func (a *App) maildirWorker(ctx context.Context) {
interval := time.Duration(a.config().MaildirScanSeconds) * time.Second
2026-06-14 01:07:48 +08:00
if interval <= 0 {
interval = 30 * time.Second
}
nextRunAt := a.now().UTC()
a.maildirHealth.markWorkerStarted(&nextRunAt)
a.log.Info("maildir sync worker started", "root", a.config().MaildirRoot, "interval", interval.String())
if counts, err := a.syncMaildirOnceTracked(ctx, interval); err != nil {
2026-06-14 01:07:48 +08:00
a.log.Warn("initial maildir sync failed", "error", err)
} else if n := counts.total(); n > 0 {
a.log.Info("initial maildir sync processed messages", "count", n)
2026-06-14 01:07:48 +08:00
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
a.maildirHealth.markWorkerStopped()
2026-06-14 01:07:48 +08:00
a.log.Info("maildir sync worker stopped")
return
case <-ticker.C:
counts, err := a.syncMaildirOnceTracked(ctx, interval)
2026-06-14 01:07:48 +08:00
if err != nil {
a.log.Warn("maildir sync failed", "error", err)
continue
}
if n := counts.total(); n > 0 {
a.log.Info("maildir sync processed messages", "count", n)
2026-06-14 01:07:48 +08:00
}
}
}
}
func (a *App) syncMaildirOnceTracked(ctx context.Context, interval time.Duration) (maildirSyncCounts, error) {
startedAt := a.now().UTC()
a.maildirHealth.markRunStarted(startedAt)
counts, err := a.syncMaildirOnceDetailed(ctx)
finishedAt := a.now().UTC()
var nextRunAt *time.Time
if interval > 0 && err == nil {
next := finishedAt.Add(interval)
nextRunAt = &next
}
a.maildirHealth.markRunFinished(finishedAt, counts, err, nextRunAt)
return counts, err
}
2026-06-14 01:07:48 +08:00
func (a *App) syncMaildirOnce(ctx context.Context) (int, error) {
counts, err := a.syncMaildirOnceDetailed(ctx)
return counts.total(), err
}
func (a *App) syncMaildirOnceDetailed(ctx context.Context) (maildirSyncCounts, error) {
root := strings.TrimSpace(a.config().MaildirRoot)
2026-06-14 01:07:48 +08:00
if root == "" {
return maildirSyncCounts{}, nil
2026-06-14 01:07:48 +08:00
}
mailboxes, err := a.maildirMailboxes(ctx)
if err != nil {
return maildirSyncCounts{}, err
2026-06-14 01:07:48 +08:00
}
counts := maildirSyncCounts{}
2026-06-14 01:07:48 +08:00
for _, mb := range mailboxes {
if mb.Unregistered {
mbCounts, err := a.syncUnregisteredMaildirDetailed(ctx, mb)
counts.FilesScanned += mbCounts.FilesScanned
counts.Imported += mbCounts.Imported
counts.FileErrors += mbCounts.FileErrors
counts.fileErrorDetails = append(counts.fileErrorDetails, mbCounts.fileErrorDetails...)
if err != nil {
return counts, err
}
continue
}
2026-06-14 01:07:48 +08:00
folders, err := a.maildirFolders(ctx, mb.ID)
if err != nil {
return counts, err
2026-06-14 01:07:48 +08:00
}
base := filepath.Join(root, mb.Domain, mb.LocalPart, "Maildir")
for _, folder := range folders {
folderBase := maildirFolderPath(base, folder.Name)
for _, sub := range []string{"new", "cur"} {
select {
case <-ctx.Done():
return counts, ctx.Err()
2026-06-14 01:07:48 +08:00
default:
}
dir := filepath.Join(folderBase, sub)
entries, err := os.ReadDir(dir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
continue
}
return counts, err
2026-06-14 01:07:48 +08:00
}
for _, entry := range entries {
if entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
continue
}
path := filepath.Join(dir, entry.Name())
counts.FilesScanned++
2026-06-14 01:07:48 +08:00
ok, err := a.syncMaildirFile(ctx, mb, folder, path)
if err != nil {
counts.FileErrors++
counts.fileErrorDetails = append(counts.fileErrorDetails, fmt.Sprintf("%s: %v", path, err))
2026-06-14 01:07:48 +08:00
a.log.Warn("maildir file import failed", "path", path, "error", err)
continue
}
if ok {
counts.Imported++
2026-06-14 01:07:48 +08:00
}
}
}
}
}
backfilled, err := a.backfillSQLiteMessagesToMaildir(ctx)
if err != nil {
return counts, err
}
counts.Backfilled += backfilled
cleaned, err := a.cleanupMissingMaildirMessages(ctx)
if err != nil {
return counts, err
}
counts.Cleaned += cleaned
return counts, nil
2026-06-14 01:07:48 +08:00
}
func (a *App) maildirMailboxes(ctx context.Context) ([]maildirMailbox, error) {
rows, err := a.db.QueryContext(ctx, `SELECT m.id,m.address,m.local_part,d.name FROM mailboxes m JOIN domains d ON d.id=m.domain_id WHERE m.status='active' AND d.status='active' ORDER BY m.address`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []maildirMailbox
for rows.Next() {
var mb maildirMailbox
if err := rows.Scan(&mb.ID, &mb.Address, &mb.LocalPart, &mb.Domain); err != nil {
return nil, err
}
out = append(out, mb)
}
if err := rows.Err(); err != nil {
return nil, err
}
if a.config().CatchAllEnabled {
domainRows, err := a.db.QueryContext(ctx, `SELECT name FROM domains WHERE status='active' ORDER BY name`)
if err != nil {
return nil, err
}
defer domainRows.Close()
for domainRows.Next() {
var domain string
if err := domainRows.Scan(&domain); err != nil {
return nil, err
}
out = append(out, maildirMailbox{
Address: "__unregistered__@" + domain,
LocalPart: "__unregistered__",
Domain: domain,
Unregistered: true,
RecipientDomain: domain,
})
}
if err := domainRows.Err(); err != nil {
return nil, err
}
}
return out, nil
}
func (a *App) syncUnregisteredMaildirDetailed(ctx context.Context, mb maildirMailbox) (maildirSyncCounts, error) {
base := filepath.Join(strings.TrimSpace(a.config().MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
counts := maildirSyncCounts{}
for _, sub := range []string{"new", "cur"} {
select {
case <-ctx.Done():
return counts, ctx.Err()
default:
}
dir := filepath.Join(base, sub)
entries, err := os.ReadDir(dir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
continue
}
return counts, err
}
for _, entry := range entries {
if entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
continue
}
path := filepath.Join(dir, entry.Name())
counts.FilesScanned++
ok, err := a.syncUnregisteredMaildirFile(ctx, mb, path)
if err != nil {
counts.FileErrors++
counts.fileErrorDetails = append(counts.fileErrorDetails, fmt.Sprintf("%s: %v", path, err))
a.log.Warn("unregistered maildir file import failed", "path", path, "error", err)
continue
}
if ok {
counts.Imported++
}
}
}
return counts, nil
}
func (a *App) syncUnregisteredMaildirFile(ctx context.Context, mb maildirMailbox, path string) (bool, error) {
raw, err := os.ReadFile(path)
if err != nil {
return false, err
}
msg, attachments, err := a.parseMaildirMessage(raw, mb.Address)
if err != nil {
return false, err
}
recipient := unregisteredRecipientFromMessage(msg, mb.RecipientDomain)
if recipient == "" {
recipient = mb.Address
}
msg.MailboxID = ""
msg.FolderID = ""
msg.RecipientAddr = recipient
msg.RawPath = path
if msg.MessageUID == "" {
msg.MessageUID = newID("uid")
}
if msg.MessageID == "" {
msg.MessageID = fmt.Sprintf("<%s@lanqin.local>", newID("msg"))
}
if msg.ReceivedAt.IsZero() {
msg.ReceivedAt = a.now().UTC()
}
if msg.SentAt.IsZero() {
msg.SentAt = msg.ReceivedAt
}
if msg.Snippet == "" {
msg.Snippet = snippetFrom(msg.BodyText, msg.BodyHTML)
}
if exists, err := a.unregisteredMaildirMessageExists(ctx, path, msg.MessageID, msg.RecipientAddr); err != nil {
return false, err
} else if exists {
a.attachUnregisteredMaildirRawPathToExisting(ctx, path, msg.MessageID, msg.RecipientAddr)
return false, nil
}
id, err := a.insertMessage(ctx, msg, attachments)
if err == nil {
a.enqueueTelegramMailNotification(ctx, id, msg, attachments)
}
return err == nil, err
2026-06-14 01:07:48 +08:00
}
func (a *App) maildirFolders(ctx context.Context, mailboxID string) ([]maildirFolder, error) {
rows, err := a.db.QueryContext(ctx, `SELECT id,name,role FROM folders WHERE mailbox_id=?`, mailboxID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []maildirFolder
for rows.Next() {
var f maildirFolder
if err := rows.Scan(&f.ID, &f.Name, &f.Role); err != nil {
return nil, err
}
out = append(out, f)
}
return out, rows.Err()
}
func maildirFolderPath(base, folder string) string {
if strings.EqualFold(folder, "Inbox") {
return base
}
folder = strings.TrimSpace(folder)
folder = strings.TrimPrefix(folder, ".")
return filepath.Join(base, "."+folder)
}
func (a *App) syncMaildirFile(ctx context.Context, mb maildirMailbox, folder maildirFolder, path string) (bool, error) {
raw, err := os.ReadFile(path)
if err != nil {
return false, err
}
msg, attachments, err := a.parseMaildirMessage(raw, mb.Address)
if err != nil {
return false, err
}
msg.MailboxID = mb.ID
msg.FolderID = folder.ID
2026-08-06 17:13:08 +08:00
if strings.TrimSpace(msg.RecipientAddr) == "" {
msg.RecipientAddr = mb.Address
}
msg.IsRead, msg.IsStarred = maildirFlagsFromPath(path, folder.Name)
2026-06-14 01:07:48 +08:00
msg.RawPath = path
if msg.MessageUID == "" {
msg.MessageUID = newID("uid")
}
if msg.MessageID == "" {
msg.MessageID = fmt.Sprintf("<%s@lanqin.local>", newID("msg"))
}
if msg.ReceivedAt.IsZero() {
msg.ReceivedAt = a.now().UTC()
}
if msg.SentAt.IsZero() {
msg.SentAt = msg.ReceivedAt
}
if msg.Snippet == "" {
msg.Snippet = snippetFrom(msg.BodyText, msg.BodyHTML)
}
if exists, err := a.maildirMessageExists(ctx, mb.ID, folder.ID, path, msg.MessageID); err != nil {
return false, err
} else if exists {
if _, err := a.syncExistingMaildirMessageState(ctx, mb.ID, folder.ID, path, msg.MessageID, msg.IsRead, msg.IsStarred); err != nil {
return false, err
}
return false, nil
}
if handled, err := a.syncExistingMaildirMessageState(ctx, mb.ID, folder.ID, path, msg.MessageID, msg.IsRead, msg.IsStarred); err != nil {
return false, err
} else if handled {
2026-06-14 01:07:48 +08:00
return false, nil
}
id, err := a.insertMessage(ctx, msg, attachments)
if err == nil && strings.EqualFold(folder.Name, "Inbox") {
a.applyInboundControls(ctx, id, mb.ID, msg.From, msg.Subject)
2026-08-02 07:37:07 +08:00
a.processInboundForwarding(ctx, id, mb.ID, raw)
2026-08-06 17:13:08 +08:00
if a.shouldNotifyTelegramMessage(ctx, id) {
a.enqueueTelegramMailNotification(ctx, id, msg, attachments)
}
2026-06-14 01:07:48 +08:00
}
return err == nil, err
}
func (a *App) maildirMessageExists(ctx context.Context, mailboxID, folderID, rawPath, messageID string) (bool, error) {
var count int
err := a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM messages WHERE mailbox_id=? AND (raw_path=? OR (folder_id=? AND message_id=? AND message_id <> ''))`, mailboxID, rawPath, folderID, messageID).Scan(&count)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return false, err
}
return count > 0, nil
}
func (a *App) unregisteredMaildirMessageExists(ctx context.Context, rawPath, messageID, recipient string) (bool, error) {
var count int
err := a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM messages WHERE mailbox_id IS NULL AND (raw_path=? OR (recipient_addr=? AND message_id=? AND message_id <> ''))`, rawPath, recipient, messageID).Scan(&count)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return false, err
}
return count > 0, nil
}
func (a *App) syncExistingMaildirMessageState(ctx context.Context, mailboxID, folderID, rawPath, messageID string, read, starred bool) (bool, error) {
now := a.now().UTC().Format(time.RFC3339Nano)
var samePathID, oldFolderID string
var oldRead, oldStarred int
var oldModSeq int64
err := a.db.QueryRowContext(ctx, `SELECT id,COALESCE(folder_id,''),is_read,is_starred,imap_modseq FROM messages WHERE mailbox_id=? AND raw_path=?`, mailboxID, rawPath).Scan(&samePathID, &oldFolderID, &oldRead, &oldStarred, &oldModSeq)
if err == nil {
if oldFolderID != folderID {
if oldFolderID != "" {
if _, err := a.bumpFolderModSeq(ctx, oldFolderID); err != nil {
return false, err
}
}
meta, err := a.nextIMAPMetadata(ctx, a.db, folderID)
if err != nil {
return false, err
}
_, err = a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?,raw_path=?,is_read=?,is_starred=?,imap_uid=?,imap_modseq=?,updated_at=? WHERE id=?`,
folderID, rawPath, boolInt(read), boolInt(starred), meta.UID, meta.ModSeq, now, samePathID)
return err == nil, err
}
modSeq := oldModSeq
if oldRead != boolInt(read) || oldStarred != boolInt(starred) {
modSeq, err = a.bumpFolderModSeq(ctx, folderID)
if err != nil {
return false, err
}
}
_, err = a.db.ExecContext(ctx, `UPDATE messages SET raw_path=?,is_read=?,is_starred=?,imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END,updated_at=? WHERE id=?`,
rawPath, boolInt(read), boolInt(starred), modSeq, modSeq, now, samePathID)
return err == nil, err
}
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return false, err
}
if strings.TrimSpace(messageID) == "" {
return false, nil
}
type candidate struct {
ID string
RawPath string
}
rows, err := a.db.QueryContext(ctx, `SELECT id,raw_path FROM messages WHERE mailbox_id=? AND message_id=? AND message_id <> '' ORDER BY CASE WHEN folder_id=? THEN 0 ELSE 1 END, created_at`, mailboxID, messageID, folderID)
if err != nil {
return false, err
}
var chosen candidate
for rows.Next() {
var c candidate
if err := rows.Scan(&c.ID, &c.RawPath); err != nil {
rows.Close()
return false, err
}
if c.RawPath == "" || c.RawPath == rawPath {
chosen = c
break
}
ok, err := a.pathIsUnderMaildirRoot(c.RawPath)
if err != nil {
rows.Close()
return false, err
}
if ok {
if _, err := os.Stat(c.RawPath); errors.Is(err, os.ErrNotExist) {
chosen = c
break
} else if err != nil {
rows.Close()
return false, err
}
}
}
if err := rows.Err(); err != nil {
rows.Close()
return false, err
}
if err := rows.Close(); err != nil {
return false, err
}
if chosen.ID == "" {
a.removeDuplicateMaildirMessage(ctx, rawPath, mailboxID, folderID, messageID)
return false, nil
}
var previousFolderID string
if err := a.db.QueryRowContext(ctx, `SELECT COALESCE(folder_id,'') FROM messages WHERE id=?`, chosen.ID).Scan(&previousFolderID); err != nil {
return false, err
}
if previousFolderID != "" && previousFolderID != folderID {
if _, err := a.bumpFolderModSeq(ctx, previousFolderID); err != nil {
return false, err
}
}
meta, err := a.nextIMAPMetadata(ctx, a.db, folderID)
if err != nil {
return false, err
}
_, err = a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?,raw_path=?,is_read=?,is_starred=?,imap_uid=?,imap_modseq=?,updated_at=? WHERE id=?`, folderID, rawPath, boolInt(read), boolInt(starred), meta.UID, meta.ModSeq, now, chosen.ID)
return err == nil, err
}
func (a *App) removeDuplicateMaildirMessage(ctx context.Context, rawPath, mailboxID, folderID, messageID string) {
var existing string
err := a.db.QueryRowContext(ctx, `SELECT raw_path FROM messages WHERE mailbox_id=? AND folder_id=? AND message_id=? AND message_id <> '' AND raw_path<>'' LIMIT 1`, mailboxID, folderID, messageID).Scan(&existing)
if err != nil || existing == "" || existing == rawPath {
return
}
a.removeMaildirPath(ctx, rawPath)
}
func (a *App) cleanupMissingMaildirMessages(ctx context.Context) (int, error) {
if strings.TrimSpace(a.config().MaildirRoot) == "" {
return 0, nil
}
cutoff := a.now().UTC().Add(-5 * time.Minute).Format(time.RFC3339Nano)
rows, err := a.db.QueryContext(ctx, `SELECT id,raw_path FROM messages WHERE COALESCE(mailbox_id,'')<>'' AND raw_path<>'' AND updated_at<?`, cutoff)
if err != nil {
return 0, err
}
type item struct {
ID string
RawPath string
}
var missing []item
for rows.Next() {
var it item
if err := rows.Scan(&it.ID, &it.RawPath); err != nil {
rows.Close()
return 0, err
}
ok, err := a.pathIsUnderMaildirRoot(it.RawPath)
if err != nil {
rows.Close()
return 0, err
}
if !ok {
continue
}
if _, err := os.Stat(it.RawPath); errors.Is(err, os.ErrNotExist) {
missing = append(missing, it)
} else if err != nil {
rows.Close()
return 0, err
}
}
if err := rows.Err(); err != nil {
rows.Close()
return 0, err
}
if err := rows.Close(); err != nil {
return 0, err
}
for _, it := range missing {
a.deleteMessageFiles(ctx, it.ID)
var folderID sql.NullString
_ = a.db.QueryRowContext(ctx, `SELECT folder_id FROM messages WHERE id=?`, it.ID).Scan(&folderID)
if _, err := a.db.ExecContext(ctx, `DELETE FROM messages WHERE id=?`, it.ID); err != nil {
return 0, err
}
if folderID.Valid && folderID.String != "" {
if _, err := a.bumpFolderModSeq(ctx, folderID.String); err != nil {
return 0, err
}
}
}
return len(missing), nil
}
func maildirFlagsFromPath(path, folderName string) (bool, bool) {
base := filepath.Base(path)
flags := ""
hasFlags := false
for _, sep := range []string{maildirFlagSeparator(), ":2,", "!2,"} {
if idx := strings.LastIndex(base, sep); idx >= 0 {
flags = base[idx+len(sep):]
hasFlags = true
break
}
}
if hasFlags {
return strings.ContainsRune(flags, 'S'), strings.ContainsRune(flags, 'F')
}
return !strings.EqualFold(folderName, "Inbox"), false
}
func (a *App) attachUnregisteredMaildirRawPathToExisting(ctx context.Context, rawPath, messageID, recipient string) {
if strings.TrimSpace(messageID) == "" || strings.TrimSpace(rawPath) == "" {
return
}
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET raw_path=?,updated_at=? WHERE mailbox_id IS NULL AND recipient_addr=? AND message_id=? AND message_id <> '' AND raw_path=''`,
rawPath, a.now().UTC().Format(time.RFC3339Nano), recipient, messageID); err != nil {
a.log.Warn("failed to attach unregistered maildir raw path to existing message", "path", rawPath, "error", err)
}
}
func unregisteredRecipientFromMessage(msg storedMessage, domain string) string {
domain = normalizeDomain(domain)
2026-08-06 17:13:08 +08:00
if address := normalizeEmail(msg.RecipientAddr); strings.HasSuffix(address, "@"+domain) {
return address
}
for _, address := range append(append([]string{}, msg.To...), msg.CC...) {
address = normalizeEmail(address)
if strings.HasSuffix(address, "@"+domain) {
return address
}
}
return ""
}
2026-06-14 01:07:48 +08:00
func (a *App) parseMaildirMessage(raw []byte, fallbackTo string) (storedMessage, []AttachmentInput, error) {
m, err := netmail.ReadMessage(bytes.NewReader(raw))
if err != nil {
return storedMessage{}, nil, err
}
2026-06-19 22:15:00 +08:00
subject := decodeMIMEHeader(m.Header.Get("Subject"))
2026-06-14 01:07:48 +08:00
if strings.TrimSpace(subject) == "" {
subject = "(no subject)"
}
from, fromName := firstAddressParts(m.Header.Get("From"))
2026-06-14 01:07:48 +08:00
to := addressList(m.Header.Get("To"))
cc := addressList(m.Header.Get("Cc"))
if len(to) == 0 {
to = []string{fallbackTo}
}
2026-08-06 17:13:08 +08:00
recipientAddr := originalMailRecipient(m.Header)
2026-06-14 01:07:48 +08:00
sentAt := parseMailDate(m.Header.Get("Date"))
parsed := &parsedMail{}
if err := parseMailPart(textproto.MIMEHeader(m.Header), m.Body, parsed); err != nil {
return storedMessage{}, nil, err
}
2026-08-06 17:13:08 +08:00
if looksLikeHTMLDocument(parsed.Text) {
if strings.TrimSpace(parsed.HTML) == "" {
parsed.HTML = parsed.Text
}
parsed.Text = telegramHTMLToText(parsed.Text)
}
2026-06-14 01:07:48 +08:00
bodyHTML := a.policy.Sanitize(parsed.HTML)
bodyText := parsed.Text
if strings.TrimSpace(bodyText) == "" {
bodyText = stripTags(bodyHTML)
}
if strings.TrimSpace(bodyHTML) == "" && strings.TrimSpace(bodyText) != "" {
bodyHTML = "<p>" + htmlEscape(bodyText) + "</p>"
}
receivedAt := a.now().UTC()
if !sentAt.IsZero() {
receivedAt = sentAt
}
return storedMessage{
MessageUID: newID("uid"),
MessageID: strings.TrimSpace(m.Header.Get("Message-Id")),
2026-08-06 17:13:08 +08:00
RecipientAddr: recipientAddr,
Subject: subject,
From: from,
FromName: fromName,
To: to,
CC: cc,
SentAt: sentAt,
ReceivedAt: receivedAt,
Snippet: snippetFrom(bodyText, bodyHTML),
BodyText: bodyText,
BodyHTML: bodyHTML,
IsRead: false,
Authentication: parseMailAuthentication(textproto.MIMEHeader(m.Header)),
2026-06-14 01:07:48 +08:00
}, parsed.Attachments, nil
}
2026-08-06 17:13:08 +08:00
func originalMailRecipient(header netmail.Header) string {
for _, key := range []string{"X-Original-To", "Delivered-To", "Envelope-To", "Original-Recipient"} {
value := strings.TrimSpace(header.Get(key))
if key == "Original-Recipient" {
if _, suffix, ok := strings.Cut(value, ";"); ok {
value = strings.TrimSpace(suffix)
}
}
if address, _ := firstAddressParts(value); strings.Contains(address, "@") {
return address
}
}
return ""
}
2026-06-14 01:07:48 +08:00
func parseMailPart(header textproto.MIMEHeader, body io.Reader, parsed *parsedMail) error {
contentType := header.Get("Content-Type")
mediaType, params, err := mime.ParseMediaType(contentType)
if err != nil || mediaType == "" {
mediaType = "text/plain"
}
if strings.HasPrefix(strings.ToLower(mediaType), "multipart/") {
boundary := params["boundary"]
if boundary == "" {
return nil
}
mr := multipart.NewReader(body, boundary)
for {
part, err := mr.NextPart()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return err
}
if err := parseMailPart(part.Header, part, parsed); err != nil {
return err
}
}
return nil
}
decoded, err := io.ReadAll(transferReader(header.Get("Content-Transfer-Encoding"), body))
if err != nil {
return err
}
filename := partFilename(header)
if filename != "" || (!strings.HasPrefix(strings.ToLower(mediaType), "text/") && len(decoded) > 0) {
if filename == "" {
filename = "attachment.bin"
}
parsed.Attachments = append(parsed.Attachments, AttachmentInput{Filename: filename, ContentType: mediaType, ContentBase64: base64.StdEncoding.EncodeToString(decoded)})
return nil
}
2026-08-06 17:13:08 +08:00
if strings.HasPrefix(strings.ToLower(mediaType), "text/") {
if charset := strings.TrimSpace(params["charset"]); charset != "" && !strings.EqualFold(charset, "utf-8") && !strings.EqualFold(charset, "us-ascii") {
if reader, decodeErr := charsetReader(charset, bytes.NewReader(decoded)); decodeErr == nil {
if converted, readErr := io.ReadAll(reader); readErr == nil {
decoded = converted
}
}
}
decoded = []byte(strings.ToValidUTF8(string(decoded), ""))
}
2026-06-14 01:07:48 +08:00
switch strings.ToLower(mediaType) {
case "text/html":
if parsed.HTML == "" {
parsed.HTML = string(decoded)
}
case "text/plain":
if parsed.Text == "" {
parsed.Text = string(decoded)
}
default:
// Ignore unsupported inline parts for now.
}
return nil
}
func transferReader(encoding string, r io.Reader) io.Reader {
switch strings.ToLower(strings.TrimSpace(encoding)) {
case "base64":
return base64.NewDecoder(base64.StdEncoding, r)
case "quoted-printable":
return quotedprintable.NewReader(r)
default:
return r
}
}
func partFilename(header textproto.MIMEHeader) string {
if _, params, err := mime.ParseMediaType(header.Get("Content-Disposition")); err == nil {
if name := strings.TrimSpace(params["filename"]); name != "" {
2026-06-19 22:15:00 +08:00
return filepath.Base(decodeMIMEHeader(name))
2026-06-14 01:07:48 +08:00
}
}
if _, params, err := mime.ParseMediaType(header.Get("Content-Type")); err == nil {
if name := strings.TrimSpace(params["name"]); name != "" {
2026-06-19 22:15:00 +08:00
return filepath.Base(decodeMIMEHeader(name))
2026-06-14 01:07:48 +08:00
}
}
return ""
}
func firstAddressParts(value string) (string, string) {
items, err := netmail.ParseAddressList(value)
if err != nil || len(items) == 0 {
// ParseAddressList failed — attempt RFC 2047 decode on the raw header,
// then retry parsing. This handles non-standard From headers where
// encoded words (e.g. =?UTF-8?B?…?=) cause the initial parse to fail.
decoded := decodeMIMEHeader(value)
items, err = netmail.ParseAddressList(decoded)
if err != nil || len(items) == 0 {
// Still unparseable: return the decoded value and try to extract
// a display name from the decoded string.
email, name := splitNameAndEmail(decoded)
return normalizeEmail(email), strings.TrimSpace(name)
}
2026-06-14 01:07:48 +08:00
}
item := items[0]
// Decode item.Name individually so that non-UTF-8 charsets (e.g. GBK,
// Shift_JIS) are handled by our CharsetReader, while the address list
// structure is parsed from the raw header (avoiding commas/semicolons
// inside decoded display names breaking the parser).
return normalizeEmail(item.Address), strings.TrimSpace(decodeMIMEHeader(item.Name))
2026-06-14 01:07:48 +08:00
}
// decodeMIMEHeader decodes all RFC 2047 encoded words (=?charset?encoding?data?=)
// in the given header value. Falls back to the original value on any error.
2026-06-19 22:15:00 +08:00
// Supports non-UTF-8 charsets (e.g. GBK, GB2312, Shift_JIS) via x/text.
// Per RFC 2047 §6.2, linear whitespace between adjacent encoded words is
// stripped before decoding.
func decodeMIMEHeader(value string) string {
if !strings.Contains(value, "=?") {
return value
}
// RFC 2047 §6.2: ignore whitespace between adjacent encoded words.
collapsed := adjacentEncodedWordSpaceRe.ReplaceAllString(value, "$1$2")
2026-06-19 22:15:00 +08:00
decoder := &mime.WordDecoder{
CharsetReader: charsetReader,
}
decoded, err := decoder.DecodeHeader(collapsed)
if err != nil {
return value
}
return decoded
}
// adjacentEncodedWordSpaceRe matches whitespace between two adjacent RFC 2047
// encoded words. Per RFC 2047 §6.2, this whitespace must be ignored when
// displaying the header.
var adjacentEncodedWordSpaceRe = regexp.MustCompile(`(\?=)\s+(=\?)`)
2026-06-19 22:15:00 +08:00
// charsetReader converts a non-UTF-8 charset stream into UTF-8 using x/text encodings.
func charsetReader(charset string, input io.Reader) (io.Reader, error) {
charset = strings.ToLower(strings.TrimSpace(charset))
if charset == "utf-8" || charset == "us-ascii" {
return input, nil
}
enc, err := ianaindex.IANA.Encoding(charset)
if err != nil {
return nil, fmt.Errorf("unsupported charset %q: %w", charset, err)
}
if enc == nil || enc == encoding.Nop || enc == encoding.Replacement {
2026-06-19 22:15:00 +08:00
return nil, fmt.Errorf("unsupported charset %q", charset)
}
return enc.NewDecoder().Reader(input), nil
}
// splitNameAndEmail attempts to extract a display name and email address from
// a string like "Display Name <user@example.com>" or plain "user@example.com".
func splitNameAndEmail(value string) (string, string) {
value = strings.TrimSpace(value)
if value == "" {
return "", ""
}
// Try "Name <email>" pattern
2026-06-19 22:15:00 +08:00
if idx := strings.LastIndex(value, "<"); idx >= 0 {
email := strings.TrimSpace(strings.Trim(value[idx+1:], "> "))
name := strings.TrimSpace(strings.Trim(value[:idx], `" `))
if strings.Contains(email, "@") {
return email, name
}
}
// Plain email or unknown format
if strings.Contains(value, "@") {
return value, ""
}
return value, ""
}
2026-06-14 01:07:48 +08:00
func addressList(value string) []string {
items, err := netmail.ParseAddressList(value)
2026-06-19 22:15:00 +08:00
if err != nil || len(items) == 0 {
// ParseAddressList failed — attempt RFC 2047 decode, then retry.
decoded := decodeMIMEHeader(value)
items, err = netmail.ParseAddressList(decoded)
if err != nil || len(items) == 0 {
return nil
}
2026-06-14 01:07:48 +08:00
}
out := make([]string, 0, len(items))
for _, item := range items {
out = append(out, normalizeEmail(item.Address))
}
return out
}
func parseMailDate(value string) time.Time {
if strings.TrimSpace(value) == "" {
return time.Time{}
}
if t, err := netmail.ParseDate(value); err == nil {
return t.UTC()
}
return time.Time{}
}