diff --git a/apps/api/internal/app/admin_handlers.go b/apps/api/internal/app/admin_handlers.go index 3835045..537570b 100644 --- a/apps/api/internal/app/admin_handlers.go +++ b/apps/api/internal/app/admin_handlers.go @@ -715,7 +715,7 @@ func (a *App) handleDeleteMailbox(w http.ResponseWriter, r *http.Request) { } rows.Close() for _, messageID := range messageIDs { - a.deleteMessageFiles(r.Context(), messageID) + a.deleteMessage(r.Context(), messageID) } res, err := a.db.ExecContext(r.Context(), `DELETE FROM mailboxes WHERE id=?`, id) if err != nil { diff --git a/apps/api/internal/app/app.go b/apps/api/internal/app/app.go index 5069cb4..f9e33a1 100644 --- a/apps/api/internal/app/app.go +++ b/apps/api/internal/app/app.go @@ -582,7 +582,7 @@ func (a *App) migrateLegacyBootstrapMailbox(ctx context.Context) error { return err } for _, messageID := range messageIDs { - a.deleteMessageFiles(ctx, messageID) + a.deleteMessage(ctx, messageID) } if _, err := a.db.ExecContext(ctx, `DELETE FROM mailboxes WHERE id=?`, item.id); err != nil { return err diff --git a/apps/api/internal/app/app_test.go b/apps/api/internal/app/app_test.go index 701d7a7..26e4430 100644 --- a/apps/api/internal/app/app_test.go +++ b/apps/api/internal/app/app_test.go @@ -2397,6 +2397,189 @@ func TestMaildirSyncImportsSentFolder(t *testing.T) { } } +func TestWebmailSentWritesMaildirSent(t *testing.T) { + a := newTestApp(t) + ctx := context.Background() + a.cfg.MaildirRoot = t.TempDir() + user, mb := defaultAdminUserAndMailbox(t, a) + clearMailboxMessagesForTest(t, a, mb.ID) + + msg, err := a.sendMailNow(ctx, user, mb, mailComposeInput{ + MailboxID: mb.ID, + To: []string{"recipient@example.test"}, + Subject: "maildir sent copy", + Text: "sent body", + HTML: "
sent body
", + }) + if err != nil { + t.Fatal(err) + } + rawPath := maildirRawPathForTest(t, a, msg.ID) + if !strings.Contains(filepath.ToSlash(rawPath), "/.Sent/cur/") { + t.Fatalf("raw_path=%q, want .Sent/cur", rawPath) + } + raw, err := os.ReadFile(rawPath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(raw), "Subject: maildir sent copy") { + t.Fatalf("sent maildir raw missing subject:\n%s", string(raw)) + } + count, err := a.syncMaildirOnce(ctx) + if err != nil { + t.Fatal(err) + } + if count != 0 { + t.Fatalf("sync imported own sent copy=%d, want 0", count) + } +} + +func TestMaildirSyncBackfillsSQLiteOnlySent(t *testing.T) { + a := newTestApp(t) + ctx := context.Background() + a.cfg.MaildirRoot = t.TempDir() + user, mb := defaultAdminUserAndMailbox(t, a) + clearMailboxMessagesForTest(t, a, mb.ID) + + msg, err := a.sendMailNow(ctx, user, mb, mailComposeInput{ + MailboxID: mb.ID, + To: []string{"recipient@example.test"}, + Subject: "legacy sent copy", + Text: "legacy body", + HTML: "legacy body
", + }) + if err != nil { + t.Fatal(err) + } + oldPath := maildirRawPathForTest(t, a, msg.ID) + if err := os.Remove(oldPath); err != nil { + t.Fatal(err) + } + if _, err := a.db.ExecContext(ctx, `UPDATE messages SET raw_path='' WHERE id=?`, msg.ID); err != nil { + t.Fatal(err) + } + count, err := a.syncMaildirOnce(ctx) + if err != nil { + t.Fatal(err) + } + if count != 1 { + t.Fatalf("backfilled=%d, want 1", count) + } + newPath := maildirRawPathForTest(t, a, msg.ID) + if !strings.Contains(filepath.ToSlash(newPath), "/.Sent/cur/") { + t.Fatalf("raw_path=%q, want .Sent/cur", newPath) + } + var messages int + if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM messages WHERE mailbox_id=? AND message_id=?`, mb.ID, msg.MessageID).Scan(&messages); err != nil { + t.Fatal(err) + } + if messages != 1 { + t.Fatalf("messages with same Message-ID=%d, want 1", messages) + } +} + +func TestDraftWritesAndUpdatesMaildirDrafts(t *testing.T) { + a := newTestApp(t) + 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) + } + _, mb := defaultAdminUserAndMailbox(t, a) + clearMailboxMessagesForTest(t, a, mb.ID) + + var draft MailMessage + payload := map[string]any{ + "mailboxId": mb.ID, + "to": []string{"recipient@example.test"}, + "subject": "draft one", + "text": "draft body one", + "html": "draft body one
", + } + if code := client.do("POST", "/api/mail/drafts", payload, &draft); code != http.StatusCreated { + t.Fatalf("save draft code=%d draft=%+v", code, draft) + } + rawPath := maildirRawPathForTest(t, a, draft.ID) + if !strings.Contains(filepath.ToSlash(rawPath), "/.Drafts/cur/") { + t.Fatalf("raw_path=%q, want .Drafts/cur", rawPath) + } + oldRawPath := rawPath + + payload["subject"] = "draft two" + payload["text"] = "draft body two" + payload["html"] = "draft body two
" + if code := client.do("POST", "/api/mail/drafts/"+draft.ID, payload, &draft); code != http.StatusOK { + t.Fatalf("update draft code=%d draft=%+v", code, draft) + } + rawPath = maildirRawPathForTest(t, a, draft.ID) + if _, err := os.Stat(oldRawPath); err == nil && oldRawPath != rawPath { + t.Fatalf("old draft maildir file still exists: %s", oldRawPath) + } + raw, err := os.ReadFile(rawPath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(raw), "Subject: draft two") { + t.Fatalf("updated draft raw missing new subject:\n%s", string(raw)) + } +} + +func TestMoveAndDeleteMessageUpdateMaildir(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) + + msg, err := a.sendMailNow(ctx, user, mb, mailComposeInput{ + MailboxID: mb.ID, + To: []string{"recipient@example.test"}, + Subject: "move me", + Text: "move body", + HTML: "move body
", + }) + if err != nil { + t.Fatal(err) + } + sentPath := maildirRawPathForTest(t, a, msg.ID) + if code := client.do("POST", "/api/mail/messages/"+msg.ID+"/move", map[string]string{"folder": "Archive"}, nil); code != http.StatusOK { + t.Fatalf("move code=%d", code) + } + archivePath := maildirRawPathForTest(t, a, msg.ID) + if !strings.Contains(filepath.ToSlash(archivePath), "/.Archive/cur/") { + t.Fatalf("raw_path=%q, want .Archive/cur", archivePath) + } + if _, err := os.Stat(sentPath); err == nil && sentPath != archivePath { + t.Fatalf("old sent maildir file still exists: %s", sentPath) + } + if code := client.do("DELETE", "/api/mail/messages/"+msg.ID, nil, nil); code != http.StatusOK { + t.Fatalf("trash code=%d", code) + } + trashPath := maildirRawPathForTest(t, a, msg.ID) + if !strings.Contains(filepath.ToSlash(trashPath), "/.Trash/cur/") { + t.Fatalf("raw_path=%q, want .Trash/cur", trashPath) + } + if _, err := os.Stat(archivePath); err == nil && archivePath != trashPath { + t.Fatalf("old archive maildir file still exists: %s", archivePath) + } + if code := client.do("DELETE", "/api/mail/messages/"+msg.ID, nil, nil); code != http.StatusOK { + t.Fatalf("permanent delete code=%d", code) + } + if _, err := os.Stat(trashPath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("trash maildir file exists after permanent delete err=%v", err) + } +} + func mustDefaultDomainID(t *testing.T, a *App) string { t.Helper() var id string @@ -2428,3 +2611,25 @@ func withoutPermissions(items []string, removed ...string) []string { } return out } + +func maildirRawPathForTest(t *testing.T, a *App, messageID string) string { + t.Helper() + var rawPath string + if err := a.db.QueryRow(`SELECT raw_path FROM messages WHERE id=?`, messageID).Scan(&rawPath); err != nil { + t.Fatal(err) + } + if strings.TrimSpace(rawPath) == "" { + t.Fatalf("message %s raw_path is empty", messageID) + } + if _, err := os.Stat(rawPath); err != nil { + t.Fatalf("raw_path %s stat error: %v", rawPath, err) + } + return rawPath +} + +func clearMailboxMessagesForTest(t *testing.T, a *App, mailboxID string) { + t.Helper() + if _, err := a.db.Exec(`DELETE FROM messages WHERE mailbox_id=?`, mailboxID); err != nil { + t.Fatal(err) + } +} diff --git a/apps/api/internal/app/mail_handlers.go b/apps/api/internal/app/mail_handlers.go index 55ddcf4..33bc2bb 100644 --- a/apps/api/internal/app/mail_handlers.go +++ b/apps/api/internal/app/mail_handlers.go @@ -480,6 +480,10 @@ func (a *App) sendMailNow(ctx context.Context, user *User, mb *Mailbox, req mail if err != nil { return nil, fmt.Errorf("failed to store sent message: %w", err) } + if err := a.writeRawMessageToMaildir(ctx, sentID, mimeBytes, false); err != nil { + a.deleteMessage(ctx, sentID) + return nil, fmt.Errorf("failed to store sent message in maildir: %w", err) + } a.recordSendAudit(ctx, sendAuditAccepted, sendQueueStatusQueued, sendAuditInput{UserID: user.ID, MailboxID: mb.ID, SentMessageID: sentID, Source: sendSourceWebmail, MailFrom: fromAddress, HeaderFrom: fromAddress, Recipients: allRecipients}) if _, err := a.enqueueSend(ctx, sendQueueInput{UserID: user.ID, MailboxID: mb.ID, SentMessageID: sentID, MessageID: messageID, Source: sendSourceWebmail, MailFrom: fromAddress, HeaderFrom: fromAddress, Recipients: allRecipients, MIMEBytes: mimeBytes, Now: now}); err != nil { a.deleteMessage(ctx, sentID) @@ -503,7 +507,9 @@ func (a *App) sendMailNow(ctx context.Context, user *User, mb *Mailbox, req mail copyMsg.RecipientAddr = normalizeEmail(rcpt) copyMsg.MessageUID = newID("uid") copyMsg.IsRead = false - _, _ = a.insertMessage(ctx, copyMsg, req.Attachments) + if copyID, err := a.insertMessage(ctx, copyMsg, req.Attachments); err == nil { + _ = a.writeStoredMessageToMaildir(ctx, copyID, copyMsg, req.Attachments) + } continue } if rcptMailbox.Status != "active" { @@ -514,7 +520,9 @@ func (a *App) sendMailNow(ctx context.Context, user *User, mb *Mailbox, req mail copyMsg.RecipientAddr = normalizeEmail(rcpt) copyMsg.MessageUID = newID("uid") copyMsg.IsRead = false - _, _ = a.insertMessage(ctx, copyMsg, req.Attachments) + if copyID, err := a.insertMessage(ctx, copyMsg, req.Attachments); err == nil { + _ = a.writeStoredMessageToMaildir(ctx, copyID, copyMsg, req.Attachments) + } } continue } @@ -528,6 +536,7 @@ func (a *App) sendMailNow(ctx context.Context, user *User, mb *Mailbox, req mail copyMsg.MessageUID = newID("uid") copyMsg.IsRead = false if inboxMsgID, err := a.insertMessage(ctx, copyMsg, req.Attachments); err == nil { + _ = a.writeStoredMessageToMaildir(ctx, inboxMsgID, copyMsg, req.Attachments) a.applyInboundControls(ctx, inboxMsgID, rcptMailbox.ID, copyMsg.From, copyMsg.Subject) } } @@ -767,6 +776,11 @@ func (a *App) handleSaveDraft(w http.ResponseWriter, r *http.Request) { respondError(w, http.StatusInternalServerError, "failed to save draft") return } + if err := a.writeStoredMessageToMaildir(r.Context(), draftID, stored, attachments); err != nil { + a.deleteMessage(r.Context(), draftID) + respondError(w, http.StatusInternalServerError, "failed to save draft") + return + } msg, _ := a.messageByID(r.Context(), draftID, true) respondJSON(w, http.StatusCreated, msg) return @@ -810,6 +824,10 @@ func (a *App) handleSaveDraft(w http.ResponseWriter, r *http.Request) { } } } + if err := a.rewriteMessageMaildir(r.Context(), draftID); err != nil { + respondError(w, http.StatusInternalServerError, "failed to update draft") + return + } msg, _ := a.messageByID(r.Context(), draftID, true) respondJSON(w, http.StatusOK, msg) } @@ -820,6 +838,7 @@ func (a *App) handleDeleteDraft(w http.ResponseWriter, r *http.Request) { respondError(w, http.StatusNotFound, "draft not found") return } + a.deleteMessageMaildirFile(r.Context(), msg.ID) a.deleteMessageFiles(r.Context(), msg.ID) if _, err := a.db.ExecContext(r.Context(), `DELETE FROM messages WHERE id=?`, msg.ID); err != nil { respondError(w, http.StatusInternalServerError, "failed to delete draft") @@ -1081,8 +1100,7 @@ func (a *App) processScheduledSend(ctx context.Context, id, mailboxID, draftID, return } if draftID != "" { - a.deleteMessageFiles(ctx, draftID) - _, _ = a.db.ExecContext(ctx, `DELETE FROM messages WHERE id=?`, draftID) + a.deleteMessage(ctx, draftID) } sentAt := a.now().UTC().Format(time.RFC3339Nano) if _, err := a.db.ExecContext(ctx, `UPDATE scheduled_sends SET status='sent',sent_at=?,updated_at=?,error='' WHERE id=?`, sentAt, sentAt, id); err != nil { @@ -1168,8 +1186,7 @@ func (a *App) handleMove(w http.ResponseWriter, r *http.Request) { respondError(w, http.StatusInternalServerError, "failed to load folder") return } - _, err = a.db.ExecContext(r.Context(), `UPDATE messages SET folder_id=?, updated_at=? WHERE id=?`, folderID, a.now().UTC().Format(time.RFC3339Nano), msg.ID) - if err != nil { + if err := a.moveMessageMaildir(r.Context(), msg.ID, folderID); err != nil { respondError(w, http.StatusInternalServerError, "failed to move message") return } @@ -1183,6 +1200,7 @@ func (a *App) handleDeleteMessage(w http.ResponseWriter, r *http.Request) { return } if strings.EqualFold(msg.Folder, "Trash") { + a.deleteMessageMaildirFile(r.Context(), msg.ID) a.deleteMessageFiles(r.Context(), msg.ID) _, err = a.db.ExecContext(r.Context(), `DELETE FROM messages WHERE id=?`, msg.ID) } else { @@ -1190,7 +1208,9 @@ func (a *App) handleDeleteMessage(w http.ResponseWriter, r *http.Request) { if e != nil { err = e } else { - _, err = a.db.ExecContext(r.Context(), `UPDATE messages SET folder_id=?, updated_at=? WHERE id=?`, trashID, a.now().UTC().Format(time.RFC3339Nano), msg.ID) + if e := a.moveMessageMaildir(r.Context(), msg.ID, trashID); e != nil { + err = e + } } } if err != nil { @@ -1578,6 +1598,7 @@ func (a *App) deleteMessageFiles(ctx context.Context, messageID string) { } func (a *App) deleteMessage(ctx context.Context, messageID string) { + a.deleteMessageMaildirFile(ctx, messageID) a.deleteMessageFiles(ctx, messageID) _, _ = a.db.ExecContext(ctx, `DELETE FROM messages WHERE id=?`, messageID) } diff --git a/apps/api/internal/app/maildir_sync.go b/apps/api/internal/app/maildir_sync.go index d42edfb..36e591a 100644 --- a/apps/api/internal/app/maildir_sync.go +++ b/apps/api/internal/app/maildir_sync.go @@ -53,7 +53,7 @@ func (a *App) maildirWorker(ctx context.Context) { if n, err := a.syncMaildirOnce(ctx); err != nil { a.log.Warn("initial maildir sync failed", "error", err) } else if n > 0 { - a.log.Info("initial maildir sync imported messages", "count", n) + a.log.Info("initial maildir sync processed messages", "count", n) } ticker := time.NewTicker(interval) defer ticker.Stop() @@ -69,7 +69,7 @@ func (a *App) maildirWorker(ctx context.Context) { continue } if n > 0 { - a.log.Info("maildir sync imported messages", "count", n) + a.log.Info("maildir sync processed messages", "count", n) } } } @@ -132,6 +132,11 @@ func (a *App) syncMaildirOnce(ctx context.Context) (int, error) { } } } + backfilled, err := a.backfillSQLiteMessagesToMaildir(ctx) + if err != nil { + return imported, err + } + imported += backfilled return imported, nil } @@ -248,6 +253,7 @@ func (a *App) syncUnregisteredMaildirFile(ctx context.Context, mb maildirMailbox if exists, err := a.unregisteredMaildirMessageExists(ctx, path, msg.MessageID, msg.RecipientAddr); err != nil { return false, err } else if exists { + a.attachUnregisteredMaildirRawPathToExisting(ctx, path, msg.MessageID, msg.RecipientAddr) return false, nil } _, err = a.insertMessage(ctx, msg, attachments) @@ -311,6 +317,7 @@ func (a *App) syncMaildirFile(ctx context.Context, mb maildirMailbox, folder mai if exists, err := a.maildirMessageExists(ctx, mb.ID, folder.ID, path, msg.MessageID); err != nil { return false, err } else if exists { + a.attachMaildirRawPathToExisting(ctx, mb.ID, folder.ID, path, msg.MessageID) return false, nil } id, err := a.insertMessage(ctx, msg, attachments) @@ -338,6 +345,26 @@ func (a *App) unregisteredMaildirMessageExists(ctx context.Context, rawPath, mes return count > 0, nil } +func (a *App) attachMaildirRawPathToExisting(ctx context.Context, mailboxID, folderID, rawPath, messageID string) { + if strings.TrimSpace(messageID) == "" || strings.TrimSpace(rawPath) == "" { + return + } + if _, err := a.db.ExecContext(ctx, `UPDATE messages SET raw_path=?,updated_at=? WHERE mailbox_id=? AND folder_id=? AND message_id=? AND message_id <> '' AND raw_path=''`, + rawPath, a.now().UTC().Format(time.RFC3339Nano), mailboxID, folderID, messageID); err != nil { + a.log.Warn("failed to attach maildir raw path to existing message", "path", rawPath, "error", err) + } +} + +func (a *App) attachUnregisteredMaildirRawPathToExisting(ctx context.Context, rawPath, messageID, recipient string) { + if strings.TrimSpace(messageID) == "" || strings.TrimSpace(rawPath) == "" { + return + } + if _, err := a.db.ExecContext(ctx, `UPDATE messages SET raw_path=?,updated_at=? WHERE mailbox_id IS NULL AND recipient_addr=? AND message_id=? AND message_id <> '' AND raw_path=''`, + rawPath, a.now().UTC().Format(time.RFC3339Nano), recipient, messageID); err != nil { + a.log.Warn("failed to attach unregistered maildir raw path to existing message", "path", rawPath, "error", err) + } +} + func unregisteredRecipientFromMessage(msg storedMessage, domain string) string { domain = normalizeDomain(domain) for _, address := range append(append([]string{}, msg.To...), msg.CC...) { diff --git a/apps/api/internal/app/maildir_write.go b/apps/api/internal/app/maildir_write.go new file mode 100644 index 0000000..d41763d --- /dev/null +++ b/apps/api/internal/app/maildir_write.go @@ -0,0 +1,421 @@ +package app + +import ( + "context" + "database/sql" + "encoding/base64" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +func (a *App) writeStoredMessageToMaildir(ctx context.Context, messageID string, msg storedMessage, attachments []AttachmentInput) error { + if strings.TrimSpace(a.cfg.MaildirRoot) == "" || strings.TrimSpace(msg.MailboxID) == "" || strings.TrimSpace(msg.FolderID) == "" { + return nil + } + raw, err := BuildMIME(MIMEMessage{ + From: msg.From, + FromName: msg.FromName, + To: msg.To, + CC: msg.CC, + BCC: msg.BCC, + Subject: msg.Subject, + Text: msg.BodyText, + HTML: msg.BodyHTML, + MessageID: msg.MessageID, + Date: messageDate(msg), + Attachments: attachments, + }) + if err != nil { + return err + } + return a.writeRawMessageToMaildir(ctx, messageID, raw, false) +} + +func (a *App) rewriteMessageMaildir(ctx context.Context, messageID string) error { + if strings.TrimSpace(a.cfg.MaildirRoot) == "" { + return nil + } + msg, err := a.storedMessageByID(ctx, messageID) + if err != nil { + return err + } + attachments, err := a.attachmentInputsForMessage(ctx, messageID) + if err != nil { + return err + } + raw, err := BuildMIME(MIMEMessage{ + From: msg.From, + FromName: msg.FromName, + To: msg.To, + CC: msg.CC, + BCC: msg.BCC, + Subject: msg.Subject, + Text: msg.BodyText, + HTML: msg.BodyHTML, + MessageID: msg.MessageID, + Date: messageDate(msg), + Attachments: attachments, + }) + if err != nil { + return err + } + return a.writeRawMessageToMaildir(ctx, messageID, raw, true) +} + +func (a *App) writeRawMessageToMaildir(ctx context.Context, messageID string, raw []byte, replace bool) error { + if strings.TrimSpace(a.cfg.MaildirRoot) == "" { + return nil + } + state, err := a.maildirMessageState(ctx, messageID) + if err != nil { + return err + } + if state.MailboxID == "" || state.FolderID == "" { + return nil + } + if !replace && state.RawPath != "" { + if ok, err := a.pathIsUnderMaildirRoot(state.RawPath); err != nil { + return err + } else if ok { + if _, err := os.Stat(state.RawPath); err == nil { + return nil + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + } + } + mb, err := a.maildirMailboxByID(ctx, state.MailboxID) + if err != nil { + return err + } + folderName, err := a.folderNameByID(ctx, state.FolderID) + if err != nil { + return err + } + base := filepath.Join(strings.TrimSpace(a.cfg.MaildirRoot), mb.Domain, mb.LocalPart, "Maildir") + folderBase := maildirFolderPath(base, folderName) + subdir := "cur" + if strings.EqualFold(folderName, "Inbox") && !state.IsRead { + subdir = "new" + } + if err := ensureMaildirFolderDirs(folderBase); err != nil { + return err + } + filename := maildirFilename(messageID, state.MessageID) + tmpPath := filepath.Join(folderBase, "tmp", filename) + finalPath := filepath.Join(folderBase, subdir, filename) + if err := os.WriteFile(tmpPath, raw, 0o600); err != nil { + return err + } + if err := os.Rename(tmpPath, finalPath); err != nil { + _ = os.Remove(tmpPath) + return err + } + if replace || state.RawPath != "" { + a.removeMaildirPath(ctx, state.RawPath) + } + _, err = a.db.ExecContext(ctx, `UPDATE messages SET raw_path=?, updated_at=? WHERE id=?`, finalPath, a.now().UTC().Format(time.RFC3339Nano), messageID) + return err +} + +func (a *App) moveMessageMaildir(ctx context.Context, messageID, targetFolderID string) error { + if strings.TrimSpace(a.cfg.MaildirRoot) == "" { + _, err := a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?, updated_at=? WHERE id=?`, targetFolderID, a.now().UTC().Format(time.RFC3339Nano), messageID) + return err + } + state, err := a.maildirMessageState(ctx, messageID) + if err != nil { + return err + } + if state.MailboxID == "" { + return nil + } + state.FolderID = targetFolderID + if state.RawPath == "" { + return a.writeMessageToNewMaildirFolder(ctx, messageID, targetFolderID) + } + ok, err := a.pathIsUnderMaildirRoot(state.RawPath) + if err != nil { + return err + } + if !ok { + return a.writeMessageToNewMaildirFolder(ctx, messageID, targetFolderID) + } + if _, err := os.Stat(state.RawPath); err != nil { + if errors.Is(err, os.ErrNotExist) { + return a.writeMessageToNewMaildirFolder(ctx, messageID, targetFolderID) + } + return err + } + mb, err := a.maildirMailboxByID(ctx, state.MailboxID) + if err != nil { + return err + } + folderName, err := a.folderNameByID(ctx, targetFolderID) + if err != nil { + return err + } + base := filepath.Join(strings.TrimSpace(a.cfg.MaildirRoot), mb.Domain, mb.LocalPart, "Maildir") + folderBase := maildirFolderPath(base, folderName) + if err := ensureMaildirFolderDirs(folderBase); err != nil { + return err + } + subdir := "cur" + if strings.EqualFold(folderName, "Inbox") && !state.IsRead { + subdir = "new" + } + targetPath := filepath.Join(folderBase, subdir, filepath.Base(state.RawPath)) + if filepath.Clean(targetPath) != filepath.Clean(state.RawPath) { + if err := os.Rename(state.RawPath, targetPath); err != nil { + 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) + return err +} + +func (a *App) writeMessageToNewMaildirFolder(ctx context.Context, messageID, folderID string) error { + msg, err := a.storedMessageByID(ctx, messageID) + if err != nil { + return err + } + msg.FolderID = folderID + attachments, err := a.attachmentInputsForMessage(ctx, messageID) + if err != nil { + return err + } + raw, err := BuildMIME(MIMEMessage{ + From: msg.From, + FromName: msg.FromName, + To: msg.To, + CC: msg.CC, + BCC: msg.BCC, + Subject: msg.Subject, + Text: msg.BodyText, + HTML: msg.BodyHTML, + MessageID: msg.MessageID, + Date: messageDate(msg), + Attachments: attachments, + }) + if err != nil { + return err + } + _, err = a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?, updated_at=? WHERE id=?`, folderID, a.now().UTC().Format(time.RFC3339Nano), messageID) + if err != nil { + return err + } + return a.writeRawMessageToMaildir(ctx, messageID, raw, true) +} + +func (a *App) deleteMessageMaildirFile(ctx context.Context, messageID string) { + var rawPath string + if err := a.db.QueryRowContext(ctx, `SELECT raw_path FROM messages WHERE id=?`, messageID).Scan(&rawPath); err != nil { + return + } + a.removeMaildirPath(ctx, rawPath) +} + +func (a *App) removeMaildirPath(ctx context.Context, rawPath string) { + rawPath = strings.TrimSpace(rawPath) + if rawPath == "" { + return + } + ok, err := a.pathIsUnderMaildirRoot(rawPath) + if err != nil || !ok { + if err != nil { + a.log.Warn("failed to validate maildir path", "path", rawPath, "error", err) + } + return + } + if err := os.Remove(rawPath); err != nil && !errors.Is(err, os.ErrNotExist) { + a.log.Warn("failed to remove maildir message", "path", rawPath, "error", err) + } +} + +func (a *App) backfillSQLiteMessagesToMaildir(ctx context.Context) (int, error) { + if strings.TrimSpace(a.cfg.MaildirRoot) == "" { + return 0, nil + } + rows, err := a.db.QueryContext(ctx, `SELECT id FROM messages WHERE COALESCE(mailbox_id,'')<>'' AND COALESCE(folder_id,'')<>'' AND raw_path='' ORDER BY created_at LIMIT 100`) + if err != nil { + return 0, err + } + var ids []string + for rows.Next() { + select { + case <-ctx.Done(): + rows.Close() + return 0, ctx.Err() + default: + } + var id string + if err := rows.Scan(&id); err != nil { + rows.Close() + return 0, err + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + rows.Close() + return 0, err + } + if err := rows.Close(); err != nil { + return 0, err + } + count := 0 + for _, id := range ids { + if err := a.rewriteMessageMaildir(ctx, id); err != nil { + return count, err + } + count++ + } + return count, nil +} + +type maildirMessageState struct { + MailboxID string + FolderID string + MessageID string + RawPath string + IsRead bool +} + +func (a *App) maildirMessageState(ctx context.Context, id string) (maildirMessageState, error) { + var state maildirMessageState + var mailboxID, folderID sql.NullString + var read int + err := a.db.QueryRowContext(ctx, `SELECT mailbox_id,folder_id,message_id,raw_path,is_read FROM messages WHERE id=?`, id).Scan(&mailboxID, &folderID, &state.MessageID, &state.RawPath, &read) + if err != nil { + return state, err + } + state.MailboxID = mailboxID.String + state.FolderID = folderID.String + state.IsRead = intBool(read) + return state, nil +} + +func (a *App) storedMessageByID(ctx context.Context, id string) (storedMessage, error) { + row := a.db.QueryRowContext(ctx, `SELECT COALESCE(mailbox_id,''),COALESCE(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,raw_path FROM messages WHERE id=?`, id) + var msg storedMessage + var toJSON, ccJSON, bccJSON, sent, received string + var read, starred int + err := row.Scan(&msg.MailboxID, &msg.FolderID, &msg.RecipientAddr, &msg.MessageUID, &msg.MessageID, &msg.Subject, &msg.From, &msg.FromName, &toJSON, &ccJSON, &bccJSON, &sent, &received, &msg.Snippet, &msg.BodyText, &msg.BodyHTML, &read, &starred, &msg.RawPath) + if err != nil { + return msg, err + } + msg.To = jsonDecodeSlice(toJSON) + msg.CC = jsonDecodeSlice(ccJSON) + msg.BCC = jsonDecodeSlice(bccJSON) + msg.SentAt = parseTime(sent) + msg.ReceivedAt = parseTime(received) + msg.IsRead = intBool(read) + msg.IsStarred = intBool(starred) + return msg, nil +} + +func (a *App) attachmentInputsForMessage(ctx context.Context, messageID string) ([]AttachmentInput, error) { + rows, err := a.db.QueryContext(ctx, `SELECT filename,content_type,storage_path FROM attachments WHERE message_id=? ORDER BY filename`, messageID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []AttachmentInput + for rows.Next() { + var filename, contentType, storagePath string + if err := rows.Scan(&filename, &contentType, &storagePath); err != nil { + return nil, err + } + data, err := os.ReadFile(storagePath) + if err != nil { + return nil, err + } + out = append(out, AttachmentInput{Filename: filename, ContentType: contentType, ContentBase64: base64.StdEncoding.EncodeToString(data)}) + } + return out, rows.Err() +} + +func (a *App) maildirMailboxByID(ctx context.Context, mailboxID string) (maildirMailbox, error) { + var mb maildirMailbox + err := a.db.QueryRowContext(ctx, `SELECT m.id,m.address,m.local_part,d.name FROM mailboxes m JOIN domains d ON d.id=m.domain_id WHERE m.id=?`, mailboxID).Scan(&mb.ID, &mb.Address, &mb.LocalPart, &mb.Domain) + return mb, err +} + +func (a *App) folderNameByID(ctx context.Context, folderID string) (string, error) { + var name string + err := a.db.QueryRowContext(ctx, `SELECT name FROM folders WHERE id=?`, folderID).Scan(&name) + return name, err +} + +func (a *App) pathIsUnderMaildirRoot(path string) (bool, error) { + root := strings.TrimSpace(a.cfg.MaildirRoot) + if root == "" || strings.TrimSpace(path) == "" { + return false, nil + } + rootAbs, err := filepath.Abs(root) + if err != nil { + return false, err + } + pathAbs, err := filepath.Abs(path) + if err != nil { + return false, err + } + rel, err := filepath.Rel(rootAbs, pathAbs) + if err != nil { + return false, err + } + return rel != "." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && rel != "..", nil +} + +func ensureMaildirFolderDirs(folderBase string) error { + for _, sub := range []string{"tmp", "new", "cur"} { + if err := os.MkdirAll(filepath.Join(folderBase, sub), 0o755); err != nil { + return err + } + } + return nil +} + +func maildirFilename(messageID, headerMessageID string) string { + base := strings.TrimSpace(headerMessageID) + if base == "" { + base = messageID + } + return fmt.Sprintf("%d.%s.%s", time.Now().UnixNano(), safeMaildirName(messageID), safeMaildirName(base)) +} + +func safeMaildirName(value string) string { + value = strings.Trim(value, "<>") + var b strings.Builder + for _, r := range value { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9': + b.WriteRune(r) + case r == '.', r == '_', r == '-', r == '@': + b.WriteRune(r) + default: + b.WriteByte('_') + } + } + out := strings.Trim(b.String(), "._-") + if out == "" { + out = "message" + } + if len(out) > 120 { + out = out[:120] + } + return out +} + +func messageDate(msg storedMessage) time.Time { + if !msg.SentAt.IsZero() { + return msg.SentAt + } + if !msg.ReceivedAt.IsZero() { + return msg.ReceivedAt + } + return time.Now().UTC() +} diff --git a/apps/api/internal/app/personal_handlers.go b/apps/api/internal/app/personal_handlers.go index ca87279..a2162a5 100644 --- a/apps/api/internal/app/personal_handlers.go +++ b/apps/api/internal/app/personal_handlers.go @@ -723,11 +723,14 @@ func (a *App) deleteMessagesInFolder(ctx context.Context, mailboxID, folder stri } ids = append(ids, id) } + if err := rows.Err(); err != nil { + return 0, err + } + if err := rows.Close(); err != nil { + return 0, err + } for _, id := range ids { - a.deleteMessageFiles(ctx, id) - if _, err := a.db.ExecContext(ctx, `DELETE FROM messages WHERE id=?`, id); err != nil { - return 0, err - } + a.deleteMessage(ctx, id) } return int64(len(ids)), nil } @@ -741,13 +744,31 @@ func (a *App) archiveReadInbox(ctx context.Context, mailboxID string) (int64, er if err != nil { return 0, err } - res, err := a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?, updated_at=? WHERE mailbox_id=? AND folder_id=? AND is_read=1`, - archiveID, a.now().UTC().Format(time.RFC3339Nano), mailboxID, inboxID) + rows, err := a.db.QueryContext(ctx, `SELECT id FROM messages WHERE mailbox_id=? AND folder_id=? AND is_read=1`, mailboxID, inboxID) if err != nil { return 0, err } - n, _ := res.RowsAffected() - return n, nil + defer rows.Close() + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return 0, err + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + return 0, err + } + if err := rows.Close(); err != nil { + return 0, err + } + for _, id := range ids { + if err := a.moveMessageMaildir(ctx, id, archiveID); err != nil { + return 0, err + } + } + return int64(len(ids)), nil } func scanContact(row messageSummaryScanner) (Contact, error) { @@ -861,7 +882,7 @@ func (a *App) applyInboundControls(ctx context.Context, messageID, mailboxID, fr _ = a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM blocked_senders WHERE user_id=? AND (mailbox_id='' OR mailbox_id=?) AND email=?`, userID, mailboxID, from).Scan(&blocked) if blocked > 0 { if spamID, err := a.ensureFolder(ctx, mailboxID, "Spam"); err == nil { - _, _ = a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?, updated_at=? WHERE id=?`, spamID, a.now().UTC().Format(time.RFC3339Nano), messageID) + _ = a.moveMessageMaildir(ctx, messageID, spamID) } return } @@ -1048,20 +1069,20 @@ func (a *App) applyRuleActions(ctx context.Context, mailboxID, messageID string, switch action.Type { case "archive": if folderID, err := a.ensureFolder(ctx, mailboxID, "Archive"); err == nil { - if _, err := a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?, updated_at=? WHERE id=?`, folderID, now, messageID); err != nil { + if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil { return err } } case "trash": if folderID, err := a.ensureFolder(ctx, mailboxID, "Trash"); err == nil { - if _, err := a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?, updated_at=? WHERE id=?`, folderID, now, messageID); err != nil { + if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil { return err } } case "move": target := ruleTargetFolder(action.Value) if folderID, err := a.ensureFolder(ctx, mailboxID, target); err == nil { - if _, err := a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?, updated_at=? WHERE id=?`, folderID, now, messageID); err != nil { + if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil { return err } } diff --git a/apps/api/internal/app/submission.go b/apps/api/internal/app/submission.go index 27195a0..c8fa2a4 100644 --- a/apps/api/internal/app/submission.go +++ b/apps/api/internal/app/submission.go @@ -241,6 +241,15 @@ func (a *App) submitSMTPMessage(ctx context.Context, user *User, mb *Mailbox, ma if err != nil { return err } + if insertedSent { + if err := a.rewriteMessageMaildir(ctx, sentID); err != nil { + a.deleteMessage(ctx, sentID) + if sentFolderID, ferr := a.ensureFolder(ctx, mb.ID, "Sent"); ferr == nil { + a.deleteSentDedupeKey(ctx, mb.ID, sentFolderID, msg.MessageID) + } + return err + } + } a.recordSendAudit(ctx, sendAuditAccepted, sendQueueStatusQueued, sendAuditInput{UserID: user.ID, MailboxID: mb.ID, SentMessageID: sentID, Source: sendSourceSubmission, MailFrom: mailFrom, HeaderFrom: msg.From, Recipients: recipients}) if sentID != "" { if _, err := a.enqueueSend(ctx, sendQueueInput{UserID: user.ID, MailboxID: mb.ID, SentMessageID: sentID, MessageID: msg.MessageID, Source: sendSourceSubmission, MailFrom: mailFrom, HeaderFrom: msg.From, Recipients: recipients, MIMEBytes: prepared, Now: a.now().UTC()}); err != nil { diff --git a/deploy/dovecot/dovecot.conf b/deploy/dovecot/dovecot.conf index 7ddc120..4f29ab6 100644 --- a/deploy/dovecot/dovecot.conf +++ b/deploy/dovecot/dovecot.conf @@ -29,23 +29,23 @@ auth_policy_request_attributes = login=%{requested_username} remote=%{rip} proto namespace inbox { inbox = yes mailbox Drafts { - auto = create + auto = subscribe special_use = \Drafts } mailbox Sent { - auto = create + auto = subscribe special_use = \Sent } mailbox Trash { - auto = create + auto = subscribe special_use = \Trash } mailbox Archive { - auto = create + auto = subscribe special_use = \Archive } mailbox Spam { - auto = create + auto = subscribe special_use = \Junk } }