fix(maildir): 同步 IMAP 移动、标记与删除状态
- 让 maildir 同步在消息移动到其他文件夹时更新 `folder_id` 和 `raw_path`。 - 根据文件名中的 maildir 标记同步已读、星标状态。 - 增加缺失消息清理,避免数据库残留已被删除的 maildir 邮件。 - 补充回归测试覆盖移动、重复副本、标记更新和删除场景。
This commit is contained in:
@@ -2632,6 +2632,162 @@ func TestMessageFlagsUpdateMaildir(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMaildirSyncUpdatesMovedMessageState(t *testing.T) {
|
||||||
|
a := newTestApp(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
a.cfg.MaildirRoot = t.TempDir()
|
||||||
|
user, mb := defaultAdminUserAndMailbox(t, a)
|
||||||
|
clearMailboxMessagesForTest(t, a, mb.ID)
|
||||||
|
|
||||||
|
msg, err := a.sendMailNow(ctx, user, mb, mailComposeInput{
|
||||||
|
MailboxID: mb.ID,
|
||||||
|
To: []string{"recipient@example.test"},
|
||||||
|
Subject: "imap moved",
|
||||||
|
Text: "move body",
|
||||||
|
HTML: "<p>move body</p>",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sentPath := maildirRawPathForTest(t, a, msg.ID)
|
||||||
|
archiveID, err := a.ensureFolder(ctx, mb.ID, "Archive")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
archiveDir := filepath.Join(filepath.Dir(filepath.Dir(filepath.Dir(sentPath))), ".Archive", "cur")
|
||||||
|
if err := os.MkdirAll(archiveDir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
archivePath := filepath.Join(archiveDir, filepath.Base(sentPath))
|
||||||
|
if err := os.Rename(sentPath, archivePath); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
count, err := a.syncMaildirOnce(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if count != 0 {
|
||||||
|
t.Fatalf("sync count=%d, want no import/backfill", count)
|
||||||
|
}
|
||||||
|
var folderID, rawPath string
|
||||||
|
if err := a.db.QueryRowContext(ctx, `SELECT folder_id,raw_path FROM messages WHERE id=?`, msg.ID).Scan(&folderID, &rawPath); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if folderID != archiveID || rawPath != archivePath {
|
||||||
|
t.Fatalf("folder/raw after move=%q %q, want %q %q", folderID, rawPath, archiveID, archivePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMaildirSyncKeepsDistinctCopiesWithSameMessageID(t *testing.T) {
|
||||||
|
a := newTestApp(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
a.cfg.MaildirRoot = t.TempDir()
|
||||||
|
user, mb := defaultAdminUserAndMailbox(t, a)
|
||||||
|
clearMailboxMessagesForTest(t, a, mb.ID)
|
||||||
|
|
||||||
|
msg, err := a.sendMailNow(ctx, user, mb, mailComposeInput{
|
||||||
|
MailboxID: mb.ID,
|
||||||
|
To: []string{"admin@lanqin.local"},
|
||||||
|
Subject: "self copy",
|
||||||
|
Text: "self body",
|
||||||
|
HTML: "<p>self body</p>",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := a.syncMaildirOnce(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var copies int
|
||||||
|
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM messages WHERE mailbox_id=? AND message_id=?`, mb.ID, msg.MessageID).Scan(&copies); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if copies != 2 {
|
||||||
|
t.Fatalf("copies with same Message-ID=%d, want Sent and Inbox copies", copies)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMaildirSyncUpdatesFlagsFromIMAP(t *testing.T) {
|
||||||
|
a := newTestApp(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
a.cfg.MaildirRoot = t.TempDir()
|
||||||
|
user, mb := defaultAdminUserAndMailbox(t, a)
|
||||||
|
clearMailboxMessagesForTest(t, a, mb.ID)
|
||||||
|
|
||||||
|
msg, err := a.sendMailNow(ctx, user, mb, mailComposeInput{
|
||||||
|
MailboxID: mb.ID,
|
||||||
|
To: []string{"recipient@example.test"},
|
||||||
|
Subject: "imap flags",
|
||||||
|
Text: "flag body",
|
||||||
|
HTML: "<p>flag body</p>",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rawPath := maildirRawPathForTest(t, a, msg.ID)
|
||||||
|
flaggedPath := maildirPathWithFlags(rawPath, false, true)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(flaggedPath), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Rename(rawPath, flaggedPath); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET updated_at=? WHERE id=?`, a.now().UTC().Add(-10*time.Minute).Format(time.RFC3339Nano), msg.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := a.syncMaildirOnce(ctx); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var read, starred int
|
||||||
|
var dbPath string
|
||||||
|
if err := a.db.QueryRowContext(ctx, `SELECT is_read,is_starred,raw_path FROM messages WHERE id=?`, msg.ID).Scan(&read, &starred, &dbPath); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if read != 0 || starred != 1 || dbPath != flaggedPath {
|
||||||
|
t.Fatalf("flags/path after sync read=%d starred=%d path=%q want 0 1 %q", read, starred, dbPath, flaggedPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMaildirSyncDeletesMissingMessage(t *testing.T) {
|
||||||
|
a := newTestApp(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
a.cfg.MaildirRoot = t.TempDir()
|
||||||
|
user, mb := defaultAdminUserAndMailbox(t, a)
|
||||||
|
clearMailboxMessagesForTest(t, a, mb.ID)
|
||||||
|
|
||||||
|
msg, err := a.sendMailNow(ctx, user, mb, mailComposeInput{
|
||||||
|
MailboxID: mb.ID,
|
||||||
|
To: []string{"recipient@example.test"},
|
||||||
|
Subject: "imap delete",
|
||||||
|
Text: "delete body",
|
||||||
|
HTML: "<p>delete body</p>",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rawPath := maildirRawPathForTest(t, a, msg.ID)
|
||||||
|
if err := os.Remove(rawPath); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET updated_at=? WHERE id=?`, a.now().UTC().Add(-10*time.Minute).Format(time.RFC3339Nano), msg.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
count, err := a.syncMaildirOnce(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if count != 1 {
|
||||||
|
t.Fatalf("cleanup count=%d, want 1", count)
|
||||||
|
}
|
||||||
|
var remaining int
|
||||||
|
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM messages WHERE id=?`, msg.ID).Scan(&remaining); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if remaining != 0 {
|
||||||
|
t.Fatalf("message remaining=%d, want deleted", remaining)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func mustDefaultDomainID(t *testing.T, a *App) string {
|
func mustDefaultDomainID(t *testing.T, a *App) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
var id string
|
var id string
|
||||||
|
|||||||
@@ -137,6 +137,11 @@ func (a *App) syncMaildirOnce(ctx context.Context) (int, error) {
|
|||||||
return imported, err
|
return imported, err
|
||||||
}
|
}
|
||||||
imported += backfilled
|
imported += backfilled
|
||||||
|
cleaned, err := a.cleanupMissingMaildirMessages(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return imported, err
|
||||||
|
}
|
||||||
|
imported += cleaned
|
||||||
return imported, nil
|
return imported, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,7 +302,7 @@ func (a *App) syncMaildirFile(ctx context.Context, mb maildirMailbox, folder mai
|
|||||||
}
|
}
|
||||||
msg.MailboxID = mb.ID
|
msg.MailboxID = mb.ID
|
||||||
msg.FolderID = folder.ID
|
msg.FolderID = folder.ID
|
||||||
msg.IsRead = !strings.EqualFold(folder.Name, "Inbox")
|
msg.IsRead, msg.IsStarred = maildirFlagsFromPath(path, folder.Name)
|
||||||
msg.RawPath = path
|
msg.RawPath = path
|
||||||
if msg.MessageUID == "" {
|
if msg.MessageUID == "" {
|
||||||
msg.MessageUID = newID("uid")
|
msg.MessageUID = newID("uid")
|
||||||
@@ -317,7 +322,14 @@ func (a *App) syncMaildirFile(ctx context.Context, mb maildirMailbox, folder mai
|
|||||||
if exists, err := a.maildirMessageExists(ctx, mb.ID, folder.ID, path, msg.MessageID); err != nil {
|
if exists, err := a.maildirMessageExists(ctx, mb.ID, folder.ID, path, msg.MessageID); err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
} else if exists {
|
} else if exists {
|
||||||
a.attachMaildirRawPathToExisting(ctx, mb.ID, folder.ID, path, msg.MessageID)
|
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
|
return false, nil
|
||||||
}
|
}
|
||||||
id, err := a.insertMessage(ctx, msg, attachments)
|
id, err := a.insertMessage(ctx, msg, attachments)
|
||||||
@@ -355,6 +367,145 @@ func (a *App) attachMaildirRawPathToExisting(ctx context.Context, mailboxID, fol
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) syncExistingMaildirMessageState(ctx context.Context, mailboxID, folderID, rawPath, messageID string, read, starred bool) (bool, error) {
|
||||||
|
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||||
|
res, err := a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?,raw_path=?,is_read=?,is_starred=?,updated_at=? WHERE mailbox_id=? AND raw_path=?`,
|
||||||
|
folderID, rawPath, boolInt(read), boolInt(starred), now, mailboxID, rawPath)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if rows, _ := res.RowsAffected(); rows > 0 {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
_, err = a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?,raw_path=?,is_read=?,is_starred=?,updated_at=? WHERE id=?`, folderID, rawPath, boolInt(read), boolInt(starred), 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)
|
||||||
|
if _, err := a.db.ExecContext(ctx, `DELETE FROM messages WHERE id=?`, it.ID); 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) {
|
func (a *App) attachUnregisteredMaildirRawPathToExisting(ctx context.Context, rawPath, messageID, recipient string) {
|
||||||
if strings.TrimSpace(messageID) == "" || strings.TrimSpace(rawPath) == "" {
|
if strings.TrimSpace(messageID) == "" || strings.TrimSpace(rawPath) == "" {
|
||||||
return
|
return
|
||||||
|
|||||||
Reference in New Issue
Block a user