632a8a4896
- 读取邮件时同步更新 maildir 的 `S` 标记,避免数据库已读状态与文件名不同步。 - 写入邮件到 `maildir` 时保留已有非托管标记,并确保已读/星标标记正确拼接。 - 补充测试,校验已发送邮件和草稿的原始路径包含已读标记。
536 lines
15 KiB
Go
536 lines
15 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
func (a *App) writeStoredMessageToMaildir(ctx context.Context, messageID string, msg storedMessage, attachments []AttachmentInput) error {
|
|
if strings.TrimSpace(a.cfg.MaildirRoot) == "" || strings.TrimSpace(msg.MailboxID) == "" || strings.TrimSpace(msg.FolderID) == "" {
|
|
return nil
|
|
}
|
|
raw, err := BuildMIME(MIMEMessage{
|
|
From: msg.From,
|
|
FromName: msg.FromName,
|
|
To: msg.To,
|
|
CC: msg.CC,
|
|
BCC: msg.BCC,
|
|
Subject: msg.Subject,
|
|
Text: msg.BodyText,
|
|
HTML: msg.BodyHTML,
|
|
MessageID: msg.MessageID,
|
|
Date: messageDate(msg),
|
|
Attachments: attachments,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return a.writeRawMessageToMaildir(ctx, messageID, raw, false)
|
|
}
|
|
|
|
func (a *App) rewriteMessageMaildir(ctx context.Context, messageID string) error {
|
|
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
|
|
return nil
|
|
}
|
|
msg, err := a.storedMessageByID(ctx, messageID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
attachments, err := a.attachmentInputsForMessage(ctx, messageID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
raw, err := BuildMIME(MIMEMessage{
|
|
From: msg.From,
|
|
FromName: msg.FromName,
|
|
To: msg.To,
|
|
CC: msg.CC,
|
|
BCC: msg.BCC,
|
|
Subject: msg.Subject,
|
|
Text: msg.BodyText,
|
|
HTML: msg.BodyHTML,
|
|
MessageID: msg.MessageID,
|
|
Date: messageDate(msg),
|
|
Attachments: attachments,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return a.writeRawMessageToMaildir(ctx, messageID, raw, true)
|
|
}
|
|
|
|
func (a *App) writeRawMessageToMaildir(ctx context.Context, messageID string, raw []byte, replace bool) error {
|
|
state, err := a.maildirMessageState(ctx, messageID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return a.writeRawMessageToMaildirFolder(ctx, messageID, state.FolderID, raw, replace, false)
|
|
}
|
|
|
|
func (a *App) writeRawMessageToMaildirFolder(ctx context.Context, messageID, folderID string, raw []byte, replace bool, updateFolder bool) error {
|
|
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
|
|
return nil
|
|
}
|
|
state, err := a.maildirMessageState(ctx, messageID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if folderID != "" {
|
|
state.FolderID = folderID
|
|
}
|
|
if state.MailboxID == "" || state.FolderID == "" {
|
|
return nil
|
|
}
|
|
if !replace && state.RawPath != "" {
|
|
if ok, err := a.pathIsUnderMaildirRoot(state.RawPath); err != nil {
|
|
return err
|
|
} else if ok {
|
|
if _, err := os.Stat(state.RawPath); err == nil {
|
|
return nil
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
mb, err := a.maildirMailboxByID(ctx, state.MailboxID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
folderName, err := a.folderNameByID(ctx, state.FolderID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
base := filepath.Join(strings.TrimSpace(a.cfg.MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
|
|
folderBase := maildirFolderPath(base, folderName)
|
|
subdir := "cur"
|
|
if strings.EqualFold(folderName, "Inbox") && !state.IsRead {
|
|
subdir = "new"
|
|
}
|
|
if err := ensureMaildirFolderDirs(folderBase); err != nil {
|
|
return err
|
|
}
|
|
filename := maildirFilename(messageID, state.MessageID)
|
|
tmpPath := filepath.Join(folderBase, "tmp", filename)
|
|
finalPath := filepath.Join(folderBase, subdir, filename)
|
|
finalPath = maildirPathWithFlags(finalPath, state.IsRead, state.IsStarred)
|
|
if err := os.WriteFile(tmpPath, raw, 0o600); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Rename(tmpPath, finalPath); err != nil {
|
|
_ = os.Remove(tmpPath)
|
|
return err
|
|
}
|
|
if replace || state.RawPath != "" {
|
|
a.removeMaildirPath(ctx, state.RawPath)
|
|
}
|
|
if updateFolder {
|
|
_, err = a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?,raw_path=?,updated_at=? WHERE id=?`, state.FolderID, finalPath, a.now().UTC().Format(time.RFC3339Nano), messageID)
|
|
} else {
|
|
_, err = a.db.ExecContext(ctx, `UPDATE messages SET raw_path=?, updated_at=? WHERE id=?`, finalPath, a.now().UTC().Format(time.RFC3339Nano), messageID)
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (a *App) moveMessageMaildir(ctx context.Context, messageID, targetFolderID string) error {
|
|
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
|
|
_, err := a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?, updated_at=? WHERE id=?`, targetFolderID, a.now().UTC().Format(time.RFC3339Nano), messageID)
|
|
return err
|
|
}
|
|
state, err := a.maildirMessageState(ctx, messageID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if state.MailboxID == "" {
|
|
return nil
|
|
}
|
|
state.FolderID = targetFolderID
|
|
if state.RawPath == "" {
|
|
return a.writeMessageToNewMaildirFolder(ctx, messageID, targetFolderID)
|
|
}
|
|
ok, err := a.pathIsUnderMaildirRoot(state.RawPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !ok {
|
|
return a.writeMessageToNewMaildirFolder(ctx, messageID, targetFolderID)
|
|
}
|
|
if _, err := os.Stat(state.RawPath); err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return a.writeMessageToNewMaildirFolder(ctx, messageID, targetFolderID)
|
|
}
|
|
return err
|
|
}
|
|
mb, err := a.maildirMailboxByID(ctx, state.MailboxID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
folderName, err := a.folderNameByID(ctx, targetFolderID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
base := filepath.Join(strings.TrimSpace(a.cfg.MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
|
|
folderBase := maildirFolderPath(base, folderName)
|
|
if err := ensureMaildirFolderDirs(folderBase); err != nil {
|
|
return err
|
|
}
|
|
subdir := "cur"
|
|
if strings.EqualFold(folderName, "Inbox") && !state.IsRead {
|
|
subdir = "new"
|
|
}
|
|
targetPath := filepath.Join(folderBase, subdir, filepath.Base(state.RawPath))
|
|
if filepath.Clean(targetPath) != filepath.Clean(state.RawPath) {
|
|
if err := os.Rename(state.RawPath, targetPath); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
_, err = a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?,raw_path=?,updated_at=? WHERE id=?`, targetFolderID, targetPath, a.now().UTC().Format(time.RFC3339Nano), messageID)
|
|
return err
|
|
}
|
|
|
|
func (a *App) writeMessageToNewMaildirFolder(ctx context.Context, messageID, folderID string) error {
|
|
msg, err := a.storedMessageByID(ctx, messageID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
msg.FolderID = folderID
|
|
attachments, err := a.attachmentInputsForMessage(ctx, messageID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
raw, err := BuildMIME(MIMEMessage{
|
|
From: msg.From,
|
|
FromName: msg.FromName,
|
|
To: msg.To,
|
|
CC: msg.CC,
|
|
BCC: msg.BCC,
|
|
Subject: msg.Subject,
|
|
Text: msg.BodyText,
|
|
HTML: msg.BodyHTML,
|
|
MessageID: msg.MessageID,
|
|
Date: messageDate(msg),
|
|
Attachments: attachments,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return a.writeRawMessageToMaildirFolder(ctx, messageID, folderID, raw, true, true)
|
|
}
|
|
|
|
func (a *App) deleteMessageMaildirFile(ctx context.Context, messageID string) {
|
|
var rawPath string
|
|
if err := a.db.QueryRowContext(ctx, `SELECT raw_path FROM messages WHERE id=?`, messageID).Scan(&rawPath); err != nil {
|
|
return
|
|
}
|
|
a.removeMaildirPath(ctx, rawPath)
|
|
}
|
|
|
|
func (a *App) updateMessageMaildirFlags(ctx context.Context, messageID string, read, starred *bool) error {
|
|
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
|
|
return nil
|
|
}
|
|
state, err := a.maildirMessageState(ctx, messageID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if state.RawPath == "" {
|
|
return nil
|
|
}
|
|
ok, err := a.pathIsUnderMaildirRoot(state.RawPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !ok {
|
|
return nil
|
|
}
|
|
if _, err := os.Stat(state.RawPath); err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
currentRead := state.IsRead
|
|
currentStarred := state.IsStarred
|
|
if read != nil {
|
|
currentRead = *read
|
|
}
|
|
if starred != nil {
|
|
currentStarred = *starred
|
|
}
|
|
targetPath := maildirPathWithFlags(state.RawPath, currentRead, currentStarred)
|
|
if filepath.Clean(targetPath) == filepath.Clean(state.RawPath) {
|
|
return nil
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Rename(state.RawPath, targetPath); err != nil {
|
|
return err
|
|
}
|
|
_, err = a.db.ExecContext(ctx, `UPDATE messages SET raw_path=?,updated_at=? WHERE id=?`, targetPath, a.now().UTC().Format(time.RFC3339Nano), messageID)
|
|
return err
|
|
}
|
|
|
|
func (a *App) removeMaildirPath(ctx context.Context, rawPath string) {
|
|
rawPath = strings.TrimSpace(rawPath)
|
|
if rawPath == "" {
|
|
return
|
|
}
|
|
ok, err := a.pathIsUnderMaildirRoot(rawPath)
|
|
if err != nil || !ok {
|
|
if err != nil {
|
|
a.log.Warn("failed to validate maildir path", "path", rawPath, "error", err)
|
|
}
|
|
return
|
|
}
|
|
if err := os.Remove(rawPath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
a.log.Warn("failed to remove maildir message", "path", rawPath, "error", err)
|
|
}
|
|
}
|
|
|
|
func (a *App) backfillSQLiteMessagesToMaildir(ctx context.Context) (int, error) {
|
|
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
|
|
return 0, nil
|
|
}
|
|
rows, err := a.db.QueryContext(ctx, `SELECT id FROM messages WHERE COALESCE(mailbox_id,'')<>'' AND COALESCE(folder_id,'')<>'' AND raw_path='' ORDER BY created_at LIMIT 100`)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
var ids []string
|
|
for rows.Next() {
|
|
select {
|
|
case <-ctx.Done():
|
|
rows.Close()
|
|
return 0, ctx.Err()
|
|
default:
|
|
}
|
|
var id string
|
|
if err := rows.Scan(&id); err != nil {
|
|
rows.Close()
|
|
return 0, err
|
|
}
|
|
ids = append(ids, id)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
rows.Close()
|
|
return 0, err
|
|
}
|
|
if err := rows.Close(); err != nil {
|
|
return 0, err
|
|
}
|
|
count := 0
|
|
for _, id := range ids {
|
|
if err := a.rewriteMessageMaildir(ctx, id); err != nil {
|
|
return count, err
|
|
}
|
|
count++
|
|
}
|
|
return count, nil
|
|
}
|
|
|
|
type maildirMessageState struct {
|
|
MailboxID string
|
|
FolderID string
|
|
MessageID string
|
|
RawPath string
|
|
IsRead bool
|
|
IsStarred bool
|
|
}
|
|
|
|
func (a *App) maildirMessageState(ctx context.Context, id string) (maildirMessageState, error) {
|
|
var state maildirMessageState
|
|
var mailboxID, folderID sql.NullString
|
|
var read, starred int
|
|
err := a.db.QueryRowContext(ctx, `SELECT mailbox_id,folder_id,message_id,raw_path,is_read,is_starred FROM messages WHERE id=?`, id).Scan(&mailboxID, &folderID, &state.MessageID, &state.RawPath, &read, &starred)
|
|
if err != nil {
|
|
return state, err
|
|
}
|
|
state.MailboxID = mailboxID.String
|
|
state.FolderID = folderID.String
|
|
state.IsRead = intBool(read)
|
|
state.IsStarred = intBool(starred)
|
|
return state, nil
|
|
}
|
|
|
|
func (a *App) storedMessageByID(ctx context.Context, id string) (storedMessage, error) {
|
|
row := a.db.QueryRowContext(ctx, `SELECT COALESCE(mailbox_id,''),COALESCE(folder_id,''),recipient_addr,message_uid,message_id,subject,from_addr,from_name,to_addrs,cc_addrs,bcc_addrs,sent_at,received_at,snippet,body_text,body_html,is_read,is_starred,raw_path FROM messages WHERE id=?`, id)
|
|
var msg storedMessage
|
|
var toJSON, ccJSON, bccJSON, sent, received string
|
|
var read, starred int
|
|
err := row.Scan(&msg.MailboxID, &msg.FolderID, &msg.RecipientAddr, &msg.MessageUID, &msg.MessageID, &msg.Subject, &msg.From, &msg.FromName, &toJSON, &ccJSON, &bccJSON, &sent, &received, &msg.Snippet, &msg.BodyText, &msg.BodyHTML, &read, &starred, &msg.RawPath)
|
|
if err != nil {
|
|
return msg, err
|
|
}
|
|
msg.To = jsonDecodeSlice(toJSON)
|
|
msg.CC = jsonDecodeSlice(ccJSON)
|
|
msg.BCC = jsonDecodeSlice(bccJSON)
|
|
msg.SentAt = parseTime(sent)
|
|
msg.ReceivedAt = parseTime(received)
|
|
msg.IsRead = intBool(read)
|
|
msg.IsStarred = intBool(starred)
|
|
return msg, nil
|
|
}
|
|
|
|
func (a *App) attachmentInputsForMessage(ctx context.Context, messageID string) ([]AttachmentInput, error) {
|
|
rows, err := a.db.QueryContext(ctx, `SELECT filename,content_type,storage_path FROM attachments WHERE message_id=? ORDER BY filename`, messageID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var out []AttachmentInput
|
|
for rows.Next() {
|
|
var filename, contentType, storagePath string
|
|
if err := rows.Scan(&filename, &contentType, &storagePath); err != nil {
|
|
return nil, err
|
|
}
|
|
data, err := os.ReadFile(storagePath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, AttachmentInput{Filename: filename, ContentType: contentType, ContentBase64: base64.StdEncoding.EncodeToString(data)})
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (a *App) maildirMailboxByID(ctx context.Context, mailboxID string) (maildirMailbox, error) {
|
|
var mb maildirMailbox
|
|
err := a.db.QueryRowContext(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.id=?`, mailboxID).Scan(&mb.ID, &mb.Address, &mb.LocalPart, &mb.Domain)
|
|
return mb, err
|
|
}
|
|
|
|
func (a *App) folderNameByID(ctx context.Context, folderID string) (string, error) {
|
|
var name string
|
|
err := a.db.QueryRowContext(ctx, `SELECT name FROM folders WHERE id=?`, folderID).Scan(&name)
|
|
return name, err
|
|
}
|
|
|
|
func (a *App) pathIsUnderMaildirRoot(path string) (bool, error) {
|
|
root := strings.TrimSpace(a.cfg.MaildirRoot)
|
|
if root == "" || strings.TrimSpace(path) == "" {
|
|
return false, nil
|
|
}
|
|
rootAbs, err := filepath.Abs(root)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
pathAbs, err := filepath.Abs(path)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
rel, err := filepath.Rel(rootAbs, pathAbs)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return rel != "." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && rel != "..", nil
|
|
}
|
|
|
|
func ensureMaildirFolderDirs(folderBase string) error {
|
|
for _, sub := range []string{"tmp", "new", "cur"} {
|
|
if err := os.MkdirAll(filepath.Join(folderBase, sub), 0o755); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func maildirFilename(messageID, headerMessageID string) string {
|
|
base := strings.TrimSpace(headerMessageID)
|
|
if base == "" {
|
|
base = messageID
|
|
}
|
|
return fmt.Sprintf("%d.%s.%s", time.Now().UnixNano(), safeMaildirName(messageID), safeMaildirName(base))
|
|
}
|
|
|
|
func safeMaildirName(value string) string {
|
|
value = strings.Trim(value, "<>")
|
|
var b strings.Builder
|
|
for _, r := range value {
|
|
switch {
|
|
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
|
|
b.WriteRune(r)
|
|
case r == '.', r == '_', r == '-', r == '@':
|
|
b.WriteRune(r)
|
|
default:
|
|
b.WriteByte('_')
|
|
}
|
|
}
|
|
out := strings.Trim(b.String(), "._-")
|
|
if out == "" {
|
|
out = "message"
|
|
}
|
|
if len(out) > 120 {
|
|
out = out[:120]
|
|
}
|
|
return out
|
|
}
|
|
|
|
func messageDate(msg storedMessage) time.Time {
|
|
if !msg.SentAt.IsZero() {
|
|
return msg.SentAt
|
|
}
|
|
if !msg.ReceivedAt.IsZero() {
|
|
return msg.ReceivedAt
|
|
}
|
|
return time.Now().UTC()
|
|
}
|
|
|
|
func maildirPathWithFlags(path string, read, starred bool) string {
|
|
dir := filepath.Dir(path)
|
|
name := filepath.Base(path)
|
|
if read || starred {
|
|
dir = filepath.Join(filepath.Dir(dir), "cur")
|
|
} else if filepath.Base(dir) == "cur" {
|
|
dir = filepath.Join(filepath.Dir(dir), "new")
|
|
}
|
|
base := name
|
|
sep := maildirFlagSeparator()
|
|
existingFlags := ""
|
|
if idx := strings.LastIndex(base, sep); idx >= 0 {
|
|
existingFlags = base[idx+len(sep):]
|
|
base = base[:idx]
|
|
}
|
|
flags := preserveMaildirFlags(existingFlags, "SF")
|
|
if read {
|
|
flags = appendMaildirFlag(flags, 'S')
|
|
}
|
|
if starred {
|
|
flags = appendMaildirFlag(flags, 'F')
|
|
}
|
|
if flags != "" {
|
|
base += sep + flags
|
|
}
|
|
return filepath.Join(dir, base)
|
|
}
|
|
|
|
func preserveMaildirFlags(flags, managed string) string {
|
|
var b strings.Builder
|
|
for _, flag := range flags {
|
|
if strings.ContainsRune(managed, flag) || strings.ContainsRune(b.String(), flag) {
|
|
continue
|
|
}
|
|
b.WriteRune(flag)
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func appendMaildirFlag(flags string, flag rune) string {
|
|
if strings.ContainsRune(flags, flag) {
|
|
return flags
|
|
}
|
|
return flags + string(flag)
|
|
}
|
|
|
|
func maildirFlagSeparator() string {
|
|
if runtime.GOOS == "windows" {
|
|
return "!2,"
|
|
}
|
|
return ":2,"
|
|
}
|