From b3669f189eb70804d8d387a785e4710918805c7d Mon Sep 17 00:00:00 2001 From: LanQin_ Date: Wed, 24 Jun 2026 16:23:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(imap):=20=E8=A1=A5=E5=85=85=E9=82=AE?= =?UTF-8?q?=E4=BB=B6=20IMAP=20=E5=85=83=E6=95=B0=E6=8D=AE=E6=94=AF?= =?UTF-8?q?=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 为文件夹和消息增加 `UID/ModSeq` 字段,并在迁移时回填与建索引。 - 让收发、移动、标记、删除、草稿等流程同步维护 `imap_uid` 和 `imap_modseq`。 - 扩展后端与前端类型,并补充相关测试覆盖。 --- apps/api/internal/app/admin_handlers.go | 4 +- apps/api/internal/app/app.go | 10 +- apps/api/internal/app/app_test.go | 93 ++++++++ apps/api/internal/app/imap_metadata.go | 242 +++++++++++++++++++++ apps/api/internal/app/mail_handlers.go | 65 ++++-- apps/api/internal/app/maildir_sync.go | 59 ++++- apps/api/internal/app/maildir_write.go | 74 ++++++- apps/api/internal/app/personal_handlers.go | 12 +- apps/api/internal/app/types.go | 15 +- apps/web/src/lib/api-types.ts | 4 +- 10 files changed, 533 insertions(+), 45 deletions(-) create mode 100644 apps/api/internal/app/imap_metadata.go diff --git a/apps/api/internal/app/admin_handlers.go b/apps/api/internal/app/admin_handlers.go index 537570b..2da4d72 100644 --- a/apps/api/internal/app/admin_handlers.go +++ b/apps/api/internal/app/admin_handlers.go @@ -786,7 +786,7 @@ func (a *App) handleAdminMessages(w http.ResponseWriter, r *http.Request) { } args = append(args, limit+1, offset) - rows, err := a.db.QueryContext(r.Context(), `SELECT m.id,COALESCE(m.mailbox_id,''),COALESCE(mb.address,''),COALESCE(u.email,''),COALESCE(m.recipient_addr,''),COALESCE(m.folder_id,''),COALESCE(f.name,'Unregistered'),m.message_uid,m.message_id,m.subject,m.from_addr,COALESCE(m.from_name,''),m.to_addrs,m.cc_addrs,m.bcc_addrs,m.sent_at,m.received_at,m.snippet,m.is_read,m.is_starred,m.has_attachments,m.size_bytes + rows, err := a.db.QueryContext(r.Context(), `SELECT m.id,COALESCE(m.mailbox_id,''),COALESCE(mb.address,''),COALESCE(u.email,''),COALESCE(m.recipient_addr,''),COALESCE(m.folder_id,''),COALESCE(f.name,'Unregistered'),m.message_uid,m.imap_uid,m.imap_modseq,m.message_id,m.subject,m.from_addr,COALESCE(m.from_name,''),m.to_addrs,m.cc_addrs,m.bcc_addrs,m.sent_at,m.received_at,m.snippet,m.is_read,m.is_starred,m.has_attachments,m.size_bytes FROM messages m LEFT JOIN folders f ON f.id=m.folder_id LEFT JOIN mailboxes mb ON mb.id=m.mailbox_id @@ -1039,6 +1039,6 @@ func (a *App) ensureFolder(ctx context.Context, mailboxID, folder string) (strin } role := strings.ToLower(folder) id = newID("fld") - _, err := a.db.ExecContext(ctx, `INSERT INTO folders(id,mailbox_id,name,role,created_at) VALUES(?,?,?,?,?)`, id, mailboxID, folder, role, a.now().UTC().Format(time.RFC3339Nano)) + _, err := a.db.ExecContext(ctx, `INSERT INTO folders(id,mailbox_id,name,role,uid_validity,uid_next,highest_modseq,created_at) VALUES(?,?,?,?,?,?,?,?)`, id, mailboxID, folder, role, a.newUIDValidity(), 1, 1, a.now().UTC().Format(time.RFC3339Nano)) return id, err } diff --git a/apps/api/internal/app/app.go b/apps/api/internal/app/app.go index 2e0fd28..959bf7f 100644 --- a/apps/api/internal/app/app.go +++ b/apps/api/internal/app/app.go @@ -202,6 +202,9 @@ func (a *App) migrate(ctx context.Context) error { mailbox_id TEXT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE, name TEXT NOT NULL, role TEXT NOT NULL, + uid_validity INTEGER NOT NULL DEFAULT 0, + uid_next INTEGER NOT NULL DEFAULT 1, + highest_modseq INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL, UNIQUE(mailbox_id, name) )`, @@ -228,6 +231,8 @@ func (a *App) migrate(ctx context.Context) error { has_attachments INTEGER NOT NULL DEFAULT 0, size_bytes INTEGER NOT NULL DEFAULT 0, raw_path TEXT NOT NULL DEFAULT '', + imap_uid INTEGER NOT NULL DEFAULT 0, + imap_modseq INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL, updated_at TEXT NOT NULL )`, @@ -429,6 +434,9 @@ func (a *App) migrate(ctx context.Context) error { if err := a.migrateSendQueueMessageID(ctx); err != nil { return err } + if err := a.migrateIMAPMetadata(ctx); err != nil { + return err + } if err := a.ensureDefaultPermissionGroups(ctx); err != nil { return err } @@ -1030,7 +1038,7 @@ func (a *App) createMailboxWithPasswordHash(ctx context.Context, userID, domainI return "", err } for _, f := range defaultFolderDefs() { - _, err = tx.ExecContext(ctx, `INSERT INTO folders(id,mailbox_id,name,role,created_at) VALUES(?,?,?,?,?)`, newID("fld"), id, f.name, f.role, now) + _, err = tx.ExecContext(ctx, `INSERT INTO folders(id,mailbox_id,name,role,uid_validity,uid_next,highest_modseq,created_at) VALUES(?,?,?,?,?,?,?,?)`, newID("fld"), id, f.name, f.role, a.newUIDValidity(), 1, 1, now) if err != nil { return "", err } diff --git a/apps/api/internal/app/app_test.go b/apps/api/internal/app/app_test.go index 63e8f3d..995aa87 100644 --- a/apps/api/internal/app/app_test.go +++ b/apps/api/internal/app/app_test.go @@ -2729,6 +2729,99 @@ func TestMessageFlagsUpdateMaildir(t *testing.T) { } } +func TestIMAPUIDAndModSeqProgression(t *testing.T) { + a := newTestApp(t) + ctx := context.Background() + a.cfg.MaildirRoot = t.TempDir() + srv := httptest.NewServer(a.Router()) + defer srv.Close() + client := &testClient{t: t, server: srv} + var login map[string]any + if code := client.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK { + t.Fatalf("login code=%d body=%v", code, login) + } + user, mb := defaultAdminUserAndMailbox(t, a) + clearMailboxMessagesForTest(t, a, mb.ID) + sentID, err := a.ensureFolder(ctx, mb.ID, "Sent") + if err != nil { + t.Fatal(err) + } + archiveID, err := a.ensureFolder(ctx, mb.ID, "Archive") + if err != nil { + t.Fatal(err) + } + + first, err := a.sendMailNow(ctx, user, mb, mailComposeInput{MailboxID: mb.ID, To: []string{"one@example.test"}, Subject: "uid one", Text: "one", HTML: "

one

"}) + if err != nil { + t.Fatal(err) + } + second, err := a.sendMailNow(ctx, user, mb, mailComposeInput{MailboxID: mb.ID, To: []string{"two@example.test"}, Subject: "uid two", Text: "two", HTML: "

two

"}) + if err != nil { + t.Fatal(err) + } + var firstUID, firstModSeq, secondUID int64 + if err := a.db.QueryRowContext(ctx, `SELECT imap_uid,imap_modseq FROM messages WHERE id=?`, first.ID).Scan(&firstUID, &firstModSeq); err != nil { + t.Fatal(err) + } + if err := a.db.QueryRowContext(ctx, `SELECT imap_uid FROM messages WHERE id=?`, second.ID).Scan(&secondUID); err != nil { + t.Fatal(err) + } + if firstUID <= 0 || secondUID != firstUID+1 { + t.Fatalf("sent UIDs first=%d second=%d, want consecutive positive", firstUID, secondUID) + } + var sentUIDNext int64 + if err := a.db.QueryRowContext(ctx, `SELECT uid_next FROM folders WHERE id=?`, sentID).Scan(&sentUIDNext); err != nil { + t.Fatal(err) + } + if sentUIDNext <= secondUID { + t.Fatalf("sent uid_next=%d, second uid=%d", sentUIDNext, secondUID) + } + + starred := true + if err := a.updateMessageMaildirFlags(ctx, first.ID, nil, &starred); err != nil { + t.Fatal(err) + } + modSeq, err := a.updateMessageModSeq(ctx, first.ID, sentID) + if err != nil { + t.Fatal(err) + } + if _, err := a.db.ExecContext(ctx, `UPDATE messages SET is_starred=1,imap_modseq=? WHERE id=?`, modSeq, first.ID); err != nil { + t.Fatal(err) + } + var afterFlagUID, afterFlagModSeq int64 + if err := a.db.QueryRowContext(ctx, `SELECT imap_uid,imap_modseq FROM messages WHERE id=?`, first.ID).Scan(&afterFlagUID, &afterFlagModSeq); err != nil { + t.Fatal(err) + } + if afterFlagUID != firstUID || afterFlagModSeq <= firstModSeq { + t.Fatalf("after flag uid/modseq=%d/%d, want uid %d and modseq > %d", afterFlagUID, afterFlagModSeq, firstUID, firstModSeq) + } + + if err := a.moveMessageMaildir(ctx, first.ID, archiveID); err != nil { + t.Fatal(err) + } + var archiveUID, archiveModSeq int64 + var folderID string + if err := a.db.QueryRowContext(ctx, `SELECT folder_id,imap_uid,imap_modseq FROM messages WHERE id=?`, first.ID).Scan(&folderID, &archiveUID, &archiveModSeq); err != nil { + t.Fatal(err) + } + if folderID != archiveID { + t.Fatalf("folder after move=%s, want archive %s", folderID, archiveID) + } + if archiveUID <= 0 { + t.Fatalf("archive uid=%d, want positive uid", archiveUID) + } + var archiveUIDNext, archiveHighestModSeq int64 + if err := a.db.QueryRowContext(ctx, `SELECT uid_next,highest_modseq FROM folders WHERE id=?`, archiveID).Scan(&archiveUIDNext, &archiveHighestModSeq); err != nil { + t.Fatal(err) + } + if archiveUIDNext <= archiveUID { + t.Fatalf("archive uid_next=%d, uid=%d", archiveUIDNext, archiveUID) + } + if archiveModSeq != archiveHighestModSeq { + t.Fatalf("archive modseq=%d highest=%d, want equal", archiveModSeq, archiveHighestModSeq) + } +} + func TestMaildirSyncUpdatesMovedMessageState(t *testing.T) { a := newTestApp(t) ctx := context.Background() diff --git a/apps/api/internal/app/imap_metadata.go b/apps/api/internal/app/imap_metadata.go new file mode 100644 index 0000000..cf4f6fb --- /dev/null +++ b/apps/api/internal/app/imap_metadata.go @@ -0,0 +1,242 @@ +package app + +import ( + "context" + "database/sql" + "math" + "time" +) + +type imapMetadata struct { + UID int64 + ModSeq int64 +} + +func (a *App) migrateIMAPMetadata(ctx context.Context) error { + if err := a.ensureTableColumn(ctx, "folders", "uid_validity", `ALTER TABLE folders ADD COLUMN uid_validity INTEGER NOT NULL DEFAULT 0`); err != nil { + return err + } + if err := a.ensureTableColumn(ctx, "folders", "uid_next", `ALTER TABLE folders ADD COLUMN uid_next INTEGER NOT NULL DEFAULT 1`); err != nil { + return err + } + if err := a.ensureTableColumn(ctx, "folders", "highest_modseq", `ALTER TABLE folders ADD COLUMN highest_modseq INTEGER NOT NULL DEFAULT 1`); err != nil { + return err + } + if err := a.ensureTableColumn(ctx, "messages", "imap_uid", `ALTER TABLE messages ADD COLUMN imap_uid INTEGER NOT NULL DEFAULT 0`); err != nil { + return err + } + if err := a.ensureTableColumn(ctx, "messages", "imap_modseq", `ALTER TABLE messages ADD COLUMN imap_modseq INTEGER NOT NULL DEFAULT 1`); err != nil { + return err + } + if _, err := a.db.ExecContext(ctx, `UPDATE folders SET uid_validity=? WHERE uid_validity=0`, a.newUIDValidity()); err != nil { + return err + } + if _, err := a.db.ExecContext(ctx, `UPDATE folders SET uid_next=1 WHERE uid_next<1`); err != nil { + return err + } + if _, err := a.db.ExecContext(ctx, `UPDATE folders SET highest_modseq=1 WHERE highest_modseq<1`); err != nil { + return err + } + if err := a.backfillIMAPUIDs(ctx); err != nil { + return err + } + _, err := a.db.ExecContext(ctx, `CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_folder_imap_uid ON messages(folder_id, imap_uid) WHERE folder_id IS NOT NULL AND imap_uid > 0`) + return err +} + +func (a *App) ensureTableColumn(ctx context.Context, table, column, alterSQL string) error { + rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(`+table+`)`) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var cid int + var name, typ string + var notNull int + var dflt any + var pk int + if err := rows.Scan(&cid, &name, &typ, ¬Null, &dflt, &pk); err != nil { + return err + } + if name == column { + return nil + } + } + if err := rows.Err(); err != nil { + return err + } + _, err = a.db.ExecContext(ctx, alterSQL) + return err +} + +func (a *App) backfillIMAPUIDs(ctx context.Context) error { + rows, err := a.db.QueryContext(ctx, `SELECT id FROM folders ORDER BY created_at,id`) + if err != nil { + return err + } + var folderIDs []string + for rows.Next() { + var folderID string + if err := rows.Scan(&folderID); err != nil { + rows.Close() + return err + } + folderIDs = append(folderIDs, folderID) + } + if err := rows.Err(); err != nil { + rows.Close() + return err + } + if err := rows.Close(); err != nil { + return err + } + for _, folderID := range folderIDs { + if err := a.backfillFolderIMAPUIDs(ctx, folderID); err != nil { + return err + } + } + return nil +} + +func (a *App) backfillFolderIMAPUIDs(ctx context.Context, folderID string) error { + rows, err := a.db.QueryContext(ctx, `SELECT id FROM messages WHERE folder_id=? AND imap_uid=0 ORDER BY created_at,id`, folderID) + if err != nil { + return err + } + var messageIDs []string + for rows.Next() { + var messageID string + if err := rows.Scan(&messageID); err != nil { + rows.Close() + return err + } + messageIDs = append(messageIDs, messageID) + } + if err := rows.Err(); err != nil { + rows.Close() + return err + } + if err := rows.Close(); err != nil { + return err + } + for _, messageID := range messageIDs { + meta, err := a.nextIMAPMetadata(ctx, a.db, folderID) + if err != nil { + return err + } + if _, err := a.db.ExecContext(ctx, `UPDATE messages SET imap_uid=?,imap_modseq=? WHERE id=?`, meta.UID, meta.ModSeq, messageID); err != nil { + return err + } + } + var maxUID, maxModSeq int64 + if err := a.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(imap_uid),0),COALESCE(MAX(imap_modseq),1) FROM messages WHERE folder_id=?`, folderID).Scan(&maxUID, &maxModSeq); err != nil { + return err + } + _, err = a.db.ExecContext(ctx, `UPDATE folders SET uid_next=MAX(uid_next,?),highest_modseq=MAX(highest_modseq,?) WHERE id=?`, maxUID+1, maxModSeq, folderID) + return err +} + +func (a *App) newUIDValidity() int64 { + value := a.now().UTC().Unix() + if value <= 0 { + return time.Now().UTC().Unix() + } + return value +} + +func (a *App) nextIMAPMetadata(ctx context.Context, db dbExecutor, folderID string) (imapMetadata, error) { + if folderID == "" { + return imapMetadata{}, nil + } + rowDB, ok := db.(dbQueryer) + if !ok { + return imapMetadata{}, nil + } + var nextUID, highestModSeq int64 + err := rowDB.QueryRowContext(ctx, `SELECT uid_next,highest_modseq FROM folders WHERE id=?`, folderID).Scan(&nextUID, &highestModSeq) + if err != nil { + return imapMetadata{}, err + } + if nextUID < 1 { + nextUID = 1 + } + nextModSeq := highestModSeq + 1 + if nextModSeq < 1 { + nextModSeq = 1 + } + if _, err := db.ExecContext(ctx, `UPDATE folders SET uid_next=?,highest_modseq=MAX(highest_modseq,?) WHERE id=?`, nextUID+1, nextModSeq, folderID); err != nil { + return imapMetadata{}, err + } + return imapMetadata{UID: nextUID, ModSeq: nextModSeq}, nil +} + +func (a *App) bumpFolderModSeq(ctx context.Context, folderID string) (int64, error) { + return a.bumpFolderModSeqWithDB(ctx, a.db, folderID) +} + +func (a *App) bumpFolderModSeqWithDB(ctx context.Context, db dbExecutor, folderID string) (int64, error) { + if folderID == "" { + return 0, nil + } + rowDB, ok := db.(dbQueryer) + if !ok { + return 0, nil + } + var current int64 + if err := rowDB.QueryRowContext(ctx, `SELECT highest_modseq FROM folders WHERE id=?`, folderID).Scan(¤t); err != nil { + if err == sql.ErrNoRows { + return 0, nil + } + return 0, err + } + next := current + 1 + if next < 1 || next == math.MaxInt64 { + next = current + } + if _, err := db.ExecContext(ctx, `UPDATE folders SET highest_modseq=MAX(highest_modseq,?) WHERE id=?`, next, folderID); err != nil { + return 0, err + } + return next, nil +} + +func (a *App) touchMessageIMAPModSeq(ctx context.Context, messageID string) error { + var folderID sql.NullString + if err := a.db.QueryRowContext(ctx, `SELECT folder_id FROM messages WHERE id=?`, messageID).Scan(&folderID); err != nil { + return err + } + if !folderID.Valid || folderID.String == "" { + return nil + } + modSeq, err := a.bumpFolderModSeq(ctx, folderID.String) + if err != nil { + return err + } + if modSeq == 0 { + return nil + } + _, err = a.db.ExecContext(ctx, `UPDATE messages SET imap_modseq=? WHERE id=?`, modSeq, messageID) + return err +} + +func (a *App) updateMessageModSeq(ctx context.Context, messageID string, folderID string) (int64, error) { + if folderID == "" { + var dbFolderID sql.NullString + if err := a.db.QueryRowContext(ctx, `SELECT folder_id FROM messages WHERE id=?`, messageID).Scan(&dbFolderID); err != nil { + return 0, err + } + if !dbFolderID.Valid || dbFolderID.String == "" { + return 0, nil + } + folderID = dbFolderID.String + } + modSeq, err := a.bumpFolderModSeq(ctx, folderID) + if err != nil { + return 0, err + } + if modSeq == 0 { + return 0, nil + } + _, err = a.db.ExecContext(ctx, `UPDATE messages SET imap_modseq=? WHERE id=?`, modSeq, messageID) + return modSeq, err +} diff --git a/apps/api/internal/app/mail_handlers.go b/apps/api/internal/app/mail_handlers.go index f7f5a88..a46ade4 100644 --- a/apps/api/internal/app/mail_handlers.go +++ b/apps/api/internal/app/mail_handlers.go @@ -84,7 +84,8 @@ func (a *App) handleMailFolders(w http.ResponseWriter, r *http.Request) { } rows, err := a.db.QueryContext(r.Context(), `SELECT f.id,f.name,f.role, COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread, - COUNT(m.id) AS total + COUNT(m.id) AS total, + f.uid_validity,f.uid_next,f.highest_modseq FROM folders f LEFT JOIN messages m ON m.folder_id=f.id WHERE f.mailbox_id=? GROUP BY f.id,f.name,f.role ORDER BY CASE f.role WHEN 'inbox' THEN 1 WHEN 'sent' THEN 2 WHEN 'drafts' THEN 3 WHEN 'archive' THEN 4 WHEN 'spam' THEN 5 WHEN 'trash' THEN 6 ELSE 99 END, f.name`, mb.ID) @@ -96,7 +97,7 @@ func (a *App) handleMailFolders(w http.ResponseWriter, r *http.Request) { items := []MailFolder{} for rows.Next() { var f MailFolder - if err := rows.Scan(&f.ID, &f.Name, &f.Role, &f.UnreadCount, &f.TotalCount); err != nil { + if err := rows.Scan(&f.ID, &f.Name, &f.Role, &f.UnreadCount, &f.TotalCount, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil { respondError(w, http.StatusInternalServerError, "failed to scan folders") return } @@ -154,7 +155,7 @@ func (a *App) respondMailMessageList(w http.ResponseWriter, r *http.Request, whe args = append(args, like, like, like, like, like) } args = append(args, limit+1, offset) - query := `SELECT m.id,m.mailbox_id,m.folder_id,COALESCE(f.name,''),m.message_uid,m.message_id,m.subject,m.from_addr,COALESCE(m.from_name,''),m.to_addrs,m.cc_addrs,m.bcc_addrs,m.sent_at,m.received_at,m.snippet,m.is_read,m.is_starred,m.has_attachments,m.size_bytes + query := `SELECT m.id,m.mailbox_id,m.folder_id,COALESCE(f.name,''),m.message_uid,m.imap_uid,m.imap_modseq,m.message_id,m.subject,m.from_addr,COALESCE(m.from_name,''),m.to_addrs,m.cc_addrs,m.bcc_addrs,m.sent_at,m.received_at,m.snippet,m.is_read,m.is_starred,m.has_attachments,m.size_bytes FROM messages m LEFT JOIN folders f ON f.id=m.folder_id WHERE ` + where + ` ORDER BY m.received_at DESC LIMIT ? OFFSET ?` rows, err := a.db.QueryContext(r.Context(), query, args...) if err != nil { @@ -332,7 +333,11 @@ func (a *App) handleMailMessage(w http.ResponseWriter, r *http.Request) { if err := a.updateMessageMaildirFlags(r.Context(), msg.ID, &read, nil); err != nil { a.log.Warn("failed to update maildir read flag", "message_id", msg.ID, "error", err) } - _, _ = a.db.ExecContext(r.Context(), `UPDATE messages SET is_read=1, updated_at=? WHERE id=?`, a.now().UTC().Format(time.RFC3339Nano), msg.ID) + if modSeq, err := a.updateMessageModSeq(r.Context(), msg.ID, msg.FolderID); err == nil && modSeq > 0 { + _, _ = a.db.ExecContext(r.Context(), `UPDATE messages SET is_read=1, imap_modseq=?, updated_at=? WHERE id=?`, modSeq, a.now().UTC().Format(time.RFC3339Nano), msg.ID) + } else { + _, _ = a.db.ExecContext(r.Context(), `UPDATE messages SET is_read=1, updated_at=? WHERE id=?`, a.now().UTC().Format(time.RFC3339Nano), msg.ID) + } msg.IsRead = true } respondJSON(w, http.StatusOK, msg) @@ -814,8 +819,13 @@ func (a *App) handleSaveDraft(w http.ResponseWriter, r *http.Request) { _ = a.db.QueryRowContext(r.Context(), `SELECT COALESCE(SUM(size_bytes),0) FROM attachments WHERE message_id=?`, draftID).Scan(&attachmentBytes) size += attachmentBytes } - _, err = a.db.ExecContext(r.Context(), `UPDATE messages SET subject=?,to_addrs=?,cc_addrs=?,bcc_addrs=?,sent_at=?,received_at=?,snippet=?,body_text=?,body_html=?,is_read=1,has_attachments=?,size_bytes=?,updated_at=? WHERE id=?`, - subject, jsonEncode(compose.To), jsonEncode(compose.CC), jsonEncode(compose.BCC), now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano), snippetFrom(compose.Text, compose.HTML), compose.Text, compose.HTML, boolInt(hasAttachments), size, now.Format(time.RFC3339Nano), draftID) + modSeq, err := a.updateMessageModSeq(r.Context(), draftID, existing.FolderID) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to update draft") + return + } + _, err = a.db.ExecContext(r.Context(), `UPDATE messages SET subject=?,to_addrs=?,cc_addrs=?,bcc_addrs=?,sent_at=?,received_at=?,snippet=?,body_text=?,body_html=?,is_read=1,has_attachments=?,size_bytes=?,imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END,updated_at=? WHERE id=?`, + subject, jsonEncode(compose.To), jsonEncode(compose.CC), jsonEncode(compose.BCC), now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano), snippetFrom(compose.Text, compose.HTML), compose.Text, compose.HTML, boolInt(hasAttachments), size, modSeq, modSeq, now.Format(time.RFC3339Nano), draftID) if err != nil { respondError(w, http.StatusInternalServerError, "failed to update draft") return @@ -848,6 +858,7 @@ func (a *App) handleDeleteDraft(w http.ResponseWriter, r *http.Request) { respondError(w, http.StatusInternalServerError, "failed to delete draft") return } + _, _ = a.bumpFolderModSeq(r.Context(), msg.FolderID) respondJSON(w, http.StatusOK, map[string]any{"ok": true}) } @@ -1146,7 +1157,12 @@ func (a *App) handleMarkRead(w http.ResponseWriter, r *http.Request) { respondError(w, http.StatusInternalServerError, "failed to update message") return } - _, err = a.db.ExecContext(r.Context(), `UPDATE messages SET is_read=?, updated_at=? WHERE id=?`, boolInt(read), a.now().UTC().Format(time.RFC3339Nano), msg.ID) + modSeq, err := a.updateMessageModSeq(r.Context(), msg.ID, msg.FolderID) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to update message") + return + } + _, err = a.db.ExecContext(r.Context(), `UPDATE messages SET is_read=?, imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END, updated_at=? WHERE id=?`, boolInt(read), modSeq, modSeq, a.now().UTC().Format(time.RFC3339Nano), msg.ID) if err != nil { respondError(w, http.StatusInternalServerError, "failed to update message") return @@ -1172,7 +1188,12 @@ func (a *App) handleStar(w http.ResponseWriter, r *http.Request) { respondError(w, http.StatusInternalServerError, "failed to update message") return } - _, err = a.db.ExecContext(r.Context(), `UPDATE messages SET is_starred=?, updated_at=? WHERE id=?`, boolInt(starred), a.now().UTC().Format(time.RFC3339Nano), msg.ID) + modSeq, err := a.updateMessageModSeq(r.Context(), msg.ID, msg.FolderID) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to update message") + return + } + _, err = a.db.ExecContext(r.Context(), `UPDATE messages SET is_starred=?, imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END, updated_at=? WHERE id=?`, boolInt(starred), modSeq, modSeq, a.now().UTC().Format(time.RFC3339Nano), msg.ID) if err != nil { respondError(w, http.StatusInternalServerError, "failed to update message") return @@ -1215,6 +1236,9 @@ func (a *App) handleDeleteMessage(w http.ResponseWriter, r *http.Request) { a.deleteMessageMaildirFile(r.Context(), msg.ID) a.deleteMessageFiles(r.Context(), msg.ID) _, err = a.db.ExecContext(r.Context(), `DELETE FROM messages WHERE id=?`, msg.ID) + if err == nil { + _, _ = a.bumpFolderModSeq(r.Context(), msg.FolderID) + } } else { trashID, e := a.ensureFolder(r.Context(), msg.MailboxID, "Trash") if e != nil { @@ -1348,7 +1372,7 @@ func (a *App) loadMessageForRequest(r *http.Request, id string, includeBody bool } func (a *App) messageByID(ctx context.Context, id string, includeBody bool) (*MailMessage, error) { - row := a.db.QueryRowContext(ctx, `SELECT m.id,COALESCE(m.mailbox_id,''),COALESCE(m.recipient_addr,''),COALESCE(m.folder_id,''),COALESCE(f.name,'Unregistered'),m.message_uid,m.message_id,m.subject,m.from_addr,COALESCE(m.from_name,''),m.to_addrs,m.cc_addrs,m.bcc_addrs,m.sent_at,m.received_at,m.snippet,m.body_text,m.body_html,m.is_read,m.is_starred,m.has_attachments,m.size_bytes + row := a.db.QueryRowContext(ctx, `SELECT m.id,COALESCE(m.mailbox_id,''),COALESCE(m.recipient_addr,''),COALESCE(m.folder_id,''),COALESCE(f.name,'Unregistered'),m.message_uid,m.imap_uid,m.imap_modseq,m.message_id,m.subject,m.from_addr,COALESCE(m.from_name,''),m.to_addrs,m.cc_addrs,m.bcc_addrs,m.sent_at,m.received_at,m.snippet,m.body_text,m.body_html,m.is_read,m.is_starred,m.has_attachments,m.size_bytes FROM messages m LEFT JOIN folders f ON f.id=m.folder_id WHERE m.id=?`, id) msg, err := scanMessageFull(row, includeBody) if err != nil { @@ -1398,9 +1422,17 @@ func (a *App) insertMessageWithDB(ctx context.Context, db dbExecutor, msg stored if strings.TrimSpace(msg.FolderID) != "" { folderID = msg.FolderID } + imapUID, imapModSeq := int64(0), int64(1) + if strings.TrimSpace(msg.FolderID) != "" { + meta, err := a.nextIMAPMetadata(ctx, db, msg.FolderID) + if err != nil { + return "", err + } + imapUID, imapModSeq = meta.UID, meta.ModSeq + } recipientAddr := normalizeEmail(msg.RecipientAddr) - _, err := db.ExecContext(ctx, `INSERT INTO messages(id,mailbox_id,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,has_attachments,size_bytes,raw_path,created_at,updated_at) - VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, id, mailboxID, folderID, recipientAddr, msg.MessageUID, msg.MessageID, msg.Subject, msg.From, msg.FromName, jsonEncode(msg.To), jsonEncode(msg.CC), jsonEncode(msg.BCC), msg.SentAt.Format(time.RFC3339Nano), msg.ReceivedAt.Format(time.RFC3339Nano), msg.Snippet, msg.BodyText, msg.BodyHTML, boolInt(msg.IsRead), boolInt(msg.IsStarred), boolInt(hasAttachments), size, msg.RawPath, now, now) + _, err := db.ExecContext(ctx, `INSERT INTO messages(id,mailbox_id,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,has_attachments,size_bytes,raw_path,imap_uid,imap_modseq,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, id, mailboxID, folderID, recipientAddr, msg.MessageUID, msg.MessageID, msg.Subject, msg.From, msg.FromName, jsonEncode(msg.To), jsonEncode(msg.CC), jsonEncode(msg.BCC), msg.SentAt.Format(time.RFC3339Nano), msg.ReceivedAt.Format(time.RFC3339Nano), msg.Snippet, msg.BodyText, msg.BodyHTML, boolInt(msg.IsRead), boolInt(msg.IsStarred), boolInt(hasAttachments), size, msg.RawPath, imapUID, imapModSeq, now, now) if err != nil { return "", err } @@ -1610,9 +1642,14 @@ func (a *App) deleteMessageFiles(ctx context.Context, messageID string) { } func (a *App) deleteMessage(ctx context.Context, messageID string) { + var folderID sql.NullString + _ = a.db.QueryRowContext(ctx, `SELECT folder_id FROM messages WHERE id=?`, messageID).Scan(&folderID) a.deleteMessageMaildirFile(ctx, messageID) a.deleteMessageFiles(ctx, messageID) _, _ = a.db.ExecContext(ctx, `DELETE FROM messages WHERE id=?`, messageID) + if folderID.Valid && folderID.String != "" { + _, _ = a.bumpFolderModSeq(ctx, folderID.String) + } } type messageSummaryScanner interface{ Scan(dest ...any) error } @@ -1621,7 +1658,7 @@ func scanAdminMessageSummary(row messageSummaryScanner) (MailMessage, error) { var msg MailMessage var toJSON, ccJSON, bccJSON, sent, received string var read, starred, hasAtt int - err := row.Scan(&msg.ID, &msg.MailboxID, &msg.MailboxAddress, &msg.OwnerEmail, &msg.RecipientAddr, &msg.FolderID, &msg.Folder, &msg.MessageUID, &msg.MessageID, &msg.Subject, &msg.From, &msg.FromName, &toJSON, &ccJSON, &bccJSON, &sent, &received, &msg.Snippet, &read, &starred, &hasAtt, &msg.SizeBytes) + err := row.Scan(&msg.ID, &msg.MailboxID, &msg.MailboxAddress, &msg.OwnerEmail, &msg.RecipientAddr, &msg.FolderID, &msg.Folder, &msg.MessageUID, &msg.IMAPUID, &msg.IMAPModSeq, &msg.MessageID, &msg.Subject, &msg.From, &msg.FromName, &toJSON, &ccJSON, &bccJSON, &sent, &received, &msg.Snippet, &read, &starred, &hasAtt, &msg.SizeBytes) if err != nil { return msg, err } @@ -1635,7 +1672,7 @@ func scanMessageSummary(row messageSummaryScanner) (MailMessage, error) { var msg MailMessage var toJSON, ccJSON, bccJSON, sent, received string var read, starred, hasAtt int - err := row.Scan(&msg.ID, &msg.MailboxID, &msg.FolderID, &msg.Folder, &msg.MessageUID, &msg.MessageID, &msg.Subject, &msg.From, &msg.FromName, &toJSON, &ccJSON, &bccJSON, &sent, &received, &msg.Snippet, &read, &starred, &hasAtt, &msg.SizeBytes) + err := row.Scan(&msg.ID, &msg.MailboxID, &msg.FolderID, &msg.Folder, &msg.MessageUID, &msg.IMAPUID, &msg.IMAPModSeq, &msg.MessageID, &msg.Subject, &msg.From, &msg.FromName, &toJSON, &ccJSON, &bccJSON, &sent, &received, &msg.Snippet, &read, &starred, &hasAtt, &msg.SizeBytes) if err != nil { return msg, err } @@ -1650,7 +1687,7 @@ func scanMessageFull(row messageSummaryScanner, includeBody bool) (MailMessage, var toJSON, ccJSON, bccJSON, sent, received string var read, starred, hasAtt int var bodyText, bodyHTML string - err := row.Scan(&msg.ID, &msg.MailboxID, &msg.RecipientAddr, &msg.FolderID, &msg.Folder, &msg.MessageUID, &msg.MessageID, &msg.Subject, &msg.From, &msg.FromName, &toJSON, &ccJSON, &bccJSON, &sent, &received, &msg.Snippet, &bodyText, &bodyHTML, &read, &starred, &hasAtt, &msg.SizeBytes) + err := row.Scan(&msg.ID, &msg.MailboxID, &msg.RecipientAddr, &msg.FolderID, &msg.Folder, &msg.MessageUID, &msg.IMAPUID, &msg.IMAPModSeq, &msg.MessageID, &msg.Subject, &msg.From, &msg.FromName, &toJSON, &ccJSON, &bccJSON, &sent, &received, &msg.Snippet, &bodyText, &bodyHTML, &read, &starred, &hasAtt, &msg.SizeBytes) if err != nil { return msg, err } diff --git a/apps/api/internal/app/maildir_sync.go b/apps/api/internal/app/maildir_sync.go index b483093..42c4bed 100644 --- a/apps/api/internal/app/maildir_sync.go +++ b/apps/api/internal/app/maildir_sync.go @@ -405,13 +405,38 @@ 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 + 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 rows, _ := res.RowsAffected(); rows > 0 { - return true, nil + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return false, err } if strings.TrimSpace(messageID) == "" { return false, nil @@ -461,7 +486,20 @@ func (a *App) syncExistingMaildirMessageState(ctx context.Context, mailboxID, fo 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) + 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 } @@ -518,9 +556,16 @@ func (a *App) cleanupMissingMaildirMessages(ctx context.Context) (int, error) { } 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 } diff --git a/apps/api/internal/app/maildir_write.go b/apps/api/internal/app/maildir_write.go index c47780c..c26d08e 100644 --- a/apps/api/internal/app/maildir_write.go +++ b/apps/api/internal/app/maildir_write.go @@ -84,7 +84,13 @@ func (a *App) writeRawMessageToMaildirFolder(ctx context.Context, messageID, fol return err } if folderID != "" { + oldFolderID := state.FolderID state.FolderID = folderID + if updateFolder && oldFolderID != "" && oldFolderID != state.FolderID { + if _, err := a.bumpFolderModSeq(ctx, oldFolderID); err != nil { + return err + } + } } if state.MailboxID == "" || state.FolderID == "" { return nil @@ -132,16 +138,45 @@ func (a *App) writeRawMessageToMaildirFolder(ctx context.Context, messageID, fol 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) + if state.IMAPUID > 0 && folderID == "" { + modSeq, metaErr := a.bumpFolderModSeq(ctx, state.FolderID) + if metaErr != nil { + return metaErr + } + _, err = a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?,raw_path=?,imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END,updated_at=? WHERE id=?`, state.FolderID, finalPath, modSeq, modSeq, a.now().UTC().Format(time.RFC3339Nano), messageID) + } else { + meta, metaErr := a.nextIMAPMetadata(ctx, a.db, state.FolderID) + if metaErr != nil { + return metaErr + } + _, err = a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?,raw_path=?,imap_uid=?,imap_modseq=?,updated_at=? WHERE id=?`, state.FolderID, finalPath, meta.UID, meta.ModSeq, 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) + modSeq, metaErr := a.bumpFolderModSeq(ctx, state.FolderID) + if metaErr != nil { + return metaErr + } + _, err = a.db.ExecContext(ctx, `UPDATE messages SET raw_path=?,imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END,updated_at=? WHERE id=?`, finalPath, modSeq, modSeq, 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) + state, stateErr := a.maildirMessageState(ctx, messageID) + if stateErr != nil { + return stateErr + } + if state.FolderID != "" && state.FolderID != targetFolderID { + if _, err := a.bumpFolderModSeq(ctx, state.FolderID); err != nil { + return err + } + } + meta, metaErr := a.nextIMAPMetadata(ctx, a.db, targetFolderID) + if metaErr != nil { + return metaErr + } + _, err := a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?,imap_uid=?,imap_modseq=?,updated_at=? WHERE id=?`, targetFolderID, meta.UID, meta.ModSeq, a.now().UTC().Format(time.RFC3339Nano), messageID) return err } state, err := a.maildirMessageState(ctx, messageID) @@ -191,7 +226,16 @@ func (a *App) moveMessageMaildir(ctx context.Context, messageID, targetFolderID 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) + if state.FolderID != "" && state.FolderID != targetFolderID { + if _, err := a.bumpFolderModSeq(ctx, state.FolderID); err != nil { + return err + } + } + meta, err := a.nextIMAPMetadata(ctx, a.db, targetFolderID) + if err != nil { + return err + } + _, err = a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?,raw_path=?,imap_uid=?,imap_modseq=?,updated_at=? WHERE id=?`, targetFolderID, targetPath, meta.UID, meta.ModSeq, a.now().UTC().Format(time.RFC3339Nano), messageID) return err } @@ -274,7 +318,11 @@ func (a *App) updateMessageMaildirFlags(ctx context.Context, messageID string, r 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) + modSeq, err := a.bumpFolderModSeq(ctx, state.FolderID) + if err != nil { + return err + } + _, err = a.db.ExecContext(ctx, `UPDATE messages SET raw_path=?,imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END,updated_at=? WHERE id=?`, targetPath, modSeq, modSeq, a.now().UTC().Format(time.RFC3339Nano), messageID) return err } @@ -336,19 +384,21 @@ func (a *App) backfillSQLiteMessagesToMaildir(ctx context.Context) (int, error) } type maildirMessageState struct { - MailboxID string - FolderID string - MessageID string - RawPath string - IsRead bool - IsStarred bool + MailboxID string + FolderID string + MessageID string + RawPath string + IsRead bool + IsStarred bool + IMAPUID int64 + IMAPModSeq int64 } 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) + err := a.db.QueryRowContext(ctx, `SELECT mailbox_id,folder_id,message_id,raw_path,is_read,is_starred,imap_uid,imap_modseq FROM messages WHERE id=?`, id).Scan(&mailboxID, &folderID, &state.MessageID, &state.RawPath, &read, &starred, &state.IMAPUID, &state.IMAPModSeq) if err != nil { return state, err } diff --git a/apps/api/internal/app/personal_handlers.go b/apps/api/internal/app/personal_handlers.go index 4618677..4e63053 100644 --- a/apps/api/internal/app/personal_handlers.go +++ b/apps/api/internal/app/personal_handlers.go @@ -1091,7 +1091,11 @@ func (a *App) applyRuleActions(ctx context.Context, mailboxID, messageID string, if err := a.updateMessageMaildirFlags(ctx, messageID, nil, &starred); err != nil { return err } - if _, err := a.db.ExecContext(ctx, `UPDATE messages SET is_starred=1, updated_at=? WHERE id=?`, now, messageID); err != nil { + modSeq, err := a.updateMessageModSeq(ctx, messageID, "") + if err != nil { + return err + } + if _, err := a.db.ExecContext(ctx, `UPDATE messages SET is_starred=1, imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END, updated_at=? WHERE id=?`, modSeq, modSeq, now, messageID); err != nil { return err } case "mark-read": @@ -1099,7 +1103,11 @@ func (a *App) applyRuleActions(ctx context.Context, mailboxID, messageID string, if err := a.updateMessageMaildirFlags(ctx, messageID, &read, nil); err != nil { return err } - if _, err := a.db.ExecContext(ctx, `UPDATE messages SET is_read=1, updated_at=? WHERE id=?`, now, messageID); err != nil { + modSeq, err := a.updateMessageModSeq(ctx, messageID, "") + if err != nil { + return err + } + if _, err := a.db.ExecContext(ctx, `UPDATE messages SET is_read=1, imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END, updated_at=? WHERE id=?`, modSeq, modSeq, now, messageID); err != nil { return err } case "label": diff --git a/apps/api/internal/app/types.go b/apps/api/internal/app/types.go index bef6efd..4aec044 100644 --- a/apps/api/internal/app/types.go +++ b/apps/api/internal/app/types.go @@ -57,11 +57,14 @@ type Alias struct { } type MailFolder struct { - ID string `json:"id"` - Name string `json:"name"` - Role string `json:"role"` - UnreadCount int `json:"unreadCount"` - TotalCount int `json:"totalCount"` + ID string `json:"id"` + Name string `json:"name"` + Role string `json:"role"` + UnreadCount int `json:"unreadCount"` + TotalCount int `json:"totalCount"` + UIDValidity int64 `json:"uidValidity"` + UIDNext int64 `json:"uidNext"` + HighestModSeq int64 `json:"highestModseq"` } type MailLabel struct { @@ -81,6 +84,8 @@ type MailMessage struct { FolderID string `json:"folderId"` Folder string `json:"folder"` MessageUID string `json:"messageUid"` + IMAPUID int64 `json:"imapUid"` + IMAPModSeq int64 `json:"imapModseq"` MessageID string `json:"messageId"` Subject string `json:"subject"` From string `json:"from"` diff --git a/apps/web/src/lib/api-types.ts b/apps/web/src/lib/api-types.ts index f282913..f1cb9a6 100644 --- a/apps/web/src/lib/api-types.ts +++ b/apps/web/src/lib/api-types.ts @@ -56,11 +56,11 @@ export type AdminOverview = { users: number; activeUsers: number; domains: numbe export type Domain = { id: string; name: string; status: string; dkimSelector: string; dkimPublicKey?: string; dnsStatus: string; dnsCheckedAt?: string; createdAt: string } export type Mailbox = { id: string; userId: string; userEmail?: string; domainId: string; localPart: string; address: string; displayName: string; quotaMb: number; status: string; createdAt: string } export type Alias = { id: string; domainId: string; source: string; destination: string; enabled: boolean; createdAt: string } -export type MailFolder = { id: string; name: string; role: string; unreadCount: number; totalCount: number } +export type MailFolder = { id: string; name: string; role: string; unreadCount: number; totalCount: number; uidValidity: number; uidNext: number; highestModseq: number } export type Attachment = { id: string; messageId: string; filename: string; contentType: string; sizeBytes: number; createdAt: string } export type MailLabel = { id: string; mailboxId?: string; name: string; color: string; messageCount?: number } export type MailMessage = { - id: string; mailboxId?: string; mailboxAddress?: string; ownerEmail?: string; recipientAddress?: string; folderId: string; folder: string; messageUid: string; messageId: string; subject: string; from: string; fromName?: string; to: string[]; cc: string[]; bcc?: string[]; sentAt: string; receivedAt: string; snippet: string; bodyText?: string; bodyHtml?: string; isRead: boolean; isStarred: boolean; hasAttachments: boolean; sizeBytes: number; attachments?: Attachment[] + id: string; mailboxId?: string; mailboxAddress?: string; ownerEmail?: string; recipientAddress?: string; folderId: string; folder: string; messageUid: string; imapUid: number; imapModseq: number; messageId: string; subject: string; from: string; fromName?: string; to: string[]; cc: string[]; bcc?: string[]; sentAt: string; receivedAt: string; snippet: string; bodyText?: string; bodyHtml?: string; isRead: boolean; isStarred: boolean; hasAttachments: boolean; sizeBytes: number; attachments?: Attachment[] labels?: MailLabel[] } export type DNSRecord = { type: string; name: string; value: string; ttl: number }