feat(mail): 增加签名管理与客户端配置
- 新增邮件签名的数据表、接口和前端管理页,支持全局/邮箱默认签名的创建、编辑、删除与查询。 - 写信时自动带入当前邮箱的默认签名,并优化手动输入内容时的覆盖行为。 - 补充第三方邮件客户端配置页,展示 IMAP/POP3/SMTP 连接信息及公共主机名。 - 扩展部署配置,开放 POP3S/SMTPS 端口并启用 Dovecot POP3 服务。
This commit is contained in:
@@ -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)`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -16,6 +16,7 @@ export type DNSCheckResult = { domain: string; status: string; checks: Record<st
|
||||
export type ListResponse<T> = { 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<SystemSettings, "smtpPasswordSet" | "turnstileSecretSet"> & { 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 }
|
||||
|
||||
@@ -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<ListResponse<Contact>>("/api/me/contacts"),
|
||||
createContact: (payload: { name: string; email: string; note: string }) => request<Contact>("/api/me/contacts", { method: "POST", body: JSON.stringify(payload) }),
|
||||
deleteContact: (id: string) => request<{ ok: boolean }>(`/api/me/contacts/${id}`, { method: "DELETE" }),
|
||||
signatures: () => request<ListResponse<MailSignature>>("/api/me/signatures"),
|
||||
createSignature: (payload: { mailboxId: string; name: string; content: string; isDefault: boolean }) => request<MailSignature>("/api/me/signatures", { method: "POST", body: JSON.stringify(payload) }),
|
||||
updateSignature: (id: string, payload: { mailboxId: string; name: string; content: string; isDefault: boolean }) => request<MailSignature>(`/api/me/signatures/${id}`, { method: "POST", body: JSON.stringify(payload) }),
|
||||
setDefaultSignature: (id: string) => request<MailSignature>(`/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<ListResponse<MailRule>>("/api/me/rules"),
|
||||
createRule: (payload: { mailboxId: string; name: string; matchMode: "all" | "any"; conditions: MailRuleCondition[]; actions: MailRuleAction[]; applyToExisting: boolean; stopProcessing: boolean; enabled: boolean }) => request<MailRule>("/api/me/rules", { method: "POST", body: JSON.stringify(payload) }),
|
||||
deleteRule: (id: string) => request<{ ok: boolean }>(`/api/me/rules/${id}`, { method: "DELETE" }),
|
||||
|
||||
@@ -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<Date | null>(null)
|
||||
const [bulkPending, setBulkPending] = React.useState(false)
|
||||
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
|
||||
const sidebarPanelRef = React.useRef<ImperativePanelHandle>(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() {
|
||||
<section className="flex h-full min-h-0 flex-col">
|
||||
<header className="flex h-16 shrink-0 items-center justify-between gap-3 border-b px-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="icon" variant="ghost" onClick={refreshMail} disabled={refreshing} className={cn("transition-all", refreshing && "bg-primary/5 text-primary")}><RefreshCcw className={cn("h-4 w-4", refreshing && "animate-spin")} /></Button>
|
||||
<Button size="icon" variant="ghost" onClick={refreshMail} disabled={refreshing || autoRefreshing} className={cn("transition-all", (refreshing || autoRefreshing) && "bg-primary/5 text-primary")} title={autoRefreshing ? "自动刷新中" : "刷新邮件"}>
|
||||
<RefreshCcw className={cn("h-4 w-4", (refreshing || autoRefreshing) && "animate-spin")} />
|
||||
</Button>
|
||||
{(publicSettings.data?.mailAutoRefresh || autoRefreshing) && (
|
||||
<div className="hidden min-w-[118px] text-xs text-muted-foreground sm:block">
|
||||
{autoRefreshing ? "自动刷新中..." : lastAutoRefreshAt ? `已刷新 ${lastAutoRefreshAt.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}` : "自动刷新已开启"}
|
||||
</div>
|
||||
)}
|
||||
<Button variant="outline" size="sm" disabled={!activeMailboxId || markAllRead.isPending || unreadCount === 0} onClick={() => markAllRead.mutate(allMessages)}><MailCheck className="h-4 w-4" />全部已读</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -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<File[]>([])
|
||||
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<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
@@ -1239,7 +1258,7 @@ function ComposeDialog({ mailbox, open, draft, onOpenChange, onSent }: { mailbox
|
||||
<div className="space-y-2"><Label>收件人</Label><Input name="to" placeholder="user@example.com, other@example.com" defaultValue={draft?.to || ""} required /></div>
|
||||
<div className="grid grid-cols-2 gap-3"><div className="space-y-2"><Label>抄送</Label><Input name="cc" placeholder="cc1@example.com, cc2@example.com" defaultValue={draft?.cc || ""} /></div><div className="space-y-2"><Label>密送</Label><Input name="bcc" placeholder="bcc1@example.com, bcc2@example.com" defaultValue={draft?.bcc || ""} /></div></div>
|
||||
<div className="space-y-2"><Label>主题</Label><Input name="subject" defaultValue={draft?.subject || ""} /></div>
|
||||
<MarkdownComposer defaultValue={draft?.text || ""} />
|
||||
<MarkdownComposer defaultValue={composerText} />
|
||||
<div className="space-y-2"><Label>附件</Label><Input type="file" multiple onChange={(e) => setFiles(Array.from(e.currentTarget.files || []))} />{files.length > 0 && <div className="text-xs text-muted-foreground">{files.map((f) => `${f.name} (${formatBytes(f.size)})`).join(",")}</div>}</div>
|
||||
</div>
|
||||
<DialogFooter className="border-t bg-background px-6 py-4">
|
||||
@@ -1259,14 +1278,21 @@ function MarkdownComposer({ defaultValue }: { defaultValue: string }) {
|
||||
const [value, setValue] = React.useState(defaultValue)
|
||||
const [mode, setMode] = React.useState<MarkdownMode>("edit")
|
||||
const textareaRef = React.useRef<HTMLTextAreaElement>(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 }) {
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
onChange={(event) => { dirtyRef.current = true; setValue(event.target.value) }}
|
||||
placeholder="在此输入邮件内容..."
|
||||
className={cn("min-h-[280px] resize-y rounded-none border-0 shadow-none focus-visible:ring-0", mode === "split" && "md:border-r")}
|
||||
/>
|
||||
|
||||
@@ -2,9 +2,9 @@ import * as React from "react"
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import type { ImperativePanelHandle } from "react-resizable-panels"
|
||||
import { useNavigate, useSearchParams } from "react-router-dom"
|
||||
import { ArrowLeft, BarChart3, Ban, Contact, Copy, Info, KeyRound, LogOut, Mail, MailCheck, MailX, Moon, PanelLeftClose, PanelLeftOpen, Plus, RefreshCcw, Settings, ShieldCheck, SlidersHorizontal, Sun, Trash2, X } from "lucide-react"
|
||||
import { ArrowLeft, BarChart3, Ban, Contact, Copy, Info, KeyRound, Laptop, LogOut, Mail, MailCheck, MailX, Moon, PanelLeftClose, PanelLeftOpen, PencilLine, Plus, RefreshCcw, Settings, ShieldCheck, SlidersHorizontal, Sun, Trash2, X } from "lucide-react"
|
||||
import { QRCodeSVG } from "qrcode.react"
|
||||
import { api, MailLabel, MailRule, MailRuleAction, MailRuleCondition, Mailbox, MailboxApplyOptions, MailStats } from "@/lib/api"
|
||||
import { api, MailLabel, MailRule, MailRuleAction, MailRuleCondition, Mailbox, MailboxApplyOptions, MailSignature, MailStats } from "@/lib/api"
|
||||
import { cn, formatBytes } from "@/lib/utils"
|
||||
import { applyTheme, getInitialTheme } from "@/lib/theme"
|
||||
import { DisplayMode, useDisplayMode } from "@/lib/display-mode"
|
||||
@@ -15,6 +15,7 @@ import { Button } from "@/components/ui/button"
|
||||
import { PasswordInput } from "@/components/ui/password-input"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
@@ -28,11 +29,13 @@ import { Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent, SidebarGrou
|
||||
import { ConfirmDialog } from "@/components/confirm-dialog"
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
|
||||
type Tab = "profile" | "mailboxes" | "contacts" | "cleanup" | "rules" | "blocked" | "stats"
|
||||
type Tab = "profile" | "mailboxes" | "clients" | "signatures" | "contacts" | "cleanup" | "rules" | "blocked" | "stats"
|
||||
type PendingConfirm = { title: string; description?: string; confirmText: string; destructive?: boolean; onConfirm: () => void }
|
||||
const tabs: Record<Tab, { label: string; icon: React.ReactNode }> = {
|
||||
profile: { label: "账户资料", icon: <Settings className="h-4 w-4" /> },
|
||||
mailboxes: { label: "邮箱管理", icon: <Mail className="h-4 w-4" /> },
|
||||
clients: { label: "第三方客户端", icon: <Laptop className="h-4 w-4" /> },
|
||||
signatures: { label: "签名管理", icon: <KeyRound className="h-4 w-4" /> },
|
||||
contacts: { label: "联系人管理", icon: <Contact className="h-4 w-4" /> },
|
||||
cleanup: { label: "邮件清理", icon: <Trash2 className="h-4 w-4" /> },
|
||||
rules: { label: "收件规则", icon: <SlidersHorizontal className="h-4 w-4" /> },
|
||||
@@ -64,7 +67,9 @@ export function ProfilePage() {
|
||||
const user = me.data?.user
|
||||
const mailboxes = useQuery({ queryKey: ["mailboxes", "mine"], queryFn: api.myMailboxes })
|
||||
const mailboxApplyOptions = useQuery({ queryKey: ["mailbox-apply-options"], queryFn: api.mailboxApplyOptions })
|
||||
const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings })
|
||||
const contacts = useQuery({ queryKey: ["contacts"], queryFn: api.contacts })
|
||||
const signatures = useQuery({ queryKey: ["signatures"], queryFn: api.signatures })
|
||||
const rules = useQuery({ queryKey: ["rules"], queryFn: api.rules })
|
||||
const blocked = useQuery({ queryKey: ["blocked-senders"], queryFn: api.blockedSenders })
|
||||
const selectedMailbox = React.useMemo(() => mailboxes.data?.items.find((m) => m.id === mailboxId), [mailboxes.data?.items, mailboxId])
|
||||
@@ -107,6 +112,22 @@ export function ProfilePage() {
|
||||
onError: (error) => toast({ title: "保存失败", description: error.message }),
|
||||
})
|
||||
const deleteContact = useMutation({ mutationFn: api.deleteContact, onSuccess: () => { qc.invalidateQueries({ queryKey: ["contacts"] }); toast({ title: "联系人已删除" }) } })
|
||||
const createSignature = useMutation({
|
||||
mutationFn: (form: FormData) => api.createSignature({ mailboxId: String(form.get("mailboxId") || ""), name: String(form.get("name") || ""), content: String(form.get("content") || ""), isDefault: form.get("isDefault") === "on" }),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ["signatures"] }); qc.invalidateQueries({ queryKey: ["signature"] }); toast({ title: "签名已保存" }) },
|
||||
onError: (error) => toast({ title: "保存失败", description: error.message }),
|
||||
})
|
||||
const updateSignature = useMutation({
|
||||
mutationFn: ({ id, form }: { id: string; form: FormData }) => api.updateSignature(id, { mailboxId: String(form.get("mailboxId") || ""), name: String(form.get("name") || ""), content: String(form.get("content") || ""), isDefault: form.get("isDefault") === "on" }),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ["signatures"] }); qc.invalidateQueries({ queryKey: ["signature"] }); toast({ title: "签名已更新" }) },
|
||||
onError: (error) => toast({ title: "保存失败", description: error.message }),
|
||||
})
|
||||
const setDefaultSignature = useMutation({
|
||||
mutationFn: api.setDefaultSignature,
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ["signatures"] }); qc.invalidateQueries({ queryKey: ["signature"] }); toast({ title: "默认签名已更新" }) },
|
||||
onError: (error) => toast({ title: "设置失败", description: error.message }),
|
||||
})
|
||||
const deleteSignature = useMutation({ mutationFn: api.deleteSignature, onSuccess: () => { qc.invalidateQueries({ queryKey: ["signatures"] }); qc.invalidateQueries({ queryKey: ["signature"] }); toast({ title: "签名已删除" }) } })
|
||||
const createRule = useMutation({
|
||||
mutationFn: (payload: {
|
||||
mailboxId: string
|
||||
@@ -214,6 +235,8 @@ export function ProfilePage() {
|
||||
|
||||
function renderTab() {
|
||||
if (tab === "mailboxes") return <MailboxManagement mailboxes={mailboxes.data?.items || []} applyOptions={mailboxApplyOptions.data} applyPending={applyMailbox.isPending} selectedMailboxId={mailboxId} onSelect={setMailboxId} onCopy={copy} onOpen={(id) => { setMailboxId(id); navigate("/") }} onApply={(payload) => applyMailbox.mutateAsync(payload).then(() => undefined)} />
|
||||
if (tab === "clients") return <ClientSettingsSection mailboxes={mailboxes.data?.items || []} selectedMailboxId={mailboxId} hostname={publicSettings.data?.publicHostname} onSelectMailbox={setMailboxId} onCopy={copy} />
|
||||
if (tab === "signatures") return <SignaturesSection items={signatures.data?.items || []} mailboxes={mailboxes.data?.items || []} loading={signatures.isLoading} pending={createSignature.isPending || updateSignature.isPending || setDefaultSignature.isPending || deleteSignature.isPending} onCreate={(form) => createSignature.mutate(form)} onUpdate={(id, form) => updateSignature.mutate({ id, form })} onSetDefault={(id) => setDefaultSignature.mutate(id)} onDelete={(id) => deleteSignature.mutate(id)} />
|
||||
if (tab === "contacts") return <ContactsSection items={contacts.data?.items || []} loading={contacts.isLoading} pending={createContact.isPending} onCreate={(form) => createContact.mutate(form)} onDelete={(id) => deleteContact.mutate(id)} onCopy={copy} />
|
||||
if (tab === "cleanup") return <CleanupSection mailbox={selectedMailbox} stats={stats.data} pending={cleanup.isPending} onCleanup={(target) => cleanup.mutate(target)} />
|
||||
if (tab === "rules") return <RulesSection items={rules.data?.items || []} mailboxes={mailboxes.data?.items || []} labels={ruleLabels.data?.items || []} open={ruleDialogOpen} onOpenChange={setRuleDialogOpen} onCreate={(payload) => createRule.mutate(payload)} onDelete={(id) => deleteRule.mutate(id)} pending={createRule.isPending} />
|
||||
@@ -437,6 +460,196 @@ function ApplyMailboxDialog({ options, pending, onApply }: { options: MailboxApp
|
||||
)
|
||||
}
|
||||
|
||||
function ClientSettingsSection({ mailboxes, selectedMailboxId, hostname, onSelectMailbox, onCopy }: { mailboxes: Mailbox[]; selectedMailboxId: string; hostname?: string; onSelectMailbox: (id: string) => void; onCopy: (text: string) => void }) {
|
||||
const selected = mailboxes.find((item) => item.id === selectedMailboxId) || mailboxes[0]
|
||||
const server = clientServerHost(hostname, selected?.address)
|
||||
const rows = [
|
||||
{ label: "IMAP 服务器", value: `${server}:993`, security: "SSL" },
|
||||
{ label: "POP3 服务器", value: `${server}:995`, security: "SSL" },
|
||||
{ label: "SMTP 服务器", value: `${server}:465`, security: "SSL" },
|
||||
]
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>第三方客户端</CardTitle>
|
||||
<div className="mt-1 text-sm text-muted-foreground">IMAP / POP3 / SMTP 配置用于 Thunderbird、Apple Mail、手机邮件客户端等。</div>
|
||||
</div>
|
||||
{!!selected && <Badge variant="secondary">{selected.address}</Badge>}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<Field label="选择邮箱">
|
||||
<Select value={selected?.id || ""} onValueChange={onSelectMailbox}>
|
||||
<SelectTrigger><SelectValue placeholder="选择邮箱" /></SelectTrigger>
|
||||
<SelectContent>{mailboxes.map((mailbox) => <SelectItem key={mailbox.id} value={mailbox.id}>{mailbox.address}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
{selected ? (
|
||||
<>
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">{selected.address}</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Badge variant="secondary" className="bg-emerald-100 text-emerald-700">● IMAP</Badge>
|
||||
<Badge variant="secondary" className="bg-emerald-100 text-emerald-700">● POP3</Badge>
|
||||
<Badge variant="secondary" className="bg-emerald-100 text-emerald-700">● SMTP</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline">已启用</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-muted p-5">
|
||||
<div className="mb-4 font-medium">客户端配置</div>
|
||||
<div className="space-y-3">
|
||||
{rows.map((row) => (
|
||||
<ClientConfigRow key={row.label} label={row.label} value={row.value} security={row.security} onCopy={onCopy} />
|
||||
))}
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
<div className="grid gap-3 text-sm sm:grid-cols-[120px_minmax(0,1fr)]">
|
||||
<div className="text-muted-foreground">用户名</div>
|
||||
<div className="flex min-w-0 items-center justify-between gap-2">
|
||||
<span className="truncate text-right sm:text-left">{selected.address}</span>
|
||||
<Button type="button" variant="ghost" size="icon" className="size-7" onClick={() => onCopy(selected.address)}><Copy className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
<div className="text-muted-foreground">密码</div>
|
||||
<div>邮箱登录密码</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<EmptyState text="暂无邮箱账号,创建邮箱后可查看客户端配置" />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ClientConfigRow({ label, value, security, onCopy }: { label: string; value: string; security: string; onCopy: (text: string) => void }) {
|
||||
return (
|
||||
<div className="grid items-center gap-2 text-sm sm:grid-cols-[120px_minmax(0,1fr)]">
|
||||
<div className="text-muted-foreground">{label}</div>
|
||||
<div className="flex min-w-0 items-center justify-between gap-2">
|
||||
<code className="truncate rounded border bg-background px-2 py-1 text-xs">{value}</code>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<span className="text-xs font-medium text-emerald-600">{security}</span>
|
||||
<Button type="button" variant="ghost" size="icon" className="size-7" onClick={() => onCopy(value)}><Copy className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SignaturesSection({ items, mailboxes, loading, pending, onCreate, onUpdate, onSetDefault, onDelete }: { items: MailSignature[]; mailboxes: Mailbox[]; loading: boolean; pending: boolean; onCreate: (form: FormData) => void; onUpdate: (id: string, form: FormData) => void; onSetDefault: (id: string) => void; onDelete: (id: string) => void }) {
|
||||
const [mailboxId, setMailboxId] = React.useState("all")
|
||||
const [isDefault, setIsDefault] = React.useState(false)
|
||||
const [editing, setEditing] = React.useState<MailSignature | null>(null)
|
||||
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
|
||||
const editingMailboxId = editing?.mailboxId || "all"
|
||||
const editingIsDefault = editing?.isDefault || false
|
||||
function resetCreateForm(form: HTMLFormElement) {
|
||||
form.reset()
|
||||
setMailboxId("all")
|
||||
setIsDefault(false)
|
||||
}
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>签名管理</CardTitle>
|
||||
<div className="mt-1 text-sm text-muted-foreground">支持全局签名和按发件邮箱绑定的默认签名。</div>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">共 {items.length} 个签名</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="space-y-4 rounded-lg border p-4" onSubmit={(e) => { e.preventDefault(); const form = new FormData(e.currentTarget); form.set("mailboxId", mailboxId === "all" ? "" : mailboxId); form.set("isDefault", isDefault ? "on" : ""); onCreate(form); resetCreateForm(e.currentTarget) }}>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field label="签名名称"><Input name="name" required placeholder="例如:默认签名" /></Field>
|
||||
<Field label="绑定邮箱">
|
||||
<MailboxSelect value={mailboxId} mailboxes={mailboxes} onChange={setMailboxId} />
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="签名内容">
|
||||
<Textarea name="content" required className="min-h-40" placeholder="支持多行文本,写信时会自动转为 HTML" />
|
||||
</Field>
|
||||
<label className="flex items-center gap-3 text-sm font-medium">
|
||||
<Checkbox checked={isDefault} onCheckedChange={(value) => setIsDefault(value === true)} />
|
||||
<span>设为当前范围默认签名</span>
|
||||
</label>
|
||||
<Button disabled={pending}>{pending ? "保存中..." : "创建签名"}</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle>签名列表</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{items.map((item) => {
|
||||
const mailbox = item.mailboxId ? mailboxes.find((m) => m.id === item.mailboxId)?.address || "未知邮箱" : "全局签名"
|
||||
return (
|
||||
<div key={item.id} className="rounded-lg border p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="font-medium">{item.name}</div>
|
||||
{item.isDefault && <Badge>默认</Badge>}
|
||||
<Badge variant="outline">{mailbox}</Badge>
|
||||
</div>
|
||||
<div className="mt-2 whitespace-pre-wrap text-sm text-muted-foreground">{item.content}</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-1">
|
||||
{!item.isDefault && <Button variant="outline" size="sm" disabled={pending} onClick={() => onSetDefault(item.id)}>设为默认</Button>}
|
||||
<Button variant="ghost" size="icon" className="size-8" disabled={pending} onClick={() => setEditing(item)}><PencilLine className="h-4 w-4" /></Button>
|
||||
<Button variant="ghost" size="icon" className="size-8 text-destructive" disabled={pending} onClick={() => setPendingConfirm({ title: "删除签名?", description: `签名“${item.name}”将被删除。`, confirmText: "删除签名", onConfirm: () => { onDelete(item.id); setPendingConfirm(null) } })}><Trash2 className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{!loading && items.length === 0 && <EmptyState text="暂无签名" />}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Dialog open={!!editing} onOpenChange={(open) => { if (!open) setEditing(null) }}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader><DialogTitle>编辑签名</DialogTitle></DialogHeader>
|
||||
{editing && (
|
||||
<form key={editing.id} className="space-y-4" onSubmit={(e) => { e.preventDefault(); const form = new FormData(e.currentTarget); form.set("mailboxId", editingMailboxId === "all" ? "" : editingMailboxId); form.set("isDefault", editingIsDefault ? "on" : ""); onUpdate(editing.id, form); setEditing(null) }}>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field label="签名名称"><Input name="name" defaultValue={editing.name} required /></Field>
|
||||
<Field label="绑定邮箱">
|
||||
<MailboxSelect value={editingMailboxId} mailboxes={mailboxes} onChange={(value) => setEditing((current) => current ? { ...current, mailboxId: value === "all" ? "" : value } : current)} />
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="签名内容">
|
||||
<Textarea name="content" required className="min-h-44" defaultValue={editing.content} />
|
||||
</Field>
|
||||
<label className="flex items-center gap-3 text-sm font-medium">
|
||||
<Checkbox checked={editingIsDefault} onCheckedChange={(value) => setEditing((current) => current ? { ...current, isDefault: value === true } : current)} />
|
||||
<span>设为当前范围默认签名</span>
|
||||
</label>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setEditing(null)}>取消</Button>
|
||||
<Button disabled={pending}>{pending ? "保存中..." : "保存修改"}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<ConfirmDialog open={!!pendingConfirm} title={pendingConfirm?.title || ""} description={pendingConfirm?.description} confirmText={pendingConfirm?.confirmText || "删除"} destructive pending={pending} onOpenChange={(open) => { if (!open) setPendingConfirm(null) }} onConfirm={() => pendingConfirm?.onConfirm()} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ContactsSection({ items, loading, onCreate, onDelete, onCopy, pending }: { items: { id: string; name: string; email: string; note: string }[]; loading: boolean; onCreate: (form: FormData) => void; onDelete: (id: string) => void; onCopy: (text: string) => void; pending: boolean }) {
|
||||
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
|
||||
return (
|
||||
@@ -773,6 +986,7 @@ function MailboxSelect({ value, mailboxes, onChange }: { value: string; mailboxe
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) { return <div className="space-y-2"><Label>{label}</Label>{children}</div> }
|
||||
function EmptyState({ text }: { text: string }) { return <div className="rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground">{text}</div> }
|
||||
function folderLabel(folder: string) { return ({ Inbox: "收件箱", Sent: "已发送", Drafts: "草稿箱", Archive: "归档", Spam: "垃圾邮件", Trash: "回收站" } as Record<string, string>)[folder] || folder }
|
||||
function clientServerHost(hostname?: string, address?: string) { const value = (hostname || "").trim(); if (value) return value; const domain = (address || "").split("@")[1]; return domain ? `mail.${domain}` : "mail.example.com" }
|
||||
function AccountHeader({ collapsed, name, email, darkMode, onToggleTheme, onBack }: { collapsed: boolean; name: string; email?: string; darkMode: boolean; onToggleTheme: () => void; onBack: () => void }) {
|
||||
const displayName = cleanAccountName(name, email)
|
||||
if (collapsed) return <div className="flex justify-center"><Avatar className="size-9 rounded-full"><AvatarFallback className="bg-primary text-sm font-semibold text-primary-foreground">{accountInitial(displayName, email)}</AvatarFallback></Avatar></div>
|
||||
|
||||
Reference in New Issue
Block a user