diff --git a/apps/api/internal/app/app_test.go b/apps/api/internal/app/app_test.go index 69d1d64..2d52860 100644 --- a/apps/api/internal/app/app_test.go +++ b/apps/api/internal/app/app_test.go @@ -967,6 +967,80 @@ func TestMaildirSyncImportsRFC822(t *testing.T) { } } +func TestMaildirSyncImportsSentFolder(t *testing.T) { + a := newTestApp(t) + ctx := context.Background() + root := t.TempDir() + a.cfg.MaildirRoot = root + adminUser, _, err := a.userByEmail(ctx, "admin@lanqin.local") + if err != nil { + t.Fatal(err) + } + var mailboxID string + if err := a.db.QueryRowContext(ctx, `SELECT id FROM mailboxes WHERE user_id=? AND address=?`, adminUser.ID, "admin@lanqin.local").Scan(&mailboxID); err != nil { + t.Fatal(err) + } + if _, err := a.db.ExecContext(ctx, `DELETE FROM messages WHERE mailbox_id=?`, mailboxID); err != nil { + t.Fatal(err) + } + sentFolderID, err := a.ensureFolder(ctx, mailboxID, "Sent") + if err != nil { + t.Fatal(err) + } + + mailboxes, err := a.maildirMailboxes(ctx) + if err != nil { + t.Fatal(err) + } + var admin maildirMailbox + for _, mb := range mailboxes { + if mb.Address == "admin@lanqin.local" { + admin = mb + break + } + } + if admin.ID == "" { + t.Fatal("admin mailbox not found") + } + + dir := filepath.Join(root, admin.Domain, admin.LocalPart, "Maildir", ".Sent", "new") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + raw := strings.Join([]string{ + "From: admin@lanqin.local", + "To: recipient@example.test", + "Subject: SMTP sent archive", + "Message-Id: ", + "Date: Sat, 13 Jun 2026 14:00:00 +0000", + "MIME-Version: 1.0", + "Content-Type: text/plain; charset=utf-8", + "", + "archived from smtp client", + }, "\r\n") + if err := os.WriteFile(filepath.Join(dir, "1749823200.M1P1.sent"), []byte(raw), 0o600); err != nil { + t.Fatal(err) + } + + count, err := a.syncMaildirOnce(ctx) + if err != nil { + t.Fatal(err) + } + if count != 1 { + t.Fatalf("imported=%d, want 1", count) + } + + var folderID, subject string + var read int + err = a.db.QueryRow(`SELECT folder_id, subject, is_read FROM messages WHERE mailbox_id=? AND message_id=''`, admin.ID).Scan(&folderID, &subject, &read) + if err != nil { + t.Fatal(err) + } + if folderID != sentFolderID || subject != "SMTP sent archive" || read != 1 { + t.Fatalf("unexpected sent import folder=%q want=%q subject=%q read=%d", folderID, sentFolderID, subject, read) + } +} + func mustDefaultDomainID(t *testing.T, a *App) string { t.Helper() var id string diff --git a/apps/api/internal/app/maildir_sync.go b/apps/api/internal/app/maildir_sync.go index b2171fa..8127f70 100644 --- a/apps/api/internal/app/maildir_sync.go +++ b/apps/api/internal/app/maildir_sync.go @@ -287,6 +287,7 @@ func (a *App) syncMaildirFile(ctx context.Context, mb maildirMailbox, folder mai } msg.MailboxID = mb.ID msg.FolderID = folder.ID + msg.IsRead = !strings.EqualFold(folder.Name, "Inbox") msg.RawPath = path if msg.MessageUID == "" { msg.MessageUID = newID("uid") diff --git a/apps/web/src/components/ui/dropdown-menu.tsx b/apps/web/src/components/ui/dropdown-menu.tsx index e804bca..5e326d3 100644 --- a/apps/web/src/components/ui/dropdown-menu.tsx +++ b/apps/web/src/components/ui/dropdown-menu.tsx @@ -25,7 +25,7 @@ const DropdownMenuSubTrigger = React.forwardRef< svg]:size-4 [&>svg]:shrink-0", + "relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground data-[highlighted]:bg-accent data-[highlighted]:font-medium data-[highlighted]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:cursor-default data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0", inset && "pl-8", className )} @@ -98,7 +98,7 @@ const DropdownMenuCheckboxItem = React.forwardRef< = { + bold: "bold", + italic: "italic", + underline: "underline", + strikeThrough: "strikeThrough", + insertUnorderedList: "unorderedList", + insertOrderedList: "orderedList", + justifyLeft: "justifyLeft", + justifyCenter: "justifyCenter", + justifyRight: "justifyRight", + } + const key = keyByCommand[command] + setToolbarState((current) => { + const next = { ...current, [key]: !current[key] } + if (command === "justifyLeft" || command === "justifyCenter" || command === "justifyRight") { + next.justifyLeft = command === "justifyLeft" + next.justifyCenter = command === "justifyCenter" + next.justifyRight = command === "justifyRight" + } + if (command === "insertUnorderedList" && next.unorderedList) next.orderedList = false + if (command === "insertOrderedList" && next.orderedList) next.unorderedList = false + return next + }) + runCommand(command) + } + function updateToolbarState() { const currentElement = selectionElementInside(editorRef.current) const computedStyle = currentElement ? window.getComputedStyle(currentElement) : null @@ -2001,29 +2039,29 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, onC return (
-
+
runCommand("undo")}> runCommand("redo")}> } onClick={() => openInsertDialog("image")} /> - - fileInputRef.current?.click()}>附件 - openInsertDialog("link")}>链接 - openInsertDialog("image")}>图片链接 - runCommand("insertHorizontalRule")}>分隔线 + fileInputRef.current?.click()}>附件 + openInsertDialog("link")}>链接 + openInsertDialog("image")}>图片链接 + runCommand("insertHorizontalRule")}>分隔线 } onClick={() => fileInputRef.current?.click()} /> } onClick={() => setScheduleOpen(true)} /> - @@ -2044,18 +2082,18 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, onC
{formatOpen && ( -
+
runCommand("removeFormat")}> - {composerFontOptions.map((font) => ( - applyFont(font)}> + applyFont(font)}> {font} @@ -2064,13 +2102,13 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, onC - {composerFontSizeOptions.map(([size, label]) => ( - applyFontSize(size)}> + applyFontSize(size)}> {label} @@ -2078,19 +2116,19 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, onC - runCommand("bold")}> - runCommand("italic")}> - runCommand("underline")}> - runCommand("strikeThrough")}> + optimisticToggle("bold")}> + optimisticToggle("italic")}> + optimisticToggle("underline")}> + optimisticToggle("strikeThrough")}> - {[["#111827", "默认"], ["#dc2626", "红色"], ["#2563eb", "蓝色"], ["#16a34a", "绿色"]].map(([color, label]) => ( - runCommand("foreColor", color)}> + runCommand("foreColor", color)}> {label} ))} @@ -2098,27 +2136,27 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, onC - {[["transparent", "无高亮"], ["#fef3c7", "黄色"], ["#dcfce7", "绿色"], ["#dbeafe", "蓝色"]].map(([color, label]) => ( - runCommand("backColor", color)}> + runCommand("backColor", color)}> {label} ))} - runCommand("insertUnorderedList")}> - runCommand("insertOrderedList")}> + optimisticToggle("insertUnorderedList")}> + optimisticToggle("insertOrderedList")}> runCommand("outdent")}> runCommand("indent")}> - runCommand("justifyLeft")}> - runCommand("justifyCenter")}> - runCommand("justifyRight")}> + optimisticToggle("justifyLeft")}> + optimisticToggle("justifyCenter")}> + optimisticToggle("justifyRight")}> runCommand("formatBlock", "blockquote")}> runCommand("formatBlock", "pre")}>
@@ -2164,7 +2202,7 @@ function MailBodyComposer({ defaultValue, defaultHtml, files, signatureText, onC function ToolbarTextButton({ label, icon, active, disabled, onClick }: { label: string; icon: React.ReactNode; active?: boolean; disabled?: boolean; onClick?: () => void }) { return ( - ) @@ -2384,12 +2422,16 @@ function ToolbarButton({ label, children, active, onClick, disabled }: { label: return (