feat(maildir): 支持邮件写入与同步联动
- 为已发送邮件、草稿、收件与规则移动增加 maildir 文件写入、重写和删除流程。 - 补充 SQLite 仅有消息的回填逻辑,并在同步时补齐现有 raw_path。 - 调整 Dovecot 共享邮箱行为为订阅方式,避免重复创建文件夹。 - 新增覆盖发送、草稿、移动、删除和回填场景的测试。
This commit is contained in:
@@ -715,7 +715,7 @@ func (a *App) handleDeleteMailbox(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
rows.Close()
|
rows.Close()
|
||||||
for _, messageID := range messageIDs {
|
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)
|
res, err := a.db.ExecContext(r.Context(), `DELETE FROM mailboxes WHERE id=?`, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -582,7 +582,7 @@ func (a *App) migrateLegacyBootstrapMailbox(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for _, messageID := range messageIDs {
|
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 {
|
if _, err := a.db.ExecContext(ctx, `DELETE FROM mailboxes WHERE id=?`, item.id); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -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: "<p>sent body</p>",
|
||||||
|
})
|
||||||
|
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: "<p>legacy body</p>",
|
||||||
|
})
|
||||||
|
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": "<p>draft body one</p>",
|
||||||
|
}
|
||||||
|
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"] = "<p>draft body two</p>"
|
||||||
|
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: "<p>move body</p>",
|
||||||
|
})
|
||||||
|
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 {
|
func mustDefaultDomainID(t *testing.T, a *App) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
var id string
|
var id string
|
||||||
@@ -2428,3 +2611,25 @@ func withoutPermissions(items []string, removed ...string) []string {
|
|||||||
}
|
}
|
||||||
return out
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -480,6 +480,10 @@ func (a *App) sendMailNow(ctx context.Context, user *User, mb *Mailbox, req mail
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to store sent message: %w", err)
|
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})
|
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 {
|
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)
|
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.RecipientAddr = normalizeEmail(rcpt)
|
||||||
copyMsg.MessageUID = newID("uid")
|
copyMsg.MessageUID = newID("uid")
|
||||||
copyMsg.IsRead = false
|
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
|
continue
|
||||||
}
|
}
|
||||||
if rcptMailbox.Status != "active" {
|
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.RecipientAddr = normalizeEmail(rcpt)
|
||||||
copyMsg.MessageUID = newID("uid")
|
copyMsg.MessageUID = newID("uid")
|
||||||
copyMsg.IsRead = false
|
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
|
continue
|
||||||
}
|
}
|
||||||
@@ -528,6 +536,7 @@ func (a *App) sendMailNow(ctx context.Context, user *User, mb *Mailbox, req mail
|
|||||||
copyMsg.MessageUID = newID("uid")
|
copyMsg.MessageUID = newID("uid")
|
||||||
copyMsg.IsRead = false
|
copyMsg.IsRead = false
|
||||||
if inboxMsgID, err := a.insertMessage(ctx, copyMsg, req.Attachments); err == nil {
|
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)
|
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")
|
respondError(w, http.StatusInternalServerError, "failed to save draft")
|
||||||
return
|
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)
|
msg, _ := a.messageByID(r.Context(), draftID, true)
|
||||||
respondJSON(w, http.StatusCreated, msg)
|
respondJSON(w, http.StatusCreated, msg)
|
||||||
return
|
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)
|
msg, _ := a.messageByID(r.Context(), draftID, true)
|
||||||
respondJSON(w, http.StatusOK, msg)
|
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")
|
respondError(w, http.StatusNotFound, "draft not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
a.deleteMessageMaildirFile(r.Context(), msg.ID)
|
||||||
a.deleteMessageFiles(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 {
|
if _, err := a.db.ExecContext(r.Context(), `DELETE FROM messages WHERE id=?`, msg.ID); err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "failed to delete draft")
|
respondError(w, http.StatusInternalServerError, "failed to delete draft")
|
||||||
@@ -1081,8 +1100,7 @@ func (a *App) processScheduledSend(ctx context.Context, id, mailboxID, draftID,
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if draftID != "" {
|
if draftID != "" {
|
||||||
a.deleteMessageFiles(ctx, draftID)
|
a.deleteMessage(ctx, draftID)
|
||||||
_, _ = a.db.ExecContext(ctx, `DELETE FROM messages WHERE id=?`, draftID)
|
|
||||||
}
|
}
|
||||||
sentAt := a.now().UTC().Format(time.RFC3339Nano)
|
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 {
|
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")
|
respondError(w, http.StatusInternalServerError, "failed to load folder")
|
||||||
return
|
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 := a.moveMessageMaildir(r.Context(), msg.ID, folderID); err != nil {
|
||||||
if err != nil {
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to move message")
|
respondError(w, http.StatusInternalServerError, "failed to move message")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1183,6 +1200,7 @@ func (a *App) handleDeleteMessage(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if strings.EqualFold(msg.Folder, "Trash") {
|
if strings.EqualFold(msg.Folder, "Trash") {
|
||||||
|
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)
|
||||||
} else {
|
} else {
|
||||||
@@ -1190,7 +1208,9 @@ func (a *App) handleDeleteMessage(w http.ResponseWriter, r *http.Request) {
|
|||||||
if e != nil {
|
if e != nil {
|
||||||
err = e
|
err = e
|
||||||
} else {
|
} 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 {
|
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) {
|
func (a *App) deleteMessage(ctx context.Context, messageID string) {
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ func (a *App) maildirWorker(ctx context.Context) {
|
|||||||
if n, err := a.syncMaildirOnce(ctx); err != nil {
|
if n, err := a.syncMaildirOnce(ctx); err != nil {
|
||||||
a.log.Warn("initial maildir sync failed", "error", err)
|
a.log.Warn("initial maildir sync failed", "error", err)
|
||||||
} else if n > 0 {
|
} 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)
|
ticker := time.NewTicker(interval)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
@@ -69,7 +69,7 @@ func (a *App) maildirWorker(ctx context.Context) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if n > 0 {
|
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
|
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 {
|
if exists, err := a.unregisteredMaildirMessageExists(ctx, path, msg.MessageID, msg.RecipientAddr); err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
} else if exists {
|
} else if exists {
|
||||||
|
a.attachUnregisteredMaildirRawPathToExisting(ctx, path, msg.MessageID, msg.RecipientAddr)
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
_, err = a.insertMessage(ctx, msg, attachments)
|
_, 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 {
|
if exists, err := a.maildirMessageExists(ctx, mb.ID, folder.ID, path, msg.MessageID); err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
} else if exists {
|
} else if exists {
|
||||||
|
a.attachMaildirRawPathToExisting(ctx, mb.ID, folder.ID, path, msg.MessageID)
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
id, err := a.insertMessage(ctx, msg, attachments)
|
id, err := a.insertMessage(ctx, msg, attachments)
|
||||||
@@ -338,6 +345,26 @@ func (a *App) unregisteredMaildirMessageExists(ctx context.Context, rawPath, mes
|
|||||||
return count > 0, nil
|
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 {
|
func unregisteredRecipientFromMessage(msg storedMessage, domain string) string {
|
||||||
domain = normalizeDomain(domain)
|
domain = normalizeDomain(domain)
|
||||||
for _, address := range append(append([]string{}, msg.To...), msg.CC...) {
|
for _, address := range append(append([]string{}, msg.To...), msg.CC...) {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -723,11 +723,14 @@ func (a *App) deleteMessagesInFolder(ctx context.Context, mailboxID, folder stri
|
|||||||
}
|
}
|
||||||
ids = append(ids, id)
|
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 {
|
for _, id := range ids {
|
||||||
a.deleteMessageFiles(ctx, id)
|
a.deleteMessage(ctx, id)
|
||||||
if _, err := a.db.ExecContext(ctx, `DELETE FROM messages WHERE id=?`, id); err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return int64(len(ids)), nil
|
return int64(len(ids)), nil
|
||||||
}
|
}
|
||||||
@@ -741,13 +744,31 @@ func (a *App) archiveReadInbox(ctx context.Context, mailboxID string) (int64, er
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
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`,
|
rows, err := a.db.QueryContext(ctx, `SELECT id FROM messages WHERE mailbox_id=? AND folder_id=? AND is_read=1`, mailboxID, inboxID)
|
||||||
archiveID, a.now().UTC().Format(time.RFC3339Nano), mailboxID, inboxID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
n, _ := res.RowsAffected()
|
defer rows.Close()
|
||||||
return n, nil
|
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) {
|
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)
|
_ = 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 blocked > 0 {
|
||||||
if spamID, err := a.ensureFolder(ctx, mailboxID, "Spam"); err == nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
@@ -1048,20 +1069,20 @@ func (a *App) applyRuleActions(ctx context.Context, mailboxID, messageID string,
|
|||||||
switch action.Type {
|
switch action.Type {
|
||||||
case "archive":
|
case "archive":
|
||||||
if folderID, err := a.ensureFolder(ctx, mailboxID, "Archive"); err == nil {
|
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
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "trash":
|
case "trash":
|
||||||
if folderID, err := a.ensureFolder(ctx, mailboxID, "Trash"); err == nil {
|
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
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "move":
|
case "move":
|
||||||
target := ruleTargetFolder(action.Value)
|
target := ruleTargetFolder(action.Value)
|
||||||
if folderID, err := a.ensureFolder(ctx, mailboxID, target); err == nil {
|
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
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -241,6 +241,15 @@ func (a *App) submitSMTPMessage(ctx context.Context, user *User, mb *Mailbox, ma
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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})
|
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 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 {
|
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 {
|
||||||
|
|||||||
@@ -29,23 +29,23 @@ auth_policy_request_attributes = login=%{requested_username} remote=%{rip} proto
|
|||||||
namespace inbox {
|
namespace inbox {
|
||||||
inbox = yes
|
inbox = yes
|
||||||
mailbox Drafts {
|
mailbox Drafts {
|
||||||
auto = create
|
auto = subscribe
|
||||||
special_use = \Drafts
|
special_use = \Drafts
|
||||||
}
|
}
|
||||||
mailbox Sent {
|
mailbox Sent {
|
||||||
auto = create
|
auto = subscribe
|
||||||
special_use = \Sent
|
special_use = \Sent
|
||||||
}
|
}
|
||||||
mailbox Trash {
|
mailbox Trash {
|
||||||
auto = create
|
auto = subscribe
|
||||||
special_use = \Trash
|
special_use = \Trash
|
||||||
}
|
}
|
||||||
mailbox Archive {
|
mailbox Archive {
|
||||||
auto = create
|
auto = subscribe
|
||||||
special_use = \Archive
|
special_use = \Archive
|
||||||
}
|
}
|
||||||
mailbox Spam {
|
mailbox Spam {
|
||||||
auto = create
|
auto = subscribe
|
||||||
special_use = \Junk
|
special_use = \Junk
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user