feat(imap): 补充邮件 IMAP 元数据支持

- 为文件夹和消息增加 `UID/ModSeq` 字段,并在迁移时回填与建索引。
- 让收发、移动、标记、删除、草稿等流程同步维护 `imap_uid` 和 `imap_modseq`。
- 扩展后端与前端类型,并补充相关测试覆盖。
This commit is contained in:
LanQin_
2026-06-24 16:23:45 +08:00
parent d28ed4adcc
commit b3669f189e
10 changed files with 533 additions and 45 deletions
+2 -2
View File
@@ -786,7 +786,7 @@ func (a *App) handleAdminMessages(w http.ResponseWriter, r *http.Request) {
} }
args = append(args, limit+1, offset) 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 FROM messages m
LEFT JOIN folders f ON f.id=m.folder_id LEFT JOIN folders f ON f.id=m.folder_id
LEFT JOIN mailboxes mb ON mb.id=m.mailbox_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) role := strings.ToLower(folder)
id = newID("fld") 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 return id, err
} }
+9 -1
View File
@@ -202,6 +202,9 @@ func (a *App) migrate(ctx context.Context) error {
mailbox_id TEXT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE, mailbox_id TEXT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
name TEXT NOT NULL, name TEXT NOT NULL,
role 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, created_at TEXT NOT NULL,
UNIQUE(mailbox_id, name) UNIQUE(mailbox_id, name)
)`, )`,
@@ -228,6 +231,8 @@ func (a *App) migrate(ctx context.Context) error {
has_attachments INTEGER NOT NULL DEFAULT 0, has_attachments INTEGER NOT NULL DEFAULT 0,
size_bytes INTEGER NOT NULL DEFAULT 0, size_bytes INTEGER NOT NULL DEFAULT 0,
raw_path TEXT NOT NULL DEFAULT '', 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, created_at TEXT NOT NULL,
updated_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 { if err := a.migrateSendQueueMessageID(ctx); err != nil {
return err return err
} }
if err := a.migrateIMAPMetadata(ctx); err != nil {
return err
}
if err := a.ensureDefaultPermissionGroups(ctx); err != nil { if err := a.ensureDefaultPermissionGroups(ctx); err != nil {
return err return err
} }
@@ -1030,7 +1038,7 @@ func (a *App) createMailboxWithPasswordHash(ctx context.Context, userID, domainI
return "", err return "", err
} }
for _, f := range defaultFolderDefs() { 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 { if err != nil {
return "", err return "", err
} }
+93
View File
@@ -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: "<p>one</p>"})
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: "<p>two</p>"})
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) { func TestMaildirSyncUpdatesMovedMessageState(t *testing.T) {
a := newTestApp(t) a := newTestApp(t)
ctx := context.Background() ctx := context.Background()
+242
View File
@@ -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, &notNull, &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(&current); 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
}
+50 -13
View File
@@ -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, 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, 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 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 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) 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{} items := []MailFolder{}
for rows.Next() { for rows.Next() {
var f MailFolder 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") respondError(w, http.StatusInternalServerError, "failed to scan folders")
return 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, like, like, like, like, like)
} }
args = append(args, limit+1, offset) 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 ?` 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...) rows, err := a.db.QueryContext(r.Context(), query, args...)
if err != nil { 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 { 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.log.Warn("failed to update maildir read flag", "message_id", msg.ID, "error", err)
} }
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) _, _ = 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 msg.IsRead = true
} }
respondJSON(w, http.StatusOK, msg) 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) _ = a.db.QueryRowContext(r.Context(), `SELECT COALESCE(SUM(size_bytes),0) FROM attachments WHERE message_id=?`, draftID).Scan(&attachmentBytes)
size += 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=?`, modSeq, err := a.updateMessageModSeq(r.Context(), draftID, existing.FolderID)
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) 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 { if err != nil {
respondError(w, http.StatusInternalServerError, "failed to update draft") respondError(w, http.StatusInternalServerError, "failed to update draft")
return return
@@ -848,6 +858,7 @@ func (a *App) handleDeleteDraft(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusInternalServerError, "failed to delete draft") respondError(w, http.StatusInternalServerError, "failed to delete draft")
return return
} }
_, _ = a.bumpFolderModSeq(r.Context(), msg.FolderID)
respondJSON(w, http.StatusOK, map[string]any{"ok": true}) 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") respondError(w, http.StatusInternalServerError, "failed to update message")
return 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 { if err != nil {
respondError(w, http.StatusInternalServerError, "failed to update message") respondError(w, http.StatusInternalServerError, "failed to update message")
return return
@@ -1172,7 +1188,12 @@ func (a *App) handleStar(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusInternalServerError, "failed to update message") respondError(w, http.StatusInternalServerError, "failed to update message")
return 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 { if err != nil {
respondError(w, http.StatusInternalServerError, "failed to update message") respondError(w, http.StatusInternalServerError, "failed to update message")
return return
@@ -1215,6 +1236,9 @@ func (a *App) handleDeleteMessage(w http.ResponseWriter, r *http.Request) {
a.deleteMessageMaildirFile(r.Context(), msg.ID) a.deleteMessageMaildirFile(r.Context(), msg.ID)
a.deleteMessageFiles(r.Context(), msg.ID) a.deleteMessageFiles(r.Context(), msg.ID)
_, err = a.db.ExecContext(r.Context(), `DELETE FROM messages WHERE id=?`, 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 { } else {
trashID, e := a.ensureFolder(r.Context(), msg.MailboxID, "Trash") trashID, e := a.ensureFolder(r.Context(), msg.MailboxID, "Trash")
if e != nil { 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) { 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) FROM messages m LEFT JOIN folders f ON f.id=m.folder_id WHERE m.id=?`, id)
msg, err := scanMessageFull(row, includeBody) msg, err := scanMessageFull(row, includeBody)
if err != nil { if err != nil {
@@ -1398,9 +1422,17 @@ func (a *App) insertMessageWithDB(ctx context.Context, db dbExecutor, msg stored
if strings.TrimSpace(msg.FolderID) != "" { if strings.TrimSpace(msg.FolderID) != "" {
folderID = 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) 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) _, 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, now, now) 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 { if err != nil {
return "", err return "", err
} }
@@ -1610,9 +1642,14 @@ func (a *App) deleteMessageFiles(ctx context.Context, messageID string) {
} }
func (a *App) deleteMessage(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.deleteMessageMaildirFile(ctx, messageID)
a.deleteMessageFiles(ctx, messageID) a.deleteMessageFiles(ctx, messageID)
_, _ = a.db.ExecContext(ctx, `DELETE FROM messages WHERE id=?`, 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 } type messageSummaryScanner interface{ Scan(dest ...any) error }
@@ -1621,7 +1658,7 @@ func scanAdminMessageSummary(row messageSummaryScanner) (MailMessage, error) {
var msg MailMessage var msg MailMessage
var toJSON, ccJSON, bccJSON, sent, received string var toJSON, ccJSON, bccJSON, sent, received string
var read, starred, hasAtt int 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 { if err != nil {
return msg, err return msg, err
} }
@@ -1635,7 +1672,7 @@ func scanMessageSummary(row messageSummaryScanner) (MailMessage, error) {
var msg MailMessage var msg MailMessage
var toJSON, ccJSON, bccJSON, sent, received string var toJSON, ccJSON, bccJSON, sent, received string
var read, starred, hasAtt int 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 { if err != nil {
return msg, err return msg, err
} }
@@ -1650,7 +1687,7 @@ func scanMessageFull(row messageSummaryScanner, includeBody bool) (MailMessage,
var toJSON, ccJSON, bccJSON, sent, received string var toJSON, ccJSON, bccJSON, sent, received string
var read, starred, hasAtt int var read, starred, hasAtt int
var bodyText, bodyHTML string 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 { if err != nil {
return msg, err return msg, err
} }
+50 -5
View File
@@ -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) { func (a *App) syncExistingMaildirMessageState(ctx context.Context, mailboxID, folderID, rawPath, messageID string, read, starred bool) (bool, error) {
now := a.now().UTC().Format(time.RFC3339Nano) 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=?`, var samePathID, oldFolderID string
folderID, rawPath, boolInt(read), boolInt(starred), now, mailboxID, rawPath) 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 { if err != nil {
return false, err return false, err
} }
if rows, _ := res.RowsAffected(); rows > 0 { _, err = a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?,raw_path=?,is_read=?,is_starred=?,imap_uid=?,imap_modseq=?,updated_at=? WHERE id=?`,
return true, nil 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) == "" { if strings.TrimSpace(messageID) == "" {
return false, nil return false, nil
@@ -461,7 +486,20 @@ func (a *App) syncExistingMaildirMessageState(ctx context.Context, mailboxID, fo
a.removeDuplicateMaildirMessage(ctx, rawPath, mailboxID, folderID, messageID) a.removeDuplicateMaildirMessage(ctx, rawPath, mailboxID, folderID, messageID)
return false, nil 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 return err == nil, err
} }
@@ -518,9 +556,16 @@ func (a *App) cleanupMissingMaildirMessages(ctx context.Context) (int, error) {
} }
for _, it := range missing { for _, it := range missing {
a.deleteMessageFiles(ctx, it.ID) 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 { if _, err := a.db.ExecContext(ctx, `DELETE FROM messages WHERE id=?`, it.ID); err != nil {
return 0, err return 0, err
} }
if folderID.Valid && folderID.String != "" {
if _, err := a.bumpFolderModSeq(ctx, folderID.String); err != nil {
return 0, err
}
}
} }
return len(missing), nil return len(missing), nil
} }
+56 -6
View File
@@ -84,7 +84,13 @@ func (a *App) writeRawMessageToMaildirFolder(ctx context.Context, messageID, fol
return err return err
} }
if folderID != "" { if folderID != "" {
oldFolderID := state.FolderID
state.FolderID = 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 == "" { if state.MailboxID == "" || state.FolderID == "" {
return nil return nil
@@ -132,16 +138,45 @@ func (a *App) writeRawMessageToMaildirFolder(ctx context.Context, messageID, fol
a.removeMaildirPath(ctx, state.RawPath) a.removeMaildirPath(ctx, state.RawPath)
} }
if updateFolder { 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 { } else {
_, err = a.db.ExecContext(ctx, `UPDATE messages SET raw_path=?, updated_at=? WHERE id=?`, finalPath, a.now().UTC().Format(time.RFC3339Nano), messageID) 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 {
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 return err
} }
func (a *App) moveMessageMaildir(ctx context.Context, messageID, targetFolderID string) error { func (a *App) moveMessageMaildir(ctx context.Context, messageID, targetFolderID string) error {
if strings.TrimSpace(a.cfg.MaildirRoot) == "" { 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 return err
} }
state, err := a.maildirMessageState(ctx, messageID) state, err := a.maildirMessageState(ctx, messageID)
@@ -191,7 +226,16 @@ func (a *App) moveMessageMaildir(ctx context.Context, messageID, targetFolderID
return err 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 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 { if err := os.Rename(state.RawPath, targetPath); err != nil {
return err 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 return err
} }
@@ -342,13 +390,15 @@ type maildirMessageState struct {
RawPath string RawPath string
IsRead bool IsRead bool
IsStarred bool IsStarred bool
IMAPUID int64
IMAPModSeq int64
} }
func (a *App) maildirMessageState(ctx context.Context, id string) (maildirMessageState, error) { func (a *App) maildirMessageState(ctx context.Context, id string) (maildirMessageState, error) {
var state maildirMessageState var state maildirMessageState
var mailboxID, folderID sql.NullString var mailboxID, folderID sql.NullString
var read, starred int 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 { if err != nil {
return state, err return state, err
} }
+10 -2
View File
@@ -1091,7 +1091,11 @@ func (a *App) applyRuleActions(ctx context.Context, mailboxID, messageID string,
if err := a.updateMessageMaildirFlags(ctx, messageID, nil, &starred); err != nil { if err := a.updateMessageMaildirFlags(ctx, messageID, nil, &starred); err != nil {
return err 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 return err
} }
case "mark-read": 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 { if err := a.updateMessageMaildirFlags(ctx, messageID, &read, nil); err != nil {
return err 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 return err
} }
case "label": case "label":
+5
View File
@@ -62,6 +62,9 @@ type MailFolder struct {
Role string `json:"role"` Role string `json:"role"`
UnreadCount int `json:"unreadCount"` UnreadCount int `json:"unreadCount"`
TotalCount int `json:"totalCount"` TotalCount int `json:"totalCount"`
UIDValidity int64 `json:"uidValidity"`
UIDNext int64 `json:"uidNext"`
HighestModSeq int64 `json:"highestModseq"`
} }
type MailLabel struct { type MailLabel struct {
@@ -81,6 +84,8 @@ type MailMessage struct {
FolderID string `json:"folderId"` FolderID string `json:"folderId"`
Folder string `json:"folder"` Folder string `json:"folder"`
MessageUID string `json:"messageUid"` MessageUID string `json:"messageUid"`
IMAPUID int64 `json:"imapUid"`
IMAPModSeq int64 `json:"imapModseq"`
MessageID string `json:"messageId"` MessageID string `json:"messageId"`
Subject string `json:"subject"` Subject string `json:"subject"`
From string `json:"from"` From string `json:"from"`
+2 -2
View File
@@ -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 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 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 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 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 MailLabel = { id: string; mailboxId?: string; name: string; color: string; messageCount?: number }
export type MailMessage = { 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[] labels?: MailLabel[]
} }
export type DNSRecord = { type: string; name: string; value: string; ttl: number } export type DNSRecord = { type: string; name: string; value: string; ttl: number }