diff --git a/apps/api/internal/app/app.go b/apps/api/internal/app/app.go index 9da86b2..76b0d6f 100644 --- a/apps/api/internal/app/app.go +++ b/apps/api/internal/app/app.go @@ -232,6 +232,16 @@ func (a *App) migrate(ctx context.Context) error { updated_at TEXT NOT NULL, UNIQUE(user_id, email) )`, + `CREATE TABLE IF NOT EXISTS mail_signatures ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + mailbox_id TEXT NOT NULL DEFAULT '', + name TEXT NOT NULL, + content TEXT NOT NULL, + is_default INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )`, `CREATE TABLE IF NOT EXISTS mail_rules ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, @@ -275,6 +285,7 @@ func (a *App) migrate(ctx context.Context) error { PRIMARY KEY(message_id, label_id) )`, `CREATE INDEX IF NOT EXISTS idx_contacts_user ON contacts(user_id, email)`, + `CREATE INDEX IF NOT EXISTS idx_mail_signatures_user_mailbox ON mail_signatures(user_id, mailbox_id, is_default)`, `CREATE INDEX IF NOT EXISTS idx_mail_rules_user_mailbox ON mail_rules(user_id, mailbox_id, enabled)`, `CREATE INDEX IF NOT EXISTS idx_blocked_senders_user_mailbox ON blocked_senders(user_id, mailbox_id, email)`, `CREATE INDEX IF NOT EXISTS idx_mail_labels_mailbox ON mail_labels(mailbox_id, name)`, diff --git a/apps/api/internal/app/app_test.go b/apps/api/internal/app/app_test.go index 41bc943..75d77d9 100644 --- a/apps/api/internal/app/app_test.go +++ b/apps/api/internal/app/app_test.go @@ -692,6 +692,57 @@ func TestProfileAndPasswordUpdate(t *testing.T) { } } +func TestUserMailSignaturesDefaultResolution(t *testing.T) { + a := newTestApp(t) + ts := httptest.NewServer(a.Router()) + defer ts.Close() + admin := &testClient{t: t, server: ts} + + var login map[string]any + if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK { + t.Fatalf("admin login code=%d body=%v", code, login) + } + domainID := mustDefaultDomainID(t, a) + mb1 := createTestMailbox(t, admin, domainID, "signer", "Signer", "Password123!", nil) + mb2 := createTestMailbox(t, admin, domainID, "second", "Second", "Password123!", map[string]any{"ownerEmail": mb1.Address}) + + user := &testClient{t: t, server: ts} + if code := user.do("POST", "/api/auth/login", map[string]string{"email": mb1.Address, "password": "Password123!"}, &login); code != http.StatusOK { + t.Fatalf("user login code=%d", code) + } + var global MailSignature + if code := user.do("POST", "/api/me/signatures", map[string]any{"name": "全局签名", "content": "Global Sig", "isDefault": true}, &global); code != http.StatusCreated || !global.IsDefault || global.MailboxID != "" { + t.Fatalf("create global signature code=%d sig=%+v", code, global) + } + var bound MailSignature + if code := user.do("POST", "/api/me/signatures", map[string]any{"mailboxId": mb1.ID, "name": "邮箱签名", "content": "Mailbox Sig", "isDefault": true}, &bound); code != http.StatusCreated || !bound.IsDefault || bound.MailboxID != mb1.ID { + t.Fatalf("create bound signature code=%d sig=%+v", code, bound) + } + var defaultResp struct { + Signature *MailSignature `json:"signature"` + } + if code := user.do("GET", "/api/me/signatures/default?mailboxId="+mb1.ID, nil, &defaultResp); code != http.StatusOK || defaultResp.Signature == nil || defaultResp.Signature.ID != bound.ID { + t.Fatalf("bound default code=%d resp=%+v", code, defaultResp) + } + if code := user.do("GET", "/api/me/signatures/default?mailboxId="+mb2.ID, nil, &defaultResp); code != http.StatusOK || defaultResp.Signature == nil || defaultResp.Signature.ID != global.ID { + t.Fatalf("global fallback code=%d resp=%+v", code, defaultResp) + } + var updated MailSignature + if code := user.do("POST", "/api/me/signatures/"+bound.ID, map[string]any{"mailboxId": mb1.ID, "name": "更新签名", "content": "Updated Sig", "isDefault": false}, &updated); code != http.StatusOK || updated.IsDefault || updated.Content != "Updated Sig" { + t.Fatalf("update signature code=%d sig=%+v", code, updated) + } + if code := user.do("GET", "/api/me/signatures/default?mailboxId="+mb1.ID, nil, &defaultResp); code != http.StatusOK || defaultResp.Signature == nil || defaultResp.Signature.ID != global.ID { + t.Fatalf("fallback after update code=%d resp=%+v", code, defaultResp) + } + var ok map[string]any + if code := user.do("DELETE", "/api/me/signatures/"+global.ID, nil, &ok); code != http.StatusOK { + t.Fatalf("delete signature code=%d body=%v", code, ok) + } + if code := user.do("GET", "/api/me/signatures/default?mailboxId="+mb2.ID, nil, &defaultResp); code != http.StatusOK || defaultResp.Signature != nil { + t.Fatalf("empty default code=%d resp=%+v", code, defaultResp) + } +} + func TestUserTwoFactorSetupAndLogin(t *testing.T) { a := newTestApp(t) a.cfg.TwoFactorEnabled = true diff --git a/apps/api/internal/app/personal_handlers.go b/apps/api/internal/app/personal_handlers.go index ffb18be..ce48561 100644 --- a/apps/api/internal/app/personal_handlers.go +++ b/apps/api/internal/app/personal_handlers.go @@ -225,6 +225,203 @@ func (a *App) handleDeleteContact(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusOK, map[string]any{"ok": true}) } +func (a *App) handleListSignatures(w http.ResponseWriter, r *http.Request) { + user := currentUser(r) + rows, err := a.db.QueryContext(r.Context(), `SELECT id,user_id,mailbox_id,name,content,is_default,created_at,updated_at FROM mail_signatures WHERE user_id=? ORDER BY is_default DESC, updated_at DESC, created_at DESC`, user.ID) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to load signatures") + return + } + defer rows.Close() + items := []MailSignature{} + for rows.Next() { + item, err := scanSignature(rows) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to scan signatures") + return + } + items = append(items, item) + } + respondJSON(w, http.StatusOK, map[string]any{"items": items}) +} + +func (a *App) handleCreateSignature(w http.ResponseWriter, r *http.Request) { + user := currentUser(r) + var req struct { + MailboxID string `json:"mailboxId"` + Name string `json:"name"` + Content string `json:"content"` + IsDefault bool `json:"isDefault"` + } + if err := decodeJSON(r, &req); err != nil { + badRequest(w, err) + return + } + mailboxID, name, content, ok := a.normalizeSignatureInput(w, r, user.ID, req.MailboxID, req.Name, req.Content) + if !ok { + return + } + id := newID("sig") + now := a.now().UTC().Format(time.RFC3339Nano) + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to save signature") + return + } + defer tx.Rollback() + if req.IsDefault { + if _, err := tx.ExecContext(r.Context(), `UPDATE mail_signatures SET is_default=0, updated_at=? WHERE user_id=? AND mailbox_id=?`, now, user.ID, mailboxID); err != nil { + respondError(w, http.StatusInternalServerError, "failed to update default signature") + return + } + } + if _, err := tx.ExecContext(r.Context(), `INSERT INTO mail_signatures(id,user_id,mailbox_id,name,content,is_default,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)`, + id, user.ID, mailboxID, name, content, boolInt(req.IsDefault), now, now); err != nil { + respondError(w, http.StatusInternalServerError, "failed to save signature") + return + } + if err := tx.Commit(); err != nil { + respondError(w, http.StatusInternalServerError, "failed to save signature") + return + } + item, err := a.signatureByID(r.Context(), user.ID, id) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to load signature") + return + } + respondJSON(w, http.StatusCreated, item) +} + +func (a *App) handleUpdateSignature(w http.ResponseWriter, r *http.Request) { + user := currentUser(r) + id := chi.URLParam(r, "id") + _, err := a.signatureByID(r.Context(), user.ID, id) + if errors.Is(err, sql.ErrNoRows) { + respondError(w, http.StatusNotFound, "signature not found") + return + } + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to load signature") + return + } + var req struct { + MailboxID string `json:"mailboxId"` + Name string `json:"name"` + Content string `json:"content"` + IsDefault bool `json:"isDefault"` + } + if err := decodeJSON(r, &req); err != nil { + badRequest(w, err) + return + } + mailboxID, name, content, ok := a.normalizeSignatureInput(w, r, user.ID, req.MailboxID, req.Name, req.Content) + if !ok { + return + } + now := a.now().UTC().Format(time.RFC3339Nano) + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to update signature") + return + } + defer tx.Rollback() + if req.IsDefault { + if _, err := tx.ExecContext(r.Context(), `UPDATE mail_signatures SET is_default=0, updated_at=? WHERE user_id=? AND mailbox_id=?`, now, user.ID, mailboxID); err != nil { + respondError(w, http.StatusInternalServerError, "failed to update default signature") + return + } + } + if _, err := tx.ExecContext(r.Context(), `UPDATE mail_signatures SET mailbox_id=?, name=?, content=?, is_default=?, updated_at=? WHERE id=? AND user_id=?`, + mailboxID, name, content, boolInt(req.IsDefault), now, id, user.ID); err != nil { + respondError(w, http.StatusInternalServerError, "failed to update signature") + return + } + if err := tx.Commit(); err != nil { + respondError(w, http.StatusInternalServerError, "failed to update signature") + return + } + item, err := a.signatureByID(r.Context(), user.ID, id) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to load signature") + return + } + respondJSON(w, http.StatusOK, item) +} + +func (a *App) handleDeleteSignature(w http.ResponseWriter, r *http.Request) { + user := currentUser(r) + res, err := a.db.ExecContext(r.Context(), `DELETE FROM mail_signatures WHERE id=? AND user_id=?`, chi.URLParam(r, "id"), user.ID) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to delete signature") + return + } + if n, _ := res.RowsAffected(); n == 0 { + respondError(w, http.StatusNotFound, "signature not found") + return + } + respondJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func (a *App) handleSetDefaultSignature(w http.ResponseWriter, r *http.Request) { + user := currentUser(r) + id := chi.URLParam(r, "id") + item, err := a.signatureByID(r.Context(), user.ID, id) + if errors.Is(err, sql.ErrNoRows) { + respondError(w, http.StatusNotFound, "signature not found") + return + } + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to load signature") + return + } + now := a.now().UTC().Format(time.RFC3339Nano) + tx, err := a.db.BeginTx(r.Context(), nil) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to update signature") + return + } + defer tx.Rollback() + if _, err := tx.ExecContext(r.Context(), `UPDATE mail_signatures SET is_default=0, updated_at=? WHERE user_id=? AND mailbox_id=?`, now, user.ID, item.MailboxID); err != nil { + respondError(w, http.StatusInternalServerError, "failed to update signature") + return + } + if _, err := tx.ExecContext(r.Context(), `UPDATE mail_signatures SET is_default=1, updated_at=? WHERE id=? AND user_id=?`, now, id, user.ID); err != nil { + respondError(w, http.StatusInternalServerError, "failed to update signature") + return + } + if err := tx.Commit(); err != nil { + respondError(w, http.StatusInternalServerError, "failed to update signature") + return + } + item, err = a.signatureByID(r.Context(), user.ID, id) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to load signature") + return + } + respondJSON(w, http.StatusOK, item) +} + +func (a *App) handleDefaultSignature(w http.ResponseWriter, r *http.Request) { + user := currentUser(r) + mailboxID := strings.TrimSpace(r.URL.Query().Get("mailboxId")) + if mailboxID != "" { + if _, err := a.mailboxForUserByID(r.Context(), user.ID, mailboxID); err != nil { + respondError(w, http.StatusForbidden, "mailbox not found") + return + } + } + item, err := a.defaultSignatureForMailbox(r.Context(), user.ID, mailboxID) + if errors.Is(err, sql.ErrNoRows) { + respondJSON(w, http.StatusOK, map[string]any{"signature": nil}) + return + } + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to load signature") + return + } + respondJSON(w, http.StatusOK, map[string]any{"signature": item}) +} + func (a *App) handleListRules(w http.ResponseWriter, r *http.Request) { user := currentUser(r) rows, err := a.db.QueryContext(r.Context(), `SELECT id,user_id,mailbox_id,name,match_mode,conditions_json,actions_json,from_contains,subject_contains,action,apply_to_existing,stop_processing,enabled,created_at FROM mail_rules WHERE user_id=? ORDER BY created_at DESC`, user.ID) @@ -561,6 +758,71 @@ func scanContact(row messageSummaryScanner) (Contact, error) { return item, err } +func scanSignature(row messageSummaryScanner) (MailSignature, error) { + var item MailSignature + var isDefault int + var created, updated string + err := row.Scan(&item.ID, &item.UserID, &item.MailboxID, &item.Name, &item.Content, &isDefault, &created, &updated) + item.IsDefault = intBool(isDefault) + item.CreatedAt = parseTime(created) + item.UpdatedAt = parseTime(updated) + return item, err +} + +func (a *App) normalizeSignatureInput(w http.ResponseWriter, r *http.Request, userID, rawMailboxID, rawName, rawContent string) (string, string, string, bool) { + mailboxID := strings.TrimSpace(rawMailboxID) + if mailboxID != "" { + if _, err := a.mailboxForUserByID(r.Context(), userID, mailboxID); err != nil { + respondError(w, http.StatusForbidden, "mailbox not found") + return "", "", "", false + } + } + name := strings.TrimSpace(rawName) + if name == "" { + badRequest(w, errors.New("signature name is required")) + return "", "", "", false + } + if len([]rune(name)) > 80 { + badRequest(w, errors.New("signature name is too long")) + return "", "", "", false + } + content := strings.TrimSpace(rawContent) + if content == "" { + badRequest(w, errors.New("signature content is required")) + return "", "", "", false + } + if len([]rune(content)) > 5000 { + badRequest(w, errors.New("signature content is too long")) + return "", "", "", false + } + return mailboxID, name, content, true +} + +func (a *App) signatureByID(ctx context.Context, userID, id string) (MailSignature, error) { + row := a.db.QueryRowContext(ctx, `SELECT id,user_id,mailbox_id,name,content,is_default,created_at,updated_at FROM mail_signatures WHERE id=? AND user_id=?`, id, userID) + return scanSignature(row) +} + +func (a *App) mailboxForUserByID(ctx context.Context, userID, mailboxID string) (*Mailbox, error) { + row := a.db.QueryRowContext(ctx, `SELECT id,user_id,domain_id,local_part,address,display_name,quota_mb,status,created_at FROM mailboxes WHERE id=? AND user_id=? AND status='active'`, mailboxID, userID) + var m Mailbox + var created string + if err := row.Scan(&m.ID, &m.UserID, &m.DomainID, &m.LocalPart, &m.Address, &m.DisplayName, &m.QuotaMB, &m.Status, &created); err != nil { + return nil, err + } + m.CreatedAt = parseTime(created) + return &m, nil +} + +func (a *App) defaultSignatureForMailbox(ctx context.Context, userID, mailboxID string) (MailSignature, error) { + row := a.db.QueryRowContext(ctx, `SELECT id,user_id,mailbox_id,name,content,is_default,created_at,updated_at + FROM mail_signatures + WHERE user_id=? AND is_default=1 AND (mailbox_id=? OR mailbox_id='') + ORDER BY CASE WHEN mailbox_id=? THEN 0 ELSE 1 END, updated_at DESC + LIMIT 1`, userID, mailboxID, mailboxID) + return scanSignature(row) +} + func scanRule(row messageSummaryScanner) (MailRule, error) { var item MailRule var enabled, applyToExisting, stopProcessing int diff --git a/apps/api/internal/app/router_auth.go b/apps/api/internal/app/router_auth.go index acf4541..b5cb1ca 100644 --- a/apps/api/internal/app/router_auth.go +++ b/apps/api/internal/app/router_auth.go @@ -44,6 +44,12 @@ func (a *App) Router() http.Handler { r.With(a.requireAuth).Get("/me/contacts", a.handleListContacts) r.With(a.requireAuth).Post("/me/contacts", a.handleCreateContact) r.With(a.requireAuth).Delete("/me/contacts/{id}", a.handleDeleteContact) + r.With(a.requireAuth).Get("/me/signatures", a.handleListSignatures) + r.With(a.requireAuth).Post("/me/signatures", a.handleCreateSignature) + r.With(a.requireAuth).Post("/me/signatures/{id}", a.handleUpdateSignature) + r.With(a.requireAuth).Post("/me/signatures/{id}/default", a.handleSetDefaultSignature) + r.With(a.requireAuth).Delete("/me/signatures/{id}", a.handleDeleteSignature) + r.With(a.requireAuth).Get("/me/signatures/default", a.handleDefaultSignature) r.With(a.requireAuth).Get("/me/rules", a.handleListRules) r.With(a.requireAuth).Post("/me/rules", a.handleCreateRule) r.With(a.requireAuth).Delete("/me/rules/{id}", a.handleDeleteRule) diff --git a/apps/api/internal/app/settings_handlers.go b/apps/api/internal/app/settings_handlers.go index db7dca8..15eac8a 100644 --- a/apps/api/internal/app/settings_handlers.go +++ b/apps/api/internal/app/settings_handlers.go @@ -63,6 +63,7 @@ type PublicSettings struct { OpenRegistration bool `json:"openRegistration"` TurnstileEnabled bool `json:"turnstileEnabled"` TurnstileSiteKey string `json:"turnstileSiteKey"` + PublicHostname string `json:"publicHostname"` MailAutoRefresh bool `json:"mailAutoRefresh"` MailRefreshMs int `json:"mailRefreshMs"` MailboxDomains []PublicDomain `json:"mailboxDomains,omitempty"` @@ -87,7 +88,7 @@ func (a *App) handlePublicSettings(w http.ResponseWriter, r *http.Request) { if refreshSeconds <= 0 { refreshSeconds = 30 } - settings := PublicSettings{OpenRegistration: a.cfg.OpenRegistration, TurnstileEnabled: enabled, TurnstileSiteKey: a.cfg.TurnstileSiteKey, MailAutoRefresh: a.cfg.MailAutoRefresh, MailRefreshMs: refreshSeconds * 1000} + settings := PublicSettings{OpenRegistration: a.cfg.OpenRegistration, TurnstileEnabled: enabled, TurnstileSiteKey: a.cfg.TurnstileSiteKey, PublicHostname: a.cfg.PublicHostname, MailAutoRefresh: a.cfg.MailAutoRefresh, MailRefreshMs: refreshSeconds * 1000} // Include available domains for mailbox creation during registration if a.cfg.OpenRegistration { diff --git a/apps/api/internal/app/types.go b/apps/api/internal/app/types.go index caaa855..f7344a8 100644 --- a/apps/api/internal/app/types.go +++ b/apps/api/internal/app/types.go @@ -132,6 +132,17 @@ type Contact struct { CreatedAt time.Time `json:"createdAt"` } +type MailSignature struct { + ID string `json:"id"` + UserID string `json:"userId,omitempty"` + MailboxID string `json:"mailboxId"` + Name string `json:"name"` + Content string `json:"content"` + IsDefault bool `json:"isDefault"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + type MailRule struct { ID string `json:"id"` UserID string `json:"userId,omitempty"` diff --git a/apps/web/src/lib/api-types.ts b/apps/web/src/lib/api-types.ts index b61eaf2..f6204fb 100644 --- a/apps/web/src/lib/api-types.ts +++ b/apps/web/src/lib/api-types.ts @@ -16,6 +16,7 @@ export type DNSCheckResult = { domain: string; status: string; checks: Record = { items: T[]; nextCursor?: string } export type SendPayload = { mailboxId?: string; to: string[]; cc: string[]; bcc: string[]; subject: string; text: string; html: string; attachments: { filename: string; contentType: string; contentBase64: string }[] } export type Contact = { id: string; name: string; email: string; note: string; createdAt: string } +export type MailSignature = { id: string; mailboxId: string; name: string; content: string; isDefault: boolean; createdAt: string; updatedAt: string } export type MailRuleCondition = { field: "from" | "to" | "subject" | "body"; operator: "contains" | "not-contains" | "equals" | "not-equals" | "starts-with" | "ends-with"; value: string } export type MailRuleAction = { type: "archive" | "trash" | "star" | "mark-read" | "label" | "move"; value?: string; labelId?: string } export type MailRule = { id: string; mailboxId: string; name: string; matchMode: "all" | "any"; conditions: MailRuleCondition[]; actions: MailRuleAction[]; applyToExisting: boolean; stopProcessing: boolean; fromContains: string; subjectContains: string; action: "archive" | "trash" | "star" | "mark-read" | "label" | "move"; enabled: boolean; createdAt: string; appliedExistingCount?: number } @@ -49,7 +50,7 @@ export type SystemSettings = { } export type SystemSettingsPayload = Omit & { smtpPassword: string; turnstileSecretKey: string } export type PublicDomain = { id: string; name: string } -export type PublicSettings = { openRegistration: boolean; turnstileEnabled: boolean; turnstileSiteKey: string; mailAutoRefresh: boolean; mailRefreshMs: number; mailboxDomains?: PublicDomain[] } +export type PublicSettings = { openRegistration: boolean; turnstileEnabled: boolean; turnstileSiteKey: string; publicHostname: string; mailAutoRefresh: boolean; mailRefreshMs: number; mailboxDomains?: PublicDomain[] } export type LoginPayload = { email?: string; password?: string; turnstileToken?: string; challengeToken?: string; twoFactorCode?: string } export type LoginResponse = { user?: User; twoFactorRequired?: boolean; challengeToken?: string } export type RegisterPayload = { email: string; displayName: string; password: string; turnstileToken?: string; domainId?: string; localPart?: string } diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index fca90ad..4ebb0c6 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -1,4 +1,4 @@ -import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, Attachment, MailLabel, MailMessage, DNSRecord, DNSCheckResult, ListResponse, SendPayload, Contact, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, MailboxApplyOptions, MailTemplate, SystemSettings, SystemSettingsPayload, PublicSettings, LoginPayload, LoginResponse, RegisterPayload } from "./api-types" +import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, Attachment, MailLabel, MailMessage, DNSRecord, DNSCheckResult, ListResponse, SendPayload, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, MailboxApplyOptions, MailTemplate, SystemSettings, SystemSettingsPayload, PublicSettings, LoginPayload, LoginResponse, RegisterPayload } from "./api-types" export * from "./api-types" const REQUEST_TIMEOUT_MS = 15_000 @@ -45,6 +45,12 @@ export const api = { contacts: () => request>("/api/me/contacts"), createContact: (payload: { name: string; email: string; note: string }) => request("/api/me/contacts", { method: "POST", body: JSON.stringify(payload) }), deleteContact: (id: string) => request<{ ok: boolean }>(`/api/me/contacts/${id}`, { method: "DELETE" }), + signatures: () => request>("/api/me/signatures"), + createSignature: (payload: { mailboxId: string; name: string; content: string; isDefault: boolean }) => request("/api/me/signatures", { method: "POST", body: JSON.stringify(payload) }), + updateSignature: (id: string, payload: { mailboxId: string; name: string; content: string; isDefault: boolean }) => request(`/api/me/signatures/${id}`, { method: "POST", body: JSON.stringify(payload) }), + setDefaultSignature: (id: string) => request(`/api/me/signatures/${id}/default`, { method: "POST" }), + deleteSignature: (id: string) => request<{ ok: boolean }>(`/api/me/signatures/${id}`, { method: "DELETE" }), + defaultSignature: (mailboxId?: string) => request<{ signature: MailSignature | null }>(`/api/me/signatures/default${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`), rules: () => request>("/api/me/rules"), createRule: (payload: { mailboxId: string; name: string; matchMode: "all" | "any"; conditions: MailRuleCondition[]; actions: MailRuleAction[]; applyToExisting: boolean; stopProcessing: boolean; enabled: boolean }) => request("/api/me/rules", { method: "POST", body: JSON.stringify(payload) }), deleteRule: (id: string) => request<{ ok: boolean }>(`/api/me/rules/${id}`, { method: "DELETE" }), diff --git a/apps/web/src/pages/mail.tsx b/apps/web/src/pages/mail.tsx index 9792059..d80b59a 100644 --- a/apps/web/src/pages/mail.tsx +++ b/apps/web/src/pages/mail.tsx @@ -85,6 +85,8 @@ export function MailPage() { const [darkMode, setDarkMode] = React.useState(getInitialTheme) const [displayMode] = useDisplayMode() const [refreshing, setRefreshing] = React.useState(false) + const [autoRefreshing, setAutoRefreshing] = React.useState(false) + const [lastAutoRefreshAt, setLastAutoRefreshAt] = React.useState(null) const [bulkPending, setBulkPending] = React.useState(false) const [pendingConfirm, setPendingConfirm] = React.useState(null) const sidebarPanelRef = React.useRef(null) @@ -303,11 +305,17 @@ export function MailPage() { React.useEffect(() => { if (!publicSettings.data?.mailAutoRefresh) return const timer = window.setInterval(() => { - qc.invalidateQueries({ queryKey: ["messages"] }) - qc.invalidateQueries({ queryKey: ["folders"] }) - qc.invalidateQueries({ queryKey: ["mail-stats"] }) - qc.invalidateQueries({ queryKey: ["labels"] }) - qc.invalidateQueries({ queryKey: ["mail-notifications"] }) + setAutoRefreshing(true) + Promise.all([ + qc.invalidateQueries({ queryKey: ["messages"] }), + qc.invalidateQueries({ queryKey: ["folders"] }), + qc.invalidateQueries({ queryKey: ["mail-stats"] }), + qc.invalidateQueries({ queryKey: ["labels"] }), + qc.invalidateQueries({ queryKey: ["mail-notifications"] }), + ]).finally(() => { + setLastAutoRefreshAt(new Date()) + window.setTimeout(() => setAutoRefreshing(false), 600) + }) }, mailRefreshInterval || 30000) return () => window.clearInterval(timer) }, [mailRefreshInterval, publicSettings.data?.mailAutoRefresh, qc]) @@ -436,6 +444,7 @@ export function MailPage() { setRefreshing(true) try { await refreshMailData() + setLastAutoRefreshAt(new Date()) } finally { setRefreshing(false) } @@ -549,7 +558,14 @@ export function MailPage() {
- + + {(publicSettings.data?.mailAutoRefresh || autoRefreshing) && ( +
+ {autoRefreshing ? "自动刷新中..." : lastAutoRefreshAt ? `已刷新 ${lastAutoRefreshAt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}` : "自动刷新已开启"} +
+ )} @@ -1215,6 +1231,9 @@ function MessageLabels({ messageLabels, availableLabels, onAdd, onRemove, pendin function ComposeDialog({ mailbox, open, draft, onOpenChange, onSent }: { mailbox?: Mailbox; open: boolean; draft?: ComposeDraft; onOpenChange: (v: boolean) => void; onSent: () => void }) { const { toast } = useToast() const [files, setFiles] = React.useState([]) + const defaultSignature = useQuery({ queryKey: ["signature", "default", mailbox?.id], queryFn: () => api.defaultSignature(mailbox?.id), enabled: open && !!mailbox?.id }) + const signatureText = defaultSignature.data?.signature?.content || "" + const composerText = draft?.text !== undefined ? draft.text : signatureText ? `\n\n-- \n${signatureText}` : "" const send = useMutation({ mutationFn: api.send, onSuccess: () => { toast({ title: "发送成功" }); setFiles([]); onSent() }, onError: (e) => toast({ title: "发送失败", description: e.message }) }) async function submit(e: React.FormEvent) { e.preventDefault() @@ -1239,7 +1258,7 @@ function ComposeDialog({ mailbox, open, draft, onOpenChange, onSent }: { mailbox
- +
setFiles(Array.from(e.currentTarget.files || []))} />{files.length > 0 &&
{files.map((f) => `${f.name} (${formatBytes(f.size)})`).join(",")}
}
@@ -1259,14 +1278,21 @@ function MarkdownComposer({ defaultValue }: { defaultValue: string }) { const [value, setValue] = React.useState(defaultValue) const [mode, setMode] = React.useState("edit") const textareaRef = React.useRef(null) + const dirtyRef = React.useRef(false) + const lastDefaultRef = React.useRef(defaultValue) const previewHtml = React.useMemo(() => markdownToHtml(value), [value]) - React.useEffect(() => setValue(defaultValue), [defaultValue]) + React.useEffect(() => { + if (defaultValue === lastDefaultRef.current) return + lastDefaultRef.current = defaultValue + if (!dirtyRef.current) setValue(defaultValue) + }, [defaultValue]) function focusEditor() { window.requestAnimationFrame(() => textareaRef.current?.focus()) } function updateSelection(next: string, start: number, end: number) { + dirtyRef.current = true setValue(next) window.requestAnimationFrame(() => { const textarea = textareaRef.current @@ -1380,7 +1406,7 @@ function MarkdownComposer({ defaultValue }: { defaultValue: string }) {