diff --git a/apps/api/internal/app/app_test.go b/apps/api/internal/app/app_test.go index e5c391f..3db4aaa 100644 --- a/apps/api/internal/app/app_test.go +++ b/apps/api/internal/app/app_test.go @@ -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: "

move body

", + }) + 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: "

self body

", + }) + 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: "

flag body

", + }) + 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: "

delete body

", + }) + 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 { t.Helper() var id string diff --git a/apps/api/internal/app/maildir_sync.go b/apps/api/internal/app/maildir_sync.go index 36e591a..c7bf78f 100644 --- a/apps/api/internal/app/maildir_sync.go +++ b/apps/api/internal/app/maildir_sync.go @@ -137,6 +137,11 @@ func (a *App) syncMaildirOnce(ctx context.Context) (int, error) { return imported, err } imported += backfilled + cleaned, err := a.cleanupMissingMaildirMessages(ctx) + if err != nil { + return imported, err + } + imported += cleaned return imported, nil } @@ -297,7 +302,7 @@ func (a *App) syncMaildirFile(ctx context.Context, mb maildirMailbox, folder mai } msg.MailboxID = mb.ID msg.FolderID = folder.ID - msg.IsRead = !strings.EqualFold(folder.Name, "Inbox") + msg.IsRead, msg.IsStarred = maildirFlagsFromPath(path, folder.Name) msg.RawPath = path if msg.MessageUID == "" { 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 { return false, err } 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 } 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= 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