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,
|
updated_at TEXT NOT NULL,
|
||||||
UNIQUE(user_id, email)
|
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 (
|
`CREATE TABLE IF NOT EXISTS mail_rules (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
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)
|
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_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_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_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)`,
|
`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) {
|
func TestUserTwoFactorSetupAndLogin(t *testing.T) {
|
||||||
a := newTestApp(t)
|
a := newTestApp(t)
|
||||||
a.cfg.TwoFactorEnabled = true
|
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})
|
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) {
|
func (a *App) handleListRules(w http.ResponseWriter, r *http.Request) {
|
||||||
user := currentUser(r)
|
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)
|
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
|
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) {
|
func scanRule(row messageSummaryScanner) (MailRule, error) {
|
||||||
var item MailRule
|
var item MailRule
|
||||||
var enabled, applyToExisting, stopProcessing int
|
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).Get("/me/contacts", a.handleListContacts)
|
||||||
r.With(a.requireAuth).Post("/me/contacts", a.handleCreateContact)
|
r.With(a.requireAuth).Post("/me/contacts", a.handleCreateContact)
|
||||||
r.With(a.requireAuth).Delete("/me/contacts/{id}", a.handleDeleteContact)
|
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).Get("/me/rules", a.handleListRules)
|
||||||
r.With(a.requireAuth).Post("/me/rules", a.handleCreateRule)
|
r.With(a.requireAuth).Post("/me/rules", a.handleCreateRule)
|
||||||
r.With(a.requireAuth).Delete("/me/rules/{id}", a.handleDeleteRule)
|
r.With(a.requireAuth).Delete("/me/rules/{id}", a.handleDeleteRule)
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ type PublicSettings struct {
|
|||||||
OpenRegistration bool `json:"openRegistration"`
|
OpenRegistration bool `json:"openRegistration"`
|
||||||
TurnstileEnabled bool `json:"turnstileEnabled"`
|
TurnstileEnabled bool `json:"turnstileEnabled"`
|
||||||
TurnstileSiteKey string `json:"turnstileSiteKey"`
|
TurnstileSiteKey string `json:"turnstileSiteKey"`
|
||||||
|
PublicHostname string `json:"publicHostname"`
|
||||||
MailAutoRefresh bool `json:"mailAutoRefresh"`
|
MailAutoRefresh bool `json:"mailAutoRefresh"`
|
||||||
MailRefreshMs int `json:"mailRefreshMs"`
|
MailRefreshMs int `json:"mailRefreshMs"`
|
||||||
MailboxDomains []PublicDomain `json:"mailboxDomains,omitempty"`
|
MailboxDomains []PublicDomain `json:"mailboxDomains,omitempty"`
|
||||||
@@ -87,7 +88,7 @@ func (a *App) handlePublicSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
if refreshSeconds <= 0 {
|
if refreshSeconds <= 0 {
|
||||||
refreshSeconds = 30
|
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
|
// Include available domains for mailbox creation during registration
|
||||||
if a.cfg.OpenRegistration {
|
if a.cfg.OpenRegistration {
|
||||||
|
|||||||
@@ -132,6 +132,17 @@ type Contact struct {
|
|||||||
CreatedAt time.Time `json:"createdAt"`
|
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 {
|
type MailRule struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
UserID string `json:"userId,omitempty"`
|
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 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 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 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 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 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 }
|
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 SystemSettingsPayload = Omit<SystemSettings, "smtpPasswordSet" | "turnstileSecretSet"> & { smtpPassword: string; turnstileSecretKey: string }
|
||||||
export type PublicDomain = { id: string; name: 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 LoginPayload = { email?: string; password?: string; turnstileToken?: string; challengeToken?: string; twoFactorCode?: string }
|
||||||
export type LoginResponse = { user?: User; twoFactorRequired?: boolean; challengeToken?: 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 }
|
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"
|
export * from "./api-types"
|
||||||
|
|
||||||
const REQUEST_TIMEOUT_MS = 15_000
|
const REQUEST_TIMEOUT_MS = 15_000
|
||||||
@@ -45,6 +45,12 @@ export const api = {
|
|||||||
contacts: () => request<ListResponse<Contact>>("/api/me/contacts"),
|
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) }),
|
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" }),
|
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"),
|
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) }),
|
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" }),
|
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 [darkMode, setDarkMode] = React.useState(getInitialTheme)
|
||||||
const [displayMode] = useDisplayMode()
|
const [displayMode] = useDisplayMode()
|
||||||
const [refreshing, setRefreshing] = React.useState(false)
|
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 [bulkPending, setBulkPending] = React.useState(false)
|
||||||
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
|
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
|
||||||
const sidebarPanelRef = React.useRef<ImperativePanelHandle>(null)
|
const sidebarPanelRef = React.useRef<ImperativePanelHandle>(null)
|
||||||
@@ -303,11 +305,17 @@ export function MailPage() {
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!publicSettings.data?.mailAutoRefresh) return
|
if (!publicSettings.data?.mailAutoRefresh) return
|
||||||
const timer = window.setInterval(() => {
|
const timer = window.setInterval(() => {
|
||||||
qc.invalidateQueries({ queryKey: ["messages"] })
|
setAutoRefreshing(true)
|
||||||
qc.invalidateQueries({ queryKey: ["folders"] })
|
Promise.all([
|
||||||
qc.invalidateQueries({ queryKey: ["mail-stats"] })
|
qc.invalidateQueries({ queryKey: ["messages"] }),
|
||||||
qc.invalidateQueries({ queryKey: ["labels"] })
|
qc.invalidateQueries({ queryKey: ["folders"] }),
|
||||||
qc.invalidateQueries({ queryKey: ["mail-notifications"] })
|
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)
|
}, mailRefreshInterval || 30000)
|
||||||
return () => window.clearInterval(timer)
|
return () => window.clearInterval(timer)
|
||||||
}, [mailRefreshInterval, publicSettings.data?.mailAutoRefresh, qc])
|
}, [mailRefreshInterval, publicSettings.data?.mailAutoRefresh, qc])
|
||||||
@@ -436,6 +444,7 @@ export function MailPage() {
|
|||||||
setRefreshing(true)
|
setRefreshing(true)
|
||||||
try {
|
try {
|
||||||
await refreshMailData()
|
await refreshMailData()
|
||||||
|
setLastAutoRefreshAt(new Date())
|
||||||
} finally {
|
} finally {
|
||||||
setRefreshing(false)
|
setRefreshing(false)
|
||||||
}
|
}
|
||||||
@@ -549,7 +558,14 @@ export function MailPage() {
|
|||||||
<section className="flex h-full min-h-0 flex-col">
|
<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">
|
<header className="flex h-16 shrink-0 items-center justify-between gap-3 border-b px-5">
|
||||||
<div className="flex items-center gap-2">
|
<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>
|
<Button variant="outline" size="sm" disabled={!activeMailboxId || markAllRead.isPending || unreadCount === 0} onClick={() => markAllRead.mutate(allMessages)}><MailCheck className="h-4 w-4" />全部已读</Button>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<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 }) {
|
function ComposeDialog({ mailbox, open, draft, onOpenChange, onSent }: { mailbox?: Mailbox; open: boolean; draft?: ComposeDraft; onOpenChange: (v: boolean) => void; onSent: () => void }) {
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const [files, setFiles] = React.useState<File[]>([])
|
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 }) })
|
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>) {
|
async function submit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
e.preventDefault()
|
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="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="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>
|
<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 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>
|
</div>
|
||||||
<DialogFooter className="border-t bg-background px-6 py-4">
|
<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 [value, setValue] = React.useState(defaultValue)
|
||||||
const [mode, setMode] = React.useState<MarkdownMode>("edit")
|
const [mode, setMode] = React.useState<MarkdownMode>("edit")
|
||||||
const textareaRef = React.useRef<HTMLTextAreaElement>(null)
|
const textareaRef = React.useRef<HTMLTextAreaElement>(null)
|
||||||
|
const dirtyRef = React.useRef(false)
|
||||||
|
const lastDefaultRef = React.useRef(defaultValue)
|
||||||
const previewHtml = React.useMemo(() => markdownToHtml(value), [value])
|
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() {
|
function focusEditor() {
|
||||||
window.requestAnimationFrame(() => textareaRef.current?.focus())
|
window.requestAnimationFrame(() => textareaRef.current?.focus())
|
||||||
}
|
}
|
||||||
function updateSelection(next: string, start: number, end: number) {
|
function updateSelection(next: string, start: number, end: number) {
|
||||||
|
dirtyRef.current = true
|
||||||
setValue(next)
|
setValue(next)
|
||||||
window.requestAnimationFrame(() => {
|
window.requestAnimationFrame(() => {
|
||||||
const textarea = textareaRef.current
|
const textarea = textareaRef.current
|
||||||
@@ -1380,7 +1406,7 @@ function MarkdownComposer({ defaultValue }: { defaultValue: string }) {
|
|||||||
<Textarea
|
<Textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(event) => setValue(event.target.value)}
|
onChange={(event) => { dirtyRef.current = true; setValue(event.target.value) }}
|
||||||
placeholder="在此输入邮件内容..."
|
placeholder="在此输入邮件内容..."
|
||||||
className={cn("min-h-[280px] resize-y rounded-none border-0 shadow-none focus-visible:ring-0", mode === "split" && "md:border-r")}
|
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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||||
import type { ImperativePanelHandle } from "react-resizable-panels"
|
import type { ImperativePanelHandle } from "react-resizable-panels"
|
||||||
import { useNavigate, useSearchParams } from "react-router-dom"
|
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 { 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 { cn, formatBytes } from "@/lib/utils"
|
||||||
import { applyTheme, getInitialTheme } from "@/lib/theme"
|
import { applyTheme, getInitialTheme } from "@/lib/theme"
|
||||||
import { DisplayMode, useDisplayMode } from "@/lib/display-mode"
|
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 { PasswordInput } from "@/components/ui/password-input"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
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 { ConfirmDialog } from "@/components/confirm-dialog"
|
||||||
import { useToast } from "@/hooks/use-toast"
|
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 }
|
type PendingConfirm = { title: string; description?: string; confirmText: string; destructive?: boolean; onConfirm: () => void }
|
||||||
const tabs: Record<Tab, { label: string; icon: React.ReactNode }> = {
|
const tabs: Record<Tab, { label: string; icon: React.ReactNode }> = {
|
||||||
profile: { label: "账户资料", icon: <Settings className="h-4 w-4" /> },
|
profile: { label: "账户资料", icon: <Settings className="h-4 w-4" /> },
|
||||||
mailboxes: { label: "邮箱管理", icon: <Mail 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" /> },
|
contacts: { label: "联系人管理", icon: <Contact className="h-4 w-4" /> },
|
||||||
cleanup: { label: "邮件清理", icon: <Trash2 className="h-4 w-4" /> },
|
cleanup: { label: "邮件清理", icon: <Trash2 className="h-4 w-4" /> },
|
||||||
rules: { label: "收件规则", icon: <SlidersHorizontal 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 user = me.data?.user
|
||||||
const mailboxes = useQuery({ queryKey: ["mailboxes", "mine"], queryFn: api.myMailboxes })
|
const mailboxes = useQuery({ queryKey: ["mailboxes", "mine"], queryFn: api.myMailboxes })
|
||||||
const mailboxApplyOptions = useQuery({ queryKey: ["mailbox-apply-options"], queryFn: api.mailboxApplyOptions })
|
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 contacts = useQuery({ queryKey: ["contacts"], queryFn: api.contacts })
|
||||||
|
const signatures = useQuery({ queryKey: ["signatures"], queryFn: api.signatures })
|
||||||
const rules = useQuery({ queryKey: ["rules"], queryFn: api.rules })
|
const rules = useQuery({ queryKey: ["rules"], queryFn: api.rules })
|
||||||
const blocked = useQuery({ queryKey: ["blocked-senders"], queryFn: api.blockedSenders })
|
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])
|
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 }),
|
onError: (error) => toast({ title: "保存失败", description: error.message }),
|
||||||
})
|
})
|
||||||
const deleteContact = useMutation({ mutationFn: api.deleteContact, onSuccess: () => { qc.invalidateQueries({ queryKey: ["contacts"] }); toast({ title: "联系人已删除" }) } })
|
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({
|
const createRule = useMutation({
|
||||||
mutationFn: (payload: {
|
mutationFn: (payload: {
|
||||||
mailboxId: string
|
mailboxId: string
|
||||||
@@ -214,6 +235,8 @@ export function ProfilePage() {
|
|||||||
|
|
||||||
function renderTab() {
|
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 === "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 === "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 === "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} />
|
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 }) {
|
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)
|
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
|
||||||
return (
|
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 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 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 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 }) {
|
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)
|
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>
|
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>
|
||||||
|
|||||||
+2
-1
@@ -123,9 +123,10 @@ docker compose -f docker-compose.stack.yml -f docker-compose.stack.build.yml up
|
|||||||
|
|
||||||
- Postfix 读取 `/data/lanqin.db` 中的 `domains`、`mailboxes`、`aliases`。
|
- Postfix 读取 `/data/lanqin.db` 中的 `domains`、`mailboxes`、`aliases`。
|
||||||
- Dovecot 读取同一个 SQLite 数据库进行邮箱认证,并使用 `/var/mail/vhosts` 作为 Maildir 根目录。
|
- Dovecot 读取同一个 SQLite 数据库进行邮箱认证,并使用 `/var/mail/vhosts` 作为 Maildir 根目录。
|
||||||
|
- 第三方客户端可使用 IMAP SSL `993`、POP3 SSL `995`、SMTP SSL `465` 或 Submission `587`。
|
||||||
- Rspamd 通过 milter 接入 Postfix,负责 DKIM 签名和垃圾邮件标记。
|
- Rspamd 通过 milter 接入 Postfix,负责 DKIM 签名和垃圾邮件标记。
|
||||||
- Rspamd 会周期性从 SQLite 导出域名 DKIM 私钥到容器内 `/var/lib/rspamd/dkim`。
|
- Rspamd 会周期性从 SQLite 导出域名 DKIM 私钥到容器内 `/var/lib/rspamd/dkim`。
|
||||||
- Go API 是 Webmail 和管理后台唯一入口;浏览器不直接连接 SMTP/IMAP。
|
- Go API 是 Webmail 和管理后台入口;浏览器不直接连接 SMTP/IMAP/POP3。
|
||||||
- Go API 会读取 `LANQIN_MAILDIR_ROOT=/var/mail/vhosts`,周期扫描 Maildir,把 Postfix/Dovecot 入站邮件同步成 Webmail 索引。
|
- Go API 会读取 `LANQIN_MAILDIR_ROOT=/var/mail/vhosts`,周期扫描 Maildir,把 Postfix/Dovecot 入站邮件同步成 Webmail 索引。
|
||||||
|
|
||||||
## SMTP 发信排查
|
## SMTP 发信排查
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
|||||||
apt-get update && apt-get install -y --no-install-recommends \
|
apt-get update && apt-get install -y --no-install-recommends \
|
||||||
ca-certificates tzdata sqlite3 supervisor nginx \
|
ca-certificates tzdata sqlite3 supervisor nginx \
|
||||||
postfix postfix-sqlite \
|
postfix postfix-sqlite \
|
||||||
dovecot-core dovecot-imapd dovecot-lmtpd dovecot-sqlite ssl-cert \
|
dovecot-core dovecot-imapd dovecot-pop3d dovecot-lmtpd dovecot-sqlite ssl-cert \
|
||||||
rspamd
|
rspamd
|
||||||
|
|
||||||
COPY --from=api-build /out/lanqin-api /usr/local/bin/lanqin-api
|
COPY --from=api-build /out/lanqin-api /usr/local/bin/lanqin-api
|
||||||
@@ -46,5 +46,5 @@ COPY deploy/rspamd/sync-dkim.sh /usr/local/bin/lanqin-rspamd-sync-dkim
|
|||||||
|
|
||||||
RUN chmod +x /entrypoint.sh /usr/local/bin/lanqin-rspamd-sync-dkim && mkdir -p /run/nginx /run/rspamd /var/mail/vhosts /data /var/lib/rspamd/dkim
|
RUN chmod +x /entrypoint.sh /usr/local/bin/lanqin-rspamd-sync-dkim && mkdir -p /run/nginx /run/rspamd /var/mail/vhosts /data /var/lib/rspamd/dkim
|
||||||
|
|
||||||
EXPOSE 80 443 25 587 993
|
EXPOSE 80 443 25 465 587 993 995
|
||||||
CMD ["/entrypoint.sh"]
|
CMD ["/entrypoint.sh"]
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ services:
|
|||||||
- ./mail:/var/mail/vhosts
|
- ./mail:/var/mail/vhosts
|
||||||
ports:
|
ports:
|
||||||
- "25:25"
|
- "25:25"
|
||||||
|
- "465:465"
|
||||||
- "587:587"
|
- "587:587"
|
||||||
depends_on:
|
depends_on:
|
||||||
- dovecot
|
- dovecot
|
||||||
@@ -48,6 +49,7 @@ services:
|
|||||||
- ./mail:/var/mail/vhosts
|
- ./mail:/var/mail/vhosts
|
||||||
ports:
|
ports:
|
||||||
- "993:993"
|
- "993:993"
|
||||||
|
- "995:995"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
rspamd:
|
rspamd:
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ services:
|
|||||||
- "80:80"
|
- "80:80"
|
||||||
- "443:443"
|
- "443:443"
|
||||||
- "25:25"
|
- "25:25"
|
||||||
|
- "465:465"
|
||||||
- "587:587"
|
- "587:587"
|
||||||
- "993:993"
|
- "993:993"
|
||||||
|
- "995:995"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/data
|
- ./data:/data
|
||||||
- ./mail:/var/mail/vhosts
|
- ./mail:/var/mail/vhosts
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ ENV DEBIAN_FRONTEND=noninteractive
|
|||||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||||
--mount=type=cache,target=/var/lib/apt/lists,sharing=locked \
|
--mount=type=cache,target=/var/lib/apt/lists,sharing=locked \
|
||||||
rm -f /etc/apt/apt.conf.d/docker-clean && \
|
rm -f /etc/apt/apt.conf.d/docker-clean && \
|
||||||
apt-get update && apt-get install -y --no-install-recommends dovecot-core dovecot-imapd dovecot-lmtpd dovecot-sqlite ssl-cert ca-certificates
|
apt-get update && apt-get install -y --no-install-recommends dovecot-core dovecot-imapd dovecot-pop3d dovecot-lmtpd dovecot-sqlite ssl-cert ca-certificates
|
||||||
COPY dovecot.conf /etc/dovecot/dovecot.conf
|
COPY dovecot.conf /etc/dovecot/dovecot.conf
|
||||||
COPY dovecot-sql.conf.ext /etc/dovecot/dovecot-sql.conf.ext
|
COPY dovecot-sql.conf.ext /etc/dovecot/dovecot-sql.conf.ext
|
||||||
COPY entrypoint.sh /entrypoint.sh
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
RUN chmod +x /entrypoint.sh
|
RUN chmod +x /entrypoint.sh
|
||||||
EXPOSE 993 24 12345
|
EXPOSE 993 995 24 12345
|
||||||
CMD ["/entrypoint.sh"]
|
CMD ["/entrypoint.sh"]
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
protocols = imap lmtp
|
protocols = imap pop3 lmtp
|
||||||
listen = *
|
listen = *
|
||||||
base_dir = /var/run/dovecot/
|
base_dir = /var/run/dovecot/
|
||||||
|
|
||||||
@@ -45,6 +45,13 @@ service imap-login {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
service pop3-login {
|
||||||
|
inet_listener pop3s {
|
||||||
|
port = 995
|
||||||
|
ssl = yes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
service lmtp {
|
service lmtp {
|
||||||
inet_listener lmtp {
|
inet_listener lmtp {
|
||||||
address = 0.0.0.0
|
address = 0.0.0.0
|
||||||
|
|||||||
@@ -11,5 +11,5 @@ COPY master.cf /etc/postfix/master.cf
|
|||||||
COPY sqlite-*.cf /etc/postfix/
|
COPY sqlite-*.cf /etc/postfix/
|
||||||
COPY entrypoint.sh /entrypoint.sh
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
RUN chmod +x /entrypoint.sh
|
RUN chmod +x /entrypoint.sh
|
||||||
EXPOSE 25 587
|
EXPOSE 25 465 587
|
||||||
CMD ["/entrypoint.sh"]
|
CMD ["/entrypoint.sh"]
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ submission inet n - n - - smtpd
|
|||||||
-o smtpd_tls_security_level=may
|
-o smtpd_tls_security_level=may
|
||||||
-o smtpd_sasl_auth_enable=yes
|
-o smtpd_sasl_auth_enable=yes
|
||||||
-o smtpd_relay_restrictions=permit_sasl_authenticated,reject
|
-o smtpd_relay_restrictions=permit_sasl_authenticated,reject
|
||||||
|
smtps inet n - n - - smtpd
|
||||||
|
-o syslog_name=postfix/smtps
|
||||||
|
-o smtpd_tls_wrappermode=yes
|
||||||
|
-o smtpd_sasl_auth_enable=yes
|
||||||
|
-o smtpd_relay_restrictions=permit_sasl_authenticated,reject
|
||||||
pickup unix n - n 60 1 pickup
|
pickup unix n - n 60 1 pickup
|
||||||
cleanup unix n - n - 0 cleanup
|
cleanup unix n - n - 0 cleanup
|
||||||
qmgr unix n - n 300 1 qmgr
|
qmgr unix n - n 300 1 qmgr
|
||||||
|
|||||||
Reference in New Issue
Block a user