b3669f189e
- 为文件夹和消息增加 `UID/ModSeq` 字段,并在迁移时回填与建索引。 - 让收发、移动、标记、删除、草稿等流程同步维护 `imap_uid` 和 `imap_modseq`。 - 扩展后端与前端类型,并补充相关测试覆盖。
851 lines
26 KiB
Go
851 lines
26 KiB
Go
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"
|
|
"strings"
|
|
"time"
|
|
|
|
"golang.org/x/text/encoding"
|
|
"golang.org/x/text/encoding/ianaindex"
|
|
)
|
|
|
|
type maildirMailbox struct {
|
|
ID string
|
|
Address string
|
|
LocalPart string
|
|
Domain string
|
|
Unregistered bool
|
|
RecipientDomain string
|
|
}
|
|
|
|
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.cfg.MaildirScanSeconds) * time.Second
|
|
if interval <= 0 {
|
|
interval = 30 * time.Second
|
|
}
|
|
nextRunAt := a.now().UTC()
|
|
a.maildirHealth.markWorkerStarted(&nextRunAt)
|
|
a.log.Info("maildir sync worker started", "root", a.cfg.MaildirRoot, "interval", interval.String())
|
|
if counts, err := a.syncMaildirOnceTracked(ctx, interval); err != nil {
|
|
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)
|
|
}
|
|
ticker := time.NewTicker(interval)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
a.maildirHealth.markWorkerStopped()
|
|
a.log.Info("maildir sync worker stopped")
|
|
return
|
|
case <-ticker.C:
|
|
counts, err := a.syncMaildirOnceTracked(ctx, interval)
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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.cfg.MaildirRoot)
|
|
if root == "" {
|
|
return maildirSyncCounts{}, nil
|
|
}
|
|
mailboxes, err := a.maildirMailboxes(ctx)
|
|
if err != nil {
|
|
return maildirSyncCounts{}, err
|
|
}
|
|
counts := maildirSyncCounts{}
|
|
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
|
|
}
|
|
folders, err := a.maildirFolders(ctx, mb.ID)
|
|
if err != nil {
|
|
return counts, err
|
|
}
|
|
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()
|
|
default:
|
|
}
|
|
dir := filepath.Join(folderBase, 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.syncMaildirFile(ctx, mb, folder, path)
|
|
if err != nil {
|
|
counts.FileErrors++
|
|
counts.fileErrorDetails = append(counts.fileErrorDetails, fmt.Sprintf("%s: %v", path, err))
|
|
a.log.Warn("maildir file import failed", "path", path, "error", err)
|
|
continue
|
|
}
|
|
if ok {
|
|
counts.Imported++
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
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
|
|
}
|
|
|
|
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.cfg.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) syncUnregisteredMaildir(ctx context.Context, mb maildirMailbox) (int, error) {
|
|
counts, err := a.syncUnregisteredMaildirDetailed(ctx, mb)
|
|
return counts.Imported, err
|
|
}
|
|
|
|
func (a *App) syncUnregisteredMaildirDetailed(ctx context.Context, mb maildirMailbox) (maildirSyncCounts, error) {
|
|
base := filepath.Join(strings.TrimSpace(a.cfg.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
|
|
}
|
|
_, err = a.insertMessage(ctx, msg, attachments)
|
|
return err == nil, err
|
|
}
|
|
|
|
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
|
|
msg.IsRead, msg.IsStarred = maildirFlagsFromPath(path, folder.Name)
|
|
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 {
|
|
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)
|
|
}
|
|
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) attachMaildirRawPathToExisting(ctx context.Context, mailboxID, folderID, rawPath, messageID 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=? AND folder_id=? AND message_id=? AND message_id <> '' AND raw_path=''`,
|
|
rawPath, a.now().UTC().Format(time.RFC3339Nano), mailboxID, folderID, messageID); err != nil {
|
|
a.log.Warn("failed to attach maildir raw path to existing message", "path", rawPath, "error", err)
|
|
}
|
|
}
|
|
|
|
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.cfg.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)
|
|
for _, address := range append(append([]string{}, msg.To...), msg.CC...) {
|
|
address = normalizeEmail(address)
|
|
if strings.HasSuffix(address, "@"+domain) {
|
|
return address
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
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
|
|
}
|
|
subject := decodeMIMEHeader(m.Header.Get("Subject"))
|
|
if strings.TrimSpace(subject) == "" {
|
|
subject = "(no subject)"
|
|
}
|
|
from, fromName := firstAddressParts(m.Header.Get("From"))
|
|
to := addressList(m.Header.Get("To"))
|
|
cc := addressList(m.Header.Get("Cc"))
|
|
if len(to) == 0 {
|
|
to = []string{fallbackTo}
|
|
}
|
|
sentAt := parseMailDate(m.Header.Get("Date"))
|
|
parsed := &parsedMail{}
|
|
if err := parseMailPart(textproto.MIMEHeader(m.Header), m.Body, parsed); err != nil {
|
|
return storedMessage{}, nil, err
|
|
}
|
|
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")),
|
|
Subject: subject,
|
|
From: from,
|
|
FromName: fromName,
|
|
To: to,
|
|
CC: cc,
|
|
SentAt: sentAt,
|
|
ReceivedAt: receivedAt,
|
|
Snippet: snippetFrom(bodyText, bodyHTML),
|
|
BodyText: bodyText,
|
|
BodyHTML: bodyHTML,
|
|
IsRead: false,
|
|
}, parsed.Attachments, nil
|
|
}
|
|
|
|
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
|
|
}
|
|
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 != "" {
|
|
return filepath.Base(decodeMIMEHeader(name))
|
|
}
|
|
}
|
|
if _, params, err := mime.ParseMediaType(header.Get("Content-Type")); err == nil {
|
|
if name := strings.TrimSpace(params["name"]); name != "" {
|
|
return filepath.Base(decodeMIMEHeader(name))
|
|
}
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
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))
|
|
}
|
|
|
|
// decodeMIMEHeader decodes all RFC 2047 encoded words (=?charset?encoding?data?=)
|
|
// in the given header value. Falls back to the original value on any error.
|
|
// 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")
|
|
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+(=\?)`)
|
|
|
|
// 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 {
|
|
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
|
|
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, ""
|
|
}
|
|
|
|
func addressList(value string) []string {
|
|
items, err := netmail.ParseAddressList(value)
|
|
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
|
|
}
|
|
}
|
|
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{}
|
|
}
|