feat(imap): 补充邮件 IMAP 元数据支持
- 为文件夹和消息增加 `UID/ModSeq` 字段,并在迁移时回填与建索引。 - 让收发、移动、标记、删除、草稿等流程同步维护 `imap_uid` 和 `imap_modseq`。 - 扩展后端与前端类型,并补充相关测试覆盖。
This commit is contained in:
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
Reference in New Issue
Block a user