From 8a042ae3dc1e09f04853e5c34cbcbbdc75fabef0 Mon Sep 17 00:00:00 2001 From: LanQin Date: Mon, 15 Jun 2026 00:37:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E6=89=A9=E5=B1=95=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=E7=AE=A1=E7=90=86=E4=B8=8E=E7=99=BB=E5=BD=95=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E8=83=BD=E5=8A=9B=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增用户、域名、邮箱、别名、邮件、系统设置与模板的管理接口和页面。 - 支持双因素认证、Turnstile、人机验证与管理员 SMTP 测试。 - 增加无人收件/未注册邮件归档、Maildir 同步和数据库迁移支持。 - 更新前端导航、个人中心 2FA 配置以及相关部署示例。 --- apps/api/internal/app/admin_handlers.go | 613 ++++++++++- apps/api/internal/app/app.go | 210 +++- apps/api/internal/app/app_test.go | 239 ++++ apps/api/internal/app/config.go | 16 + apps/api/internal/app/mail_handlers.go | 124 ++- apps/api/internal/app/maildir_sync.go | 142 ++- apps/api/internal/app/mime.go | 16 +- apps/api/internal/app/router_auth.go | 107 +- apps/api/internal/app/settings_handlers.go | 346 ++++++ apps/api/internal/app/template_handlers.go | 210 ++++ apps/api/internal/app/turnstile.go | 61 ++ apps/api/internal/app/two_factor.go | 279 +++++ apps/api/internal/app/types.go | 22 +- apps/web/package-lock.json | 151 +++ apps/web/package.json | 2 + apps/web/src/components/protected-layout.tsx | 55 +- apps/web/src/components/ui/switch.tsx | 27 + apps/web/src/index.css | 13 + apps/web/src/lib/api.ts | 71 +- apps/web/src/main.tsx | 11 +- apps/web/src/pages/admin.tsx | 1024 +++++++++++++----- apps/web/src/pages/login.tsx | 102 +- apps/web/src/pages/mail.tsx | 11 + apps/web/src/pages/profile.tsx | 84 +- deploy/.env.example | 2 + deploy/dovecot/dovecot-sql.conf.ext | 2 +- deploy/postfix/sqlite-mailboxes.cf | 2 +- 27 files changed, 3558 insertions(+), 384 deletions(-) create mode 100644 apps/api/internal/app/settings_handlers.go create mode 100644 apps/api/internal/app/template_handlers.go create mode 100644 apps/api/internal/app/turnstile.go create mode 100644 apps/api/internal/app/two_factor.go create mode 100644 apps/web/src/components/ui/switch.tsx diff --git a/apps/api/internal/app/admin_handlers.go b/apps/api/internal/app/admin_handlers.go index 5415051..5026fac 100644 --- a/apps/api/internal/app/admin_handlers.go +++ b/apps/api/internal/app/admin_handlers.go @@ -5,12 +5,252 @@ import ( "database/sql" "errors" "net/http" + "strconv" "strings" "time" + "github.com/go-chi/chi/v5" "golang.org/x/crypto/bcrypt" ) +func (a *App) handleAdminOverview(w http.ResponseWriter, r *http.Request) { + var out struct { + Users int64 `json:"users"` + ActiveUsers int64 `json:"activeUsers"` + Domains int64 `json:"domains"` + Mailboxes int64 `json:"mailboxes"` + ActiveMailboxes int64 `json:"activeMailboxes"` + Aliases int64 `json:"aliases"` + Messages int64 `json:"messages"` + UnreadMessages int64 `json:"unreadMessages"` + StorageBytes int64 `json:"storageBytes"` + } + queries := []struct { + q string + dest *int64 + }{ + {`SELECT COUNT(*) FROM users`, &out.Users}, + {`SELECT COUNT(*) FROM users WHERE disabled=0`, &out.ActiveUsers}, + {`SELECT COUNT(*) FROM domains`, &out.Domains}, + {`SELECT COUNT(*) FROM mailboxes`, &out.Mailboxes}, + {`SELECT COUNT(*) FROM mailboxes WHERE status='active'`, &out.ActiveMailboxes}, + {`SELECT COUNT(*) FROM aliases`, &out.Aliases}, + {`SELECT COUNT(*) FROM messages`, &out.Messages}, + {`SELECT COUNT(*) FROM messages WHERE is_read=0`, &out.UnreadMessages}, + {`SELECT COALESCE(SUM(size_bytes),0) FROM messages`, &out.StorageBytes}, + } + for _, item := range queries { + if err := a.db.QueryRowContext(r.Context(), item.q).Scan(item.dest); err != nil { + respondError(w, http.StatusInternalServerError, "failed to load overview") + return + } + } + respondJSON(w, http.StatusOK, out) +} + +func (a *App) handleListUsers(w http.ResponseWriter, r *http.Request) { + rows, err := a.db.QueryContext(r.Context(), `SELECT u.id,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.created_at,COUNT(mb.id),COALESCE(GROUP_CONCAT(mb.address), '') + FROM users u LEFT JOIN mailboxes mb ON mb.user_id=u.id + GROUP BY u.id,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.created_at + ORDER BY u.created_at DESC`) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to list users") + return + } + defer rows.Close() + items := []AdminUser{} + for rows.Next() { + var item AdminUser + var disabled, twoFactorEnabled int + var created, mailboxCSV string + if err := rows.Scan(&item.ID, &item.Email, &item.DisplayName, &item.Role, &disabled, &twoFactorEnabled, &created, &item.MailboxCount, &mailboxCSV); err != nil { + respondError(w, http.StatusInternalServerError, "failed to scan users") + return + } + item.Disabled = intBool(disabled) + item.TwoFactorEnabled = intBool(twoFactorEnabled) + item.CreatedAt = parseTime(created) + item.Mailboxes = splitCSV(mailboxCSV) + items = append(items, item) + } + respondJSON(w, http.StatusOK, map[string]any{"items": items}) +} + +func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) { + var req struct { + Email string `json:"email"` + DisplayName string `json:"displayName"` + Role string `json:"role"` + Password string `json:"password"` + Disabled bool `json:"disabled"` + } + if err := decodeJSON(r, &req); err != nil { + badRequest(w, err) + return + } + email := normalizeEmail(req.Email) + if email == "" || !strings.Contains(email, "@") { + badRequest(w, errors.New("invalid email")) + return + } + displayName := strings.TrimSpace(req.DisplayName) + if displayName == "" { + displayName = email + } + role := strings.TrimSpace(req.Role) + if role == "" { + role = "user" + } + if role != "admin" && role != "user" { + badRequest(w, errors.New("invalid role")) + return + } + if len(req.Password) < 8 { + badRequest(w, errors.New("password must be at least 8 characters")) + return + } + passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to hash password") + return + } + id := newID("usr") + now := a.now().UTC().Format(time.RFC3339Nano) + _, err = a.db.ExecContext(r.Context(), `INSERT INTO users(id,email,display_name,role,password_hash,disabled,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?)`, id, email, displayName, role, string(passwordHash), boolInt(req.Disabled), now, now) + if err != nil { + badRequest(w, err) + return + } + user, err := a.adminUserByID(r.Context(), id) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to load user") + return + } + respondJSON(w, http.StatusCreated, user) +} + +func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + current := currentUser(r) + var req struct { + DisplayName string `json:"displayName"` + Role string `json:"role"` + Disabled *bool `json:"disabled"` + } + if err := decodeJSON(r, &req); err != nil { + badRequest(w, err) + return + } + displayName := strings.TrimSpace(req.DisplayName) + if displayName == "" { + badRequest(w, errors.New("displayName is required")) + return + } + role := strings.TrimSpace(req.Role) + if role == "" { + role = "user" + } + if role != "admin" && role != "user" { + badRequest(w, errors.New("invalid role")) + return + } + disabled := false + if req.Disabled != nil { + disabled = *req.Disabled + } + if current != nil && current.ID == id && (disabled || role != "admin") { + badRequest(w, errors.New("cannot remove your own admin access")) + return + } + if err := a.ensureAdminRemains(r.Context(), id, role, disabled); err != nil { + badRequest(w, err) + return + } + _, err := a.db.ExecContext(r.Context(), `UPDATE users SET display_name=?, role=?, disabled=?, updated_at=? WHERE id=?`, + displayName, role, boolInt(disabled), a.now().UTC().Format(time.RFC3339Nano), id) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to update user") + return + } + user, err := a.adminUserByID(r.Context(), id) + if err != nil { + respondError(w, http.StatusNotFound, "user not found") + return + } + respondJSON(w, http.StatusOK, user) +} + +func (a *App) handleResetUserPassword(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + var req struct { + Password string `json:"password"` + } + if err := decodeJSON(r, &req); err != nil { + badRequest(w, err) + return + } + if len(req.Password) < 8 { + badRequest(w, errors.New("password must be at least 8 characters")) + return + } + hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to hash password") + 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 start transaction") + return + } + defer tx.Rollback() + res, err := tx.ExecContext(r.Context(), `UPDATE users SET password_hash=?, updated_at=? WHERE id=?`, string(hash), now, id) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to reset password") + return + } + affected, _ := res.RowsAffected() + if affected == 0 { + respondError(w, http.StatusNotFound, "user not found") + return + } + if _, err := tx.ExecContext(r.Context(), `UPDATE mailboxes SET password_hash=?, updated_at=? WHERE user_id=?`, string(hash), now, id); err != nil { + respondError(w, http.StatusInternalServerError, "failed to update mailbox passwords") + return + } + if err := tx.Commit(); err != nil { + respondError(w, http.StatusInternalServerError, "failed to save password") + return + } + respondJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func (a *App) handleDeleteUser(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + current := currentUser(r) + if current != nil && current.ID == id { + badRequest(w, errors.New("cannot delete your own user")) + return + } + if err := a.ensureAdminRemains(r.Context(), id, "user", true); err != nil { + badRequest(w, err) + return + } + res, err := a.db.ExecContext(r.Context(), `DELETE FROM users WHERE id=?`, id) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to delete user") + return + } + affected, _ := res.RowsAffected() + if affected == 0 { + respondError(w, http.StatusNotFound, "user not found") + return + } + respondJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + func (a *App) handleListDomains(w http.ResponseWriter, r *http.Request) { rows, err := a.db.QueryContext(r.Context(), `SELECT id,name,status,dkim_selector,dkim_public_key,dns_status,dns_checked_at,created_at FROM domains ORDER BY name`) if err != nil { @@ -55,6 +295,63 @@ func (a *App) handleCreateDomain(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusCreated, d) } +func (a *App) handleUpdateDomain(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + var req struct { + Status string `json:"status"` + } + if err := decodeJSON(r, &req); err != nil { + badRequest(w, err) + return + } + status := strings.TrimSpace(req.Status) + if status != "active" && status != "disabled" { + badRequest(w, errors.New("invalid status")) + return + } + res, err := a.db.ExecContext(r.Context(), `UPDATE domains SET status=?, updated_at=? WHERE id=?`, + status, a.now().UTC().Format(time.RFC3339Nano), id) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to update domain") + return + } + affected, _ := res.RowsAffected() + if affected == 0 { + respondError(w, http.StatusNotFound, "domain not found") + return + } + d, err := a.domainByID(r.Context(), id) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to load domain") + return + } + respondJSON(w, http.StatusOK, d) +} + +func (a *App) handleDeleteDomain(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + var count int + if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM mailboxes WHERE domain_id=?`, id).Scan(&count); err != nil { + respondError(w, http.StatusInternalServerError, "failed to check domain") + return + } + if count > 0 { + badRequest(w, errors.New("domain still has mailboxes")) + return + } + res, err := a.db.ExecContext(r.Context(), `DELETE FROM domains WHERE id=?`, id) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to delete domain") + return + } + affected, _ := res.RowsAffected() + if affected == 0 { + respondError(w, http.StatusNotFound, "domain not found") + return + } + respondJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + func (a *App) handleListMailboxes(w http.ResponseWriter, r *http.Request) { rows, err := a.db.QueryContext(r.Context(), `SELECT mb.id,mb.user_id,u.email,mb.domain_id,mb.local_part,mb.address,mb.display_name,mb.quota_mb,mb.status,mb.created_at FROM mailboxes mb JOIN users u ON u.id=mb.user_id ORDER BY mb.address`) @@ -197,6 +494,119 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusCreated, m) } +func (a *App) handleUpdateMailbox(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + var req struct { + DisplayName string `json:"displayName"` + QuotaMB int `json:"quotaMb"` + Status string `json:"status"` + UserID string `json:"userId"` + } + if err := decodeJSON(r, &req); err != nil { + badRequest(w, err) + return + } + displayName := strings.TrimSpace(req.DisplayName) + if displayName == "" { + badRequest(w, errors.New("displayName is required")) + return + } + if req.QuotaMB <= 0 { + req.QuotaMB = 1024 + } + status := strings.TrimSpace(req.Status) + if status == "" { + status = "active" + } + if status != "active" && status != "disabled" { + badRequest(w, errors.New("invalid status")) + return + } + userID := strings.TrimSpace(req.UserID) + if userID == "" { + badRequest(w, errors.New("userId is required")) + return + } + var disabled int + if err := a.db.QueryRowContext(r.Context(), `SELECT disabled FROM users WHERE id=?`, userID).Scan(&disabled); err != nil { + if errors.Is(err, sql.ErrNoRows) { + respondError(w, http.StatusNotFound, "owner user not found") + } else { + respondError(w, http.StatusInternalServerError, "failed to load owner user") + } + return + } + if intBool(disabled) { + badRequest(w, errors.New("owner user is disabled")) + return + } + res, err := a.db.ExecContext(r.Context(), `UPDATE mailboxes SET user_id=?,display_name=?,quota_mb=?,status=?,updated_at=? WHERE id=?`, + userID, displayName, req.QuotaMB, status, a.now().UTC().Format(time.RFC3339Nano), id) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to update mailbox") + return + } + affected, _ := res.RowsAffected() + if affected == 0 { + respondError(w, http.StatusNotFound, "mailbox not found") + return + } + m, err := a.mailboxByID(r.Context(), id) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to load mailbox") + return + } + respondJSON(w, http.StatusOK, m) +} + +func (a *App) handleDeleteMailbox(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + current := currentUser(r) + var owner string + if err := a.db.QueryRowContext(r.Context(), `SELECT user_id FROM mailboxes WHERE id=?`, id).Scan(&owner); err != nil { + respondError(w, http.StatusNotFound, "mailbox not found") + return + } + var count int + if current != nil && owner == current.ID { + if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM mailboxes WHERE user_id=?`, owner).Scan(&count); err != nil { + respondError(w, http.StatusInternalServerError, "failed to check mailbox") + return + } + if count <= 1 { + badRequest(w, errors.New("cannot delete your last mailbox")) + return + } + } + rows, err := a.db.QueryContext(r.Context(), `SELECT id FROM messages WHERE mailbox_id=?`, id) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to load mailbox messages") + return + } + messageIDs := []string{} + for rows.Next() { + var messageID string + if rows.Scan(&messageID) == nil { + messageIDs = append(messageIDs, messageID) + } + } + rows.Close() + for _, messageID := range messageIDs { + a.deleteMessageFiles(r.Context(), messageID) + } + res, err := a.db.ExecContext(r.Context(), `DELETE FROM mailboxes WHERE id=?`, id) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to delete mailbox") + return + } + affected, _ := res.RowsAffected() + if affected == 0 { + respondError(w, http.StatusNotFound, "mailbox not found") + return + } + respondJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + func (a *App) handleListAliases(w http.ResponseWriter, r *http.Request) { rows, err := a.db.QueryContext(r.Context(), `SELECT id,domain_id,source,destination,enabled,created_at FROM aliases ORDER BY source`) if err != nil { @@ -220,6 +630,86 @@ func (a *App) handleListAliases(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusOK, map[string]any{"items": items}) } +func (a *App) handleAdminMessages(w http.ResponseWriter, r *http.Request) { + q := strings.TrimSpace(r.URL.Query().Get("q")) + mailboxID := strings.TrimSpace(r.URL.Query().Get("mailboxId")) + folder := strings.TrimSpace(r.URL.Query().Get("folder")) + offset, _ := strconv.Atoi(r.URL.Query().Get("cursor")) + if offset < 0 { + offset = 0 + } + limit := 50 + + where := []string{"1=1"} + args := []any{} + if mailboxID == "unregistered" { + where = append(where, "m.mailbox_id IS NULL") + } else if mailboxID != "" && mailboxID != "all" { + where = append(where, "m.mailbox_id=?") + args = append(args, mailboxID) + } + if folder != "" && folder != "all" { + if strings.EqualFold(folder, "Unregistered") { + where = append(where, "m.mailbox_id IS NULL") + } else { + where = append(where, "lower(f.name)=lower(?)") + args = append(args, folder) + } + } + if q != "" { + where = append(where, "(m.subject LIKE ? OR m.from_addr LIKE ? OR m.to_addrs LIKE ? OR m.recipient_addr LIKE ? OR m.snippet LIKE ? OR m.body_text LIKE ? OR mb.address LIKE ? OR u.email LIKE ?)") + like := "%" + q + "%" + args = append(args, like, like, like, like, like, like, like, like) + } + args = append(args, limit+1, offset) + + rows, err := a.db.QueryContext(r.Context(), `SELECT m.id,COALESCE(m.mailbox_id,''),COALESCE(mb.address,''),COALESCE(u.email,''),COALESCE(m.recipient_addr,''),COALESCE(m.folder_id,''),COALESCE(f.name,'Unregistered'),m.message_uid,m.message_id,m.subject,m.from_addr,m.to_addrs,m.cc_addrs,m.bcc_addrs,m.sent_at,m.received_at,m.snippet,m.is_read,m.is_starred,m.has_attachments,m.size_bytes + FROM messages m + LEFT JOIN folders f ON f.id=m.folder_id + LEFT JOIN mailboxes mb ON mb.id=m.mailbox_id + LEFT JOIN users u ON u.id=mb.user_id + WHERE `+strings.Join(where, " AND ")+` + ORDER BY m.received_at DESC LIMIT ? OFFSET ?`, args...) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to load messages") + return + } + defer rows.Close() + items := []MailMessage{} + for rows.Next() { + msg, err := scanAdminMessageSummary(rows) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to scan messages") + return + } + items = append(items, msg) + } + next := "" + if len(items) > limit { + items = items[:limit] + next = strconv.Itoa(offset + limit) + } + respondJSON(w, http.StatusOK, map[string]any{"items": items, "nextCursor": next}) +} + +func (a *App) handleAdminMessage(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + msg, err := a.messageByID(r.Context(), id, true) + if err != nil { + respondError(w, http.StatusNotFound, "message not found") + return + } + if err := a.db.QueryRowContext(r.Context(), `SELECT COALESCE(mb.address,''),COALESCE(u.email,''),COALESCE(m.recipient_addr,'') + FROM messages m + LEFT JOIN mailboxes mb ON mb.id=m.mailbox_id + LEFT JOIN users u ON u.id=mb.user_id + WHERE m.id=?`, id).Scan(&msg.MailboxAddress, &msg.OwnerEmail, &msg.RecipientAddr); err != nil { + respondError(w, http.StatusInternalServerError, "failed to load message owner") + return + } + respondJSON(w, http.StatusOK, msg) +} + func (a *App) handleCreateAlias(w http.ResponseWriter, r *http.Request) { var req struct { DomainID string `json:"domainId"` @@ -260,6 +750,64 @@ func (a *App) handleCreateAlias(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusCreated, Alias{ID: id, DomainID: req.DomainID, Source: source, Destination: destination, Enabled: enabled, CreatedAt: parseTime(now)}) } +func (a *App) handleUpdateAlias(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + var req struct { + Source string `json:"source"` + Destination string `json:"destination"` + Enabled *bool `json:"enabled"` + } + if err := decodeJSON(r, &req); err != nil { + badRequest(w, err) + return + } + var domainID string + if err := a.db.QueryRowContext(r.Context(), `SELECT domain_id FROM aliases WHERE id=?`, id).Scan(&domainID); err != nil { + respondError(w, http.StatusNotFound, "alias not found") + return + } + domain, err := a.domainByID(r.Context(), domainID) + if err != nil { + respondError(w, http.StatusNotFound, "domain not found") + return + } + source := normalizeEmail(req.Source) + if !strings.Contains(source, "@") { + source = normalizeLocalPart(source) + "@" + domain.Name + } + destination := normalizeEmail(req.Destination) + if source == "" || destination == "" || !strings.Contains(destination, "@") { + badRequest(w, errors.New("invalid alias")) + return + } + enabled := true + if req.Enabled != nil { + enabled = *req.Enabled + } + _, err = a.db.ExecContext(r.Context(), `UPDATE aliases SET source=?,destination=?,enabled=?,updated_at=? WHERE id=?`, + source, destination, boolInt(enabled), a.now().UTC().Format(time.RFC3339Nano), id) + if err != nil { + badRequest(w, err) + return + } + respondJSON(w, http.StatusOK, Alias{ID: id, DomainID: domainID, Source: source, Destination: destination, Enabled: enabled, CreatedAt: a.now().UTC()}) +} + +func (a *App) handleDeleteAlias(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + res, err := a.db.ExecContext(r.Context(), `DELETE FROM aliases WHERE id=?`, id) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to delete alias") + return + } + affected, _ := res.RowsAffected() + if affected == 0 { + respondError(w, http.StatusNotFound, "alias not found") + return + } + respondJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + func (a *App) domainByID(ctx context.Context, id string) (*Domain, error) { row := a.db.QueryRowContext(ctx, `SELECT id,name,status,dkim_selector,dkim_public_key,dns_status,dns_checked_at,created_at FROM domains WHERE id=?`, id) var d Domain @@ -273,11 +821,72 @@ func (a *App) domainByID(ctx context.Context, id string) (*Domain, error) { return &d, nil } +func (a *App) adminUserByID(ctx context.Context, id string) (*AdminUser, error) { + row := a.db.QueryRowContext(ctx, `SELECT u.id,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.created_at,COUNT(mb.id),COALESCE(GROUP_CONCAT(mb.address), '') + FROM users u LEFT JOIN mailboxes mb ON mb.user_id=u.id + WHERE u.id=? + GROUP BY u.id,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.created_at`, id) + var item AdminUser + var disabled, twoFactorEnabled int + var created, mailboxCSV string + if err := row.Scan(&item.ID, &item.Email, &item.DisplayName, &item.Role, &disabled, &twoFactorEnabled, &created, &item.MailboxCount, &mailboxCSV); err != nil { + return nil, err + } + item.Disabled = intBool(disabled) + item.TwoFactorEnabled = intBool(twoFactorEnabled) + item.CreatedAt = parseTime(created) + item.Mailboxes = splitCSV(mailboxCSV) + return &item, nil +} + +func (a *App) ensureAdminRemains(ctx context.Context, targetID, nextRole string, nextDisabled bool) error { + rows, err := a.db.QueryContext(ctx, `SELECT id,role,disabled FROM users`) + if err != nil { + return err + } + defer rows.Close() + admins := 0 + for rows.Next() { + var id, role string + var disabled int + if err := rows.Scan(&id, &role, &disabled); err != nil { + return err + } + if id == targetID { + role = nextRole + disabled = boolInt(nextDisabled) + } + if role == "admin" && disabled == 0 { + admins++ + } + } + if admins == 0 { + return errors.New("at least one active admin is required") + } + return nil +} + +func splitCSV(s string) []string { + if strings.TrimSpace(s) == "" { + return nil + } + parts := strings.Split(s, ",") + out := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + out = append(out, part) + } + } + return out +} + func (a *App) mailboxByID(ctx context.Context, id 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=?`, id) + row := a.db.QueryRowContext(ctx, `SELECT mb.id,mb.user_id,u.email,mb.domain_id,mb.local_part,mb.address,mb.display_name,mb.quota_mb,mb.status,mb.created_at + FROM mailboxes mb JOIN users u ON u.id=mb.user_id WHERE mb.id=?`, id) 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 { + if err := row.Scan(&m.ID, &m.UserID, &m.UserEmail, &m.DomainID, &m.LocalPart, &m.Address, &m.DisplayName, &m.QuotaMB, &m.Status, &created); err != nil { return nil, err } m.CreatedAt = parseTime(created) diff --git a/apps/api/internal/app/app.go b/apps/api/internal/app/app.go index f24d1b8..fca13b1 100644 --- a/apps/api/internal/app/app.go +++ b/apps/api/internal/app/app.go @@ -55,6 +55,14 @@ func New(cfg Config, logger *slog.Logger) (*App, error) { db.Close() return nil, err } + if err := a.ensureDefaultMailTemplates(context.Background()); err != nil { + db.Close() + return nil, err + } + if err := a.loadPersistedSystemSettings(context.Background()); err != nil { + db.Close() + return nil, err + } if err := a.seed(context.Background()); err != nil { db.Close() return nil, err @@ -99,6 +107,8 @@ func (a *App) migrate(ctx context.Context) error { display_name TEXT NOT NULL, role TEXT NOT NULL CHECK(role IN ('admin','user')), password_hash TEXT NOT NULL, + two_factor_secret TEXT NOT NULL DEFAULT '', + two_factor_enabled INTEGER NOT NULL DEFAULT 0, disabled INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL @@ -110,6 +120,26 @@ func (a *App) migrate(ctx context.Context) error { expires_at TEXT NOT NULL, created_at TEXT NOT NULL )`, + `CREATE TABLE IF NOT EXISTS login_challenges ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS system_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS mail_templates ( + key TEXT PRIMARY KEY, + name TEXT NOT NULL, + subject TEXT NOT NULL, + body_text TEXT NOT NULL, + body_html TEXT NOT NULL, + updated_at TEXT NOT NULL + )`, `CREATE TABLE IF NOT EXISTS domains ( id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, @@ -155,8 +185,9 @@ func (a *App) migrate(ctx context.Context) error { )`, `CREATE TABLE IF NOT EXISTS messages ( id TEXT PRIMARY KEY, - mailbox_id TEXT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE, - folder_id TEXT NOT NULL REFERENCES folders(id) ON DELETE CASCADE, + mailbox_id TEXT REFERENCES mailboxes(id) ON DELETE CASCADE, + folder_id TEXT REFERENCES folders(id) ON DELETE CASCADE, + recipient_addr TEXT NOT NULL DEFAULT '', message_uid TEXT NOT NULL, message_id TEXT NOT NULL, subject TEXT NOT NULL, @@ -179,7 +210,8 @@ func (a *App) migrate(ctx context.Context) error { )`, `CREATE INDEX IF NOT EXISTS idx_messages_mailbox_folder_received ON messages(mailbox_id, folder_id, received_at DESC)`, `CREATE INDEX IF NOT EXISTS idx_messages_search ON messages(mailbox_id, subject, from_addr, snippet)`, - `CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_mailbox_raw_path ON messages(mailbox_id, raw_path) WHERE raw_path <> ''`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_mailbox_raw_path ON messages(mailbox_id, raw_path) WHERE raw_path <> '' AND mailbox_id IS NOT NULL`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_unregistered_raw_path ON messages(raw_path) WHERE raw_path <> '' AND mailbox_id IS NULL`, `CREATE TABLE IF NOT EXISTS attachments ( id TEXT PRIMARY KEY, message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE, @@ -230,9 +262,160 @@ func (a *App) migrate(ctx context.Context) error { return err } } + if err := a.migrateMessagesForUnregistered(ctx); err != nil { + return err + } + if err := a.migrateUsersForTwoFactor(ctx); err != nil { + return err + } return nil } +func (a *App) migrateUsersForTwoFactor(ctx context.Context) error { + rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(users)`) + if err != nil { + return err + } + defer rows.Close() + columns := map[string]bool{} + for rows.Next() { + var cid int + var name, typ string + var notnull int + var dflt any + var pk int + if err := rows.Scan(&cid, &name, &typ, ¬null, &dflt, &pk); err != nil { + return err + } + columns[name] = true + } + if err := rows.Err(); err != nil { + return err + } + if !columns["two_factor_secret"] { + if _, err := a.db.ExecContext(ctx, `ALTER TABLE users ADD COLUMN two_factor_secret TEXT NOT NULL DEFAULT ''`); err != nil { + return err + } + } + if !columns["two_factor_enabled"] { + if _, err := a.db.ExecContext(ctx, `ALTER TABLE users ADD COLUMN two_factor_enabled INTEGER NOT NULL DEFAULT 0`); err != nil { + return err + } + } + return nil +} + +func (a *App) migrateMessagesForUnregistered(ctx context.Context) error { + rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(messages)`) + if err != nil { + return err + } + defer rows.Close() + hasRecipientAddr := false + mailboxNullable := false + folderNullable := false + for rows.Next() { + var cid int + var name, typ string + var notnull int + var dflt any + var pk int + if err := rows.Scan(&cid, &name, &typ, ¬null, &dflt, &pk); err != nil { + return err + } + switch name { + case "recipient_addr": + hasRecipientAddr = true + case "mailbox_id": + mailboxNullable = notnull == 0 + case "folder_id": + folderNullable = notnull == 0 + } + } + if err := rows.Err(); err != nil { + return err + } + if hasRecipientAddr && mailboxNullable && folderNullable { + return nil + } + + if _, err := a.db.ExecContext(ctx, `PRAGMA foreign_keys = OFF`); err != nil { + return err + } + defer a.db.ExecContext(context.Background(), `PRAGMA foreign_keys = ON`) + + tx, err := a.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + for _, stmt := range []string{ + `DROP INDEX IF EXISTS idx_messages_mailbox_folder_received`, + `DROP INDEX IF EXISTS idx_messages_search`, + `DROP INDEX IF EXISTS idx_messages_mailbox_raw_path`, + `DROP INDEX IF EXISTS idx_messages_unregistered_raw_path`, + } { + if _, err := tx.ExecContext(ctx, stmt); err != nil { + return err + } + } + if _, err := tx.ExecContext(ctx, `CREATE TABLE messages_new ( + id TEXT PRIMARY KEY, + mailbox_id TEXT REFERENCES mailboxes(id) ON DELETE CASCADE, + folder_id TEXT REFERENCES folders(id) ON DELETE CASCADE, + recipient_addr TEXT NOT NULL DEFAULT '', + message_uid TEXT NOT NULL, + message_id TEXT NOT NULL, + subject TEXT NOT NULL, + from_addr TEXT NOT NULL, + to_addrs TEXT NOT NULL, + cc_addrs TEXT NOT NULL DEFAULT '[]', + bcc_addrs TEXT NOT NULL DEFAULT '[]', + sent_at TEXT NOT NULL, + received_at TEXT NOT NULL, + snippet TEXT NOT NULL, + body_text TEXT NOT NULL, + body_html TEXT NOT NULL, + is_read INTEGER NOT NULL DEFAULT 0, + is_starred INTEGER NOT NULL DEFAULT 0, + has_attachments INTEGER NOT NULL DEFAULT 0, + size_bytes INTEGER NOT NULL DEFAULT 0, + raw_path TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )`); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `INSERT INTO messages_new(id,mailbox_id,folder_id,recipient_addr,message_uid,message_id,subject,from_addr,to_addrs,cc_addrs,bcc_addrs,sent_at,received_at,snippet,body_text,body_html,is_read,is_starred,has_attachments,size_bytes,raw_path,created_at,updated_at) + SELECT id,mailbox_id,folder_id,'',message_uid,message_id,subject,from_addr,to_addrs,cc_addrs,bcc_addrs,sent_at,received_at,snippet,body_text,body_html,is_read,is_starred,has_attachments,size_bytes,raw_path,created_at,updated_at FROM messages`); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `DROP TABLE messages`); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `ALTER TABLE messages_new RENAME TO messages`); err != nil { + return err + } + if err := tx.Commit(); err != nil { + return err + } + for _, stmt := range messageIndexes() { + if _, err := a.db.ExecContext(ctx, stmt); err != nil { + return err + } + } + return nil +} + +func messageIndexes() []string { + return []string{ + `CREATE INDEX IF NOT EXISTS idx_messages_mailbox_folder_received ON messages(mailbox_id, folder_id, received_at DESC)`, + `CREATE INDEX IF NOT EXISTS idx_messages_search ON messages(mailbox_id, subject, from_addr, snippet)`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_mailbox_raw_path ON messages(mailbox_id, raw_path) WHERE raw_path <> '' AND mailbox_id IS NOT NULL`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_unregistered_raw_path ON messages(raw_path) WHERE raw_path <> '' AND mailbox_id IS NULL`, + } +} + func (a *App) seed(ctx context.Context) error { var count int if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&count); err != nil { @@ -378,19 +561,32 @@ func (a *App) seedWelcomeMessage(ctx context.Context, mailboxID string) error { return err } now := a.now().UTC() + subject := "欢迎使用 LanQin Email" + bodyText := "你的自建邮箱 Webmail 已经初始化完成。请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。" + bodyHTML := "

你的自建邮箱 Webmail 已经初始化完成。

请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。

" + if tpl, err := a.mailTemplate(ctx, "welcome"); err == nil { + rendered := renderMailTemplate(tpl, templateRenderData{ + To: a.cfg.AdminEmail, + From: "system@lanqin.local", + PublicHostname: a.cfg.PublicHostname, + PublicBaseURL: a.cfg.PublicBaseURL, + Time: now, + }) + subject, bodyText, bodyHTML = rendered.Subject, rendered.Text, rendered.HTML + } msg := storedMessage{ MailboxID: mailboxID, FolderID: folderID, MessageUID: newID("uid"), MessageID: fmt.Sprintf("<%s@lanqin.local>", newID("msg")), - Subject: "欢迎使用 LanQin Email", + Subject: subject, From: "system@lanqin.local", To: []string{a.cfg.AdminEmail}, SentAt: now, ReceivedAt: now, - Snippet: "你的自建邮箱 Webmail 已经初始化完成。", - BodyText: "你的自建邮箱 Webmail 已经初始化完成。请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。", - BodyHTML: "

你的自建邮箱 Webmail 已经初始化完成。

请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。

", + Snippet: snippetFrom(bodyText, bodyHTML), + BodyText: bodyText, + BodyHTML: bodyHTML, IsRead: false, } _, err = a.insertMessage(ctx, msg, nil) diff --git a/apps/api/internal/app/app_test.go b/apps/api/internal/app/app_test.go index 1f3fecf..8401daa 100644 --- a/apps/api/internal/app/app_test.go +++ b/apps/api/internal/app/app_test.go @@ -1,18 +1,21 @@ package app import ( + "bufio" "bytes" "context" "encoding/base64" "encoding/json" "io" "log/slog" + "net" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" + "time" ) func newTestApp(t *testing.T) *App { @@ -38,6 +41,70 @@ func newTestApp(t *testing.T) *App { return a } +func startFakeSMTP(t *testing.T) (string, string, <-chan string) { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + received := make(chan string, 1) + t.Cleanup(func() { _ = ln.Close() }) + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go handleFakeSMTPConn(conn, received) + } + }() + host, port, err := net.SplitHostPort(ln.Addr().String()) + if err != nil { + t.Fatal(err) + } + return host, port, received +} + +func handleFakeSMTPConn(conn net.Conn, received chan<- string) { + defer conn.Close() + reader := bufio.NewReader(conn) + _, _ = io.WriteString(conn, "220 lanqin.test ESMTP\r\n") + for { + line, err := reader.ReadString('\n') + if err != nil { + return + } + cmd := strings.ToUpper(strings.TrimSpace(line)) + switch { + case strings.HasPrefix(cmd, "EHLO") || strings.HasPrefix(cmd, "HELO"): + _, _ = io.WriteString(conn, "250-lanqin.test\r\n250 OK\r\n") + case strings.HasPrefix(cmd, "DATA"): + _, _ = io.WriteString(conn, "354 End data with .\r\n") + var data strings.Builder + for { + line, err := reader.ReadString('\n') + if err != nil { + return + } + if strings.TrimRight(line, "\r\n") == "." { + break + } + data.WriteString(line) + } + select { + case received <- data.String(): + default: + } + _, _ = io.WriteString(conn, "250 OK\r\n") + case strings.HasPrefix(cmd, "QUIT"): + _, _ = io.WriteString(conn, "221 Bye\r\n") + return + default: + _, _ = io.WriteString(conn, "250 OK\r\n") + } + } +} + type testClient struct { t *testing.T server *httptest.Server @@ -227,6 +294,120 @@ func TestUserCanSelectMultipleMailboxes(t *testing.T) { } } +func TestCatchAllStoresUnregisteredMailForAdminOnly(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("login code=%d body=%v", code, login) + } + + payload := map[string]any{ + "to": []string{"ghost@lanqin.local"}, + "subject": "should be rejected by default", + "text": "default disabled", + } + var sent MailMessage + if code := admin.do("POST", "/api/mail/send", payload, &sent); code != http.StatusCreated { + t.Fatalf("send disabled catch-all code=%d", code) + } + var list struct { + Items []MailMessage `json:"items"` + } + if code := admin.do("GET", "/api/admin/messages?mailboxId=unregistered&q=should%20be%20rejected", nil, &list); code != http.StatusOK || len(list.Items) != 0 { + t.Fatalf("disabled catch-all should not store unregistered mail: code=%d items=%+v", code, list.Items) + } + + var settings SystemSettings + if code := admin.do("GET", "/api/admin/settings", nil, &settings); code != http.StatusOK { + t.Fatalf("get settings code=%d", code) + } + update := map[string]any{ + "publicHostname": settings.PublicHostname, + "publicBaseUrl": settings.PublicBaseURL, + "smtpHost": settings.SMTPHost, + "smtpPort": settings.SMTPPort, + "smtpUsername": settings.SMTPUsername, + "smtpPassword": "", + "smtpRequireTls": settings.SMTPRequireTLS, + "maildirRoot": settings.MaildirRoot, + "maildirScanSeconds": settings.MaildirScanSeconds, + "sessionTtlHours": settings.SessionTTLHours, + "allowInsecureHttp": settings.AllowInsecureHTTP, + "openRegistration": settings.OpenRegistration, + "twoFactorEnabled": settings.TwoFactorEnabled, + "turnstileEnabled": settings.TurnstileEnabled, + "turnstileSiteKey": settings.TurnstileSiteKey, + "turnstileSecretKey": "", + "catchAllEnabled": true, + "mailAutoRefresh": settings.MailAutoRefresh, + "mailRefreshSeconds": settings.MailRefreshSeconds, + } + if code := admin.do("POST", "/api/admin/settings", update, &settings); code != http.StatusOK || !settings.CatchAllEnabled { + t.Fatalf("enable catch-all code=%d settings=%+v", code, settings) + } + + payload = map[string]any{ + "to": []string{"ghost@lanqin.local"}, + "subject": "stored for admin only", + "text": "unregistered mailbox content", + } + if code := admin.do("POST", "/api/mail/send", payload, &sent); code != http.StatusCreated { + t.Fatalf("send enabled catch-all code=%d", code) + } + if code := admin.do("GET", "/api/admin/messages?mailboxId=unregistered&q=stored%20for%20admin", nil, &list); code != http.StatusOK || len(list.Items) != 1 { + t.Fatalf("enabled catch-all admin list code=%d items=%+v", code, list.Items) + } + if got := list.Items[0].RecipientAddr; got != "ghost@lanqin.local" { + t.Fatalf("recipientAddress=%q", got) + } +} + +func TestAdminSMTPTestEndpoint(t *testing.T) { + a := newTestApp(t) + host, port, received := startFakeSMTP(t) + a.cfg.SMTPHost = host + a.cfg.SMTPPort = port + 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("login code=%d body=%v", code, login) + } + + var out map[string]any + var templates struct { + Items []MailTemplate `json:"items"` + } + if code := admin.do("GET", "/api/admin/mail-templates", nil, &templates); code != http.StatusOK || len(templates.Items) == 0 { + t.Fatalf("templates code=%d items=%d", code, len(templates.Items)) + } + var updated MailTemplate + if code := admin.do("POST", "/api/admin/mail-templates/smtp_test", map[string]string{ + "subject": "自定义 SMTP 测试", + "bodyText": "hello {{to}} from {{from}}", + "bodyHtml": "

hello {{to}} from {{from}}

", + }, &updated); code != http.StatusOK || updated.Subject != "自定义 SMTP 测试" { + t.Fatalf("update template code=%d template=%+v", code, updated) + } + if code := admin.do("POST", "/api/admin/settings/test-smtp", map[string]string{"to": "test@example.com"}, &out); code != http.StatusOK { + t.Fatalf("smtp test code=%d body=%v", code, out) + } + select { + case body := <-received: + if !strings.Contains(body, "From: admin@lanqin.local") || !strings.Contains(body, "To: test@example.com") || !strings.Contains(body, "=?utf-8?q?=E8=87=AA=E5=AE=9A=E4=B9=89_SMTP_=E6=B5=8B=E8=AF=95?=") { + t.Fatalf("unexpected smtp body: %s", body) + } + case <-time.After(2 * time.Second): + t.Fatal("smtp test message not received") + } +} + func TestProfileAndPasswordUpdate(t *testing.T) { a := newTestApp(t) ts := httptest.NewServer(a.Router()) @@ -262,6 +443,64 @@ func TestProfileAndPasswordUpdate(t *testing.T) { } } +func TestUserTwoFactorSetupAndLogin(t *testing.T) { + a := newTestApp(t) + a.cfg.TwoFactorEnabled = true + ts := httptest.NewServer(a.Router()) + defer ts.Close() + client := &testClient{t: t, server: ts} + + var login map[string]any + if code := client.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK { + t.Fatalf("login code=%d body=%v", code, login) + } + + var setup struct { + Secret string `json:"secret"` + OtpauthURL string `json:"otpauthUrl"` + } + if code := client.do("POST", "/api/me/2fa/setup", map[string]string{}, &setup); code != http.StatusOK || setup.Secret == "" || !strings.HasPrefix(setup.OtpauthURL, "otpauth://totp/") { + t.Fatalf("setup code=%d setup=%+v", code, setup) + } + + var out map[string]any + if code := client.do("POST", "/api/me/2fa/enable", map[string]string{"code": "000000"}, &out); code != http.StatusUnauthorized { + t.Fatalf("wrong enable code=%d body=%v", code, out) + } + code, err := generateTOTP(setup.Secret, a.now().UTC()) + if err != nil { + t.Fatal(err) + } + var enabled struct { + User User `json:"user"` + } + if status := client.do("POST", "/api/me/2fa/enable", map[string]string{"code": code}, &enabled); status != http.StatusOK || !enabled.User.TwoFactorEnabled { + t.Fatalf("enable status=%d user=%+v", status, enabled.User) + } + + fresh := &testClient{t: t, server: ts} + var challenge struct { + TwoFactorRequired bool `json:"twoFactorRequired"` + ChallengeToken string `json:"challengeToken"` + } + if status := fresh.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &challenge); status != http.StatusOK || !challenge.TwoFactorRequired || challenge.ChallengeToken == "" || fresh.cookie != nil { + t.Fatalf("challenge status=%d challenge=%+v cookie=%v", status, challenge, fresh.cookie) + } + if status := fresh.do("POST", "/api/auth/login", map[string]string{"challengeToken": challenge.ChallengeToken, "twoFactorCode": "000000"}, &out); status != http.StatusUnauthorized { + t.Fatalf("wrong challenge status=%d body=%v", status, out) + } + code, err = generateTOTP(setup.Secret, a.now().UTC()) + if err != nil { + t.Fatal(err) + } + if status := fresh.do("POST", "/api/auth/login", map[string]string{"challengeToken": challenge.ChallengeToken, "twoFactorCode": code}, &login); status != http.StatusOK || fresh.cookie == nil { + t.Fatalf("2fa login status=%d body=%v cookie=%v", status, login, fresh.cookie) + } + if status := fresh.do("POST", "/api/me/2fa/disable", map[string]string{"code": code}, &enabled); status != http.StatusOK || enabled.User.TwoFactorEnabled { + t.Fatalf("disable status=%d user=%+v", status, enabled.User) + } +} + func TestDNSRecords(t *testing.T) { a := newTestApp(t) d, err := a.domainByID(context.Background(), mustDefaultDomainID(t, a)) diff --git a/apps/api/internal/app/config.go b/apps/api/internal/app/config.go index 14c737b..c6a7ad7 100644 --- a/apps/api/internal/app/config.go +++ b/apps/api/internal/app/config.go @@ -25,6 +25,14 @@ type Config struct { MaildirRoot string MaildirScanSeconds int AllowInsecureHTTP bool + OpenRegistration bool + TwoFactorEnabled bool + TurnstileEnabled bool + TurnstileSiteKey string + TurnstileSecretKey string + CatchAllEnabled bool + MailAutoRefresh bool + MailRefreshSeconds int } func LoadConfig() Config { @@ -47,6 +55,14 @@ func LoadConfig() Config { MaildirRoot: getenv("LANQIN_MAILDIR_ROOT", ""), MaildirScanSeconds: getenvInt("LANQIN_MAILDIR_SCAN_SECONDS", 30), AllowInsecureHTTP: getenvBool("LANQIN_ALLOW_INSECURE_HTTP", true), + OpenRegistration: getenvBool("LANQIN_OPEN_REGISTRATION", false), + TwoFactorEnabled: getenvBool("LANQIN_TWO_FACTOR_ENABLED", false), + TurnstileEnabled: getenvBool("LANQIN_TURNSTILE_ENABLED", false), + TurnstileSiteKey: getenv("LANQIN_TURNSTILE_SITE_KEY", ""), + TurnstileSecretKey: getenv("LANQIN_TURNSTILE_SECRET_KEY", ""), + CatchAllEnabled: getenvBool("LANQIN_CATCH_ALL_ENABLED", false), + MailAutoRefresh: getenvBool("LANQIN_MAIL_AUTO_REFRESH", true), + MailRefreshSeconds: getenvInt("LANQIN_MAIL_REFRESH_SECONDS", 30), } } diff --git a/apps/api/internal/app/mail_handlers.go b/apps/api/internal/app/mail_handlers.go index 07bad72..a2dda14 100644 --- a/apps/api/internal/app/mail_handlers.go +++ b/apps/api/internal/app/mail_handlers.go @@ -23,23 +23,24 @@ type AttachmentInput struct { } type storedMessage struct { - MailboxID string - FolderID string - MessageUID string - MessageID string - Subject string - From string - To []string - CC []string - BCC []string - SentAt time.Time - ReceivedAt time.Time - Snippet string - BodyText string - BodyHTML string - IsRead bool - IsStarred bool - RawPath string + MailboxID string + FolderID string + RecipientAddr string + MessageUID string + MessageID string + Subject string + From string + To []string + CC []string + BCC []string + SentAt time.Time + ReceivedAt time.Time + Snippet string + BodyText string + BodyHTML string + IsRead bool + IsStarred bool + RawPath string } func (a *App) handleMyMailboxes(w http.ResponseWriter, r *http.Request) { @@ -225,12 +226,36 @@ func (a *App) handleMailSend(w http.ResponseWriter, r *http.Request) { return } - // Development/local-domain delivery: if a recipient exists as a local mailbox, write an Inbox copy. + // Development/local-domain delivery: known local recipients go to their Inbox. + // When catch-all is enabled, unknown local recipients are stored as unregistered + // messages visible only in the admin "全部邮件" view. localRecipients := append(req.To, req.CC...) localRecipients = append(localRecipients, req.BCC...) for _, rcpt := range localRecipients { rcptMailbox, err := a.mailboxByAddress(r.Context(), rcpt) if err != nil { + if !a.cfg.CatchAllEnabled || !a.isLocalDomainAddress(r.Context(), rcpt) { + continue + } + copyMsg := base + copyMsg.MailboxID = "" + copyMsg.FolderID = "" + copyMsg.RecipientAddr = normalizeEmail(rcpt) + copyMsg.MessageUID = newID("uid") + copyMsg.IsRead = false + _, _ = a.insertMessage(r.Context(), copyMsg, req.Attachments) + continue + } + if rcptMailbox.Status != "active" { + if a.cfg.CatchAllEnabled && a.isLocalDomainAddress(r.Context(), rcpt) { + copyMsg := base + copyMsg.MailboxID = "" + copyMsg.FolderID = "" + copyMsg.RecipientAddr = normalizeEmail(rcpt) + copyMsg.MessageUID = newID("uid") + copyMsg.IsRead = false + _, _ = a.insertMessage(r.Context(), copyMsg, req.Attachments) + } continue } inboxID, err := a.ensureFolder(r.Context(), rcptMailbox.ID, "Inbox") @@ -251,6 +276,16 @@ func (a *App) handleMailSend(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusCreated, msg) } +func (a *App) isLocalDomainAddress(ctx context.Context, address string) bool { + parts := strings.Split(normalizeEmail(address), "@") + if len(parts) != 2 || parts[1] == "" { + return false + } + var count int + _ = a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM domains WHERE name=? AND status='active'`, parts[1]).Scan(&count) + return count > 0 +} + func (a *App) handleMarkRead(w http.ResponseWriter, r *http.Request) { msg, err := a.loadMessageForRequest(r, chi.URLParam(r, "id"), false) if err != nil { @@ -369,6 +404,27 @@ func (a *App) handleAttachment(w http.ResponseWriter, r *http.Request) { _, _ = io.Copy(w, f) } +func (a *App) handleAdminAttachment(w http.ResponseWriter, r *http.Request) { + attID := chi.URLParam(r, "id") + row := a.db.QueryRowContext(r.Context(), `SELECT filename,content_type,size_bytes,storage_path FROM attachments WHERE id=?`, attID) + var filename, contentType, path string + var size int64 + if err := row.Scan(&filename, &contentType, &size, &path); err != nil { + respondError(w, http.StatusNotFound, "attachment not found") + return + } + f, err := os.Open(path) + if err != nil { + respondError(w, http.StatusNotFound, "attachment file missing") + return + } + defer f.Close() + w.Header().Set("Content-Type", contentType) + w.Header().Set("Content-Disposition", `attachment; filename="`+strings.ReplaceAll(filename, `"`, "")+`"`) + w.Header().Set("Content-Length", strconv.FormatInt(size, 10)) + _, _ = io.Copy(w, f) +} + func (a *App) handleEvents(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") @@ -440,8 +496,8 @@ func (a *App) loadMessageForRequest(r *http.Request, id string, includeBody bool } func (a *App) messageByID(ctx context.Context, id string, includeBody bool) (*MailMessage, error) { - row := a.db.QueryRowContext(ctx, `SELECT m.id,m.mailbox_id,m.folder_id,f.name,m.message_uid,m.message_id,m.subject,m.from_addr,m.to_addrs,m.cc_addrs,m.bcc_addrs,m.sent_at,m.received_at,m.snippet,m.body_text,m.body_html,m.is_read,m.is_starred,m.has_attachments,m.size_bytes - FROM messages m JOIN folders f ON f.id=m.folder_id WHERE m.id=?`, id) + row := a.db.QueryRowContext(ctx, `SELECT m.id,COALESCE(m.mailbox_id,''),COALESCE(m.recipient_addr,''),COALESCE(m.folder_id,''),COALESCE(f.name,'Unregistered'),m.message_uid,m.message_id,m.subject,m.from_addr,m.to_addrs,m.cc_addrs,m.bcc_addrs,m.sent_at,m.received_at,m.snippet,m.body_text,m.body_html,m.is_read,m.is_starred,m.has_attachments,m.size_bytes + FROM messages m LEFT JOIN folders f ON f.id=m.folder_id WHERE m.id=?`, id) msg, err := scanMessageFull(row, includeBody) if err != nil { return nil, err @@ -466,8 +522,16 @@ func (a *App) insertMessage(ctx context.Context, msg storedMessage, attachments size += int64(len(decoded)) } } - _, err := a.db.ExecContext(ctx, `INSERT INTO messages(id,mailbox_id,folder_id,message_uid,message_id,subject,from_addr,to_addrs,cc_addrs,bcc_addrs,sent_at,received_at,snippet,body_text,body_html,is_read,is_starred,has_attachments,size_bytes,raw_path,created_at,updated_at) - VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, id, msg.MailboxID, msg.FolderID, msg.MessageUID, msg.MessageID, msg.Subject, msg.From, jsonEncode(msg.To), jsonEncode(msg.CC), jsonEncode(msg.BCC), msg.SentAt.Format(time.RFC3339Nano), msg.ReceivedAt.Format(time.RFC3339Nano), msg.Snippet, msg.BodyText, msg.BodyHTML, boolInt(msg.IsRead), boolInt(msg.IsStarred), boolInt(hasAttachments), size, msg.RawPath, now, now) + var mailboxID, folderID any + if strings.TrimSpace(msg.MailboxID) != "" { + mailboxID = msg.MailboxID + } + if strings.TrimSpace(msg.FolderID) != "" { + folderID = msg.FolderID + } + recipientAddr := normalizeEmail(msg.RecipientAddr) + _, err := a.db.ExecContext(ctx, `INSERT INTO messages(id,mailbox_id,folder_id,recipient_addr,message_uid,message_id,subject,from_addr,to_addrs,cc_addrs,bcc_addrs,sent_at,received_at,snippet,body_text,body_html,is_read,is_starred,has_attachments,size_bytes,raw_path,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, id, mailboxID, folderID, recipientAddr, msg.MessageUID, msg.MessageID, msg.Subject, msg.From, jsonEncode(msg.To), jsonEncode(msg.CC), jsonEncode(msg.BCC), msg.SentAt.Format(time.RFC3339Nano), msg.ReceivedAt.Format(time.RFC3339Nano), msg.Snippet, msg.BodyText, msg.BodyHTML, boolInt(msg.IsRead), boolInt(msg.IsStarred), boolInt(hasAttachments), size, msg.RawPath, now, now) if err != nil { return "", err } @@ -541,6 +605,20 @@ func (a *App) deleteMessageFiles(ctx context.Context, messageID string) { type messageSummaryScanner interface{ Scan(dest ...any) error } +func scanAdminMessageSummary(row messageSummaryScanner) (MailMessage, error) { + var msg MailMessage + var toJSON, ccJSON, bccJSON, sent, received string + var read, starred, hasAtt int + err := row.Scan(&msg.ID, &msg.MailboxID, &msg.MailboxAddress, &msg.OwnerEmail, &msg.RecipientAddr, &msg.FolderID, &msg.Folder, &msg.MessageUID, &msg.MessageID, &msg.Subject, &msg.From, &toJSON, &ccJSON, &bccJSON, &sent, &received, &msg.Snippet, &read, &starred, &hasAtt, &msg.SizeBytes) + if err != nil { + return msg, err + } + msg.To, msg.CC, msg.BCC = jsonDecodeSlice(toJSON), jsonDecodeSlice(ccJSON), jsonDecodeSlice(bccJSON) + msg.SentAt, msg.ReceivedAt = parseTime(sent), parseTime(received) + msg.IsRead, msg.IsStarred, msg.HasAttachments = intBool(read), intBool(starred), intBool(hasAtt) + return msg, nil +} + func scanMessageSummary(row messageSummaryScanner, folder string) (MailMessage, error) { var msg MailMessage var toJSON, ccJSON, bccJSON, sent, received string @@ -561,7 +639,7 @@ func scanMessageFull(row messageSummaryScanner, includeBody bool) (MailMessage, var toJSON, ccJSON, bccJSON, sent, received string var read, starred, hasAtt int var bodyText, bodyHTML string - err := row.Scan(&msg.ID, &msg.MailboxID, &msg.FolderID, &msg.Folder, &msg.MessageUID, &msg.MessageID, &msg.Subject, &msg.From, &toJSON, &ccJSON, &bccJSON, &sent, &received, &msg.Snippet, &bodyText, &bodyHTML, &read, &starred, &hasAtt, &msg.SizeBytes) + err := row.Scan(&msg.ID, &msg.MailboxID, &msg.RecipientAddr, &msg.FolderID, &msg.Folder, &msg.MessageUID, &msg.MessageID, &msg.Subject, &msg.From, &toJSON, &ccJSON, &bccJSON, &sent, &received, &msg.Snippet, &bodyText, &bodyHTML, &read, &starred, &hasAtt, &msg.SizeBytes) if err != nil { return msg, err } diff --git a/apps/api/internal/app/maildir_sync.go b/apps/api/internal/app/maildir_sync.go index 2e225e5..81a0b92 100644 --- a/apps/api/internal/app/maildir_sync.go +++ b/apps/api/internal/app/maildir_sync.go @@ -20,10 +20,12 @@ import ( ) type maildirMailbox struct { - ID string - Address string - LocalPart string - Domain string + ID string + Address string + LocalPart string + Domain string + Unregistered bool + RecipientDomain string } type maildirFolder struct { @@ -80,6 +82,14 @@ func (a *App) syncMaildirOnce(ctx context.Context) (int, error) { } imported := 0 for _, mb := range mailboxes { + if mb.Unregistered { + count, err := a.syncUnregisteredMaildir(ctx, mb) + if err != nil { + return imported, err + } + imported += count + continue + } folders, err := a.maildirFolders(ctx, mb.ID) if err != nil { return imported, err @@ -135,7 +145,109 @@ func (a *App) maildirMailboxes(ctx context.Context) ([]maildirMailbox, error) { } out = append(out, mb) } - return out, rows.Err() + if err := rows.Err(); err != nil { + return nil, err + } + if a.cfg.CatchAllEnabled { + domainRows, err := a.db.QueryContext(ctx, `SELECT name FROM domains WHERE status='active' ORDER BY name`) + if err != nil { + return nil, err + } + defer domainRows.Close() + for domainRows.Next() { + var domain string + if err := domainRows.Scan(&domain); err != nil { + return nil, err + } + out = append(out, maildirMailbox{ + Address: "__unregistered__@" + domain, + LocalPart: "__unregistered__", + Domain: domain, + Unregistered: true, + RecipientDomain: domain, + }) + } + if err := domainRows.Err(); err != nil { + return nil, err + } + } + return out, nil +} + +func (a *App) syncUnregisteredMaildir(ctx context.Context, mb maildirMailbox) (int, error) { + base := filepath.Join(strings.TrimSpace(a.cfg.MaildirRoot), mb.Domain, mb.LocalPart, "Maildir") + imported := 0 + for _, sub := range []string{"new", "cur"} { + select { + case <-ctx.Done(): + return imported, ctx.Err() + default: + } + dir := filepath.Join(base, sub) + entries, err := os.ReadDir(dir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + continue + } + return imported, err + } + for _, entry := range entries { + if entry.IsDir() || strings.HasPrefix(entry.Name(), ".") { + continue + } + path := filepath.Join(dir, entry.Name()) + ok, err := a.syncUnregisteredMaildirFile(ctx, mb, path) + if err != nil { + a.log.Warn("unregistered maildir file import failed", "path", path, "error", err) + continue + } + if ok { + imported++ + } + } + } + return imported, nil +} + +func (a *App) syncUnregisteredMaildirFile(ctx context.Context, mb maildirMailbox, path string) (bool, error) { + raw, err := os.ReadFile(path) + if err != nil { + return false, err + } + msg, attachments, err := a.parseMaildirMessage(raw, mb.Address) + if err != nil { + return false, err + } + recipient := unregisteredRecipientFromMessage(msg, mb.RecipientDomain) + if recipient == "" { + recipient = mb.Address + } + msg.MailboxID = "" + msg.FolderID = "" + msg.RecipientAddr = recipient + msg.RawPath = path + if msg.MessageUID == "" { + msg.MessageUID = newID("uid") + } + if msg.MessageID == "" { + msg.MessageID = fmt.Sprintf("<%s@lanqin.local>", newID("msg")) + } + if msg.ReceivedAt.IsZero() { + msg.ReceivedAt = a.now().UTC() + } + if msg.SentAt.IsZero() { + msg.SentAt = msg.ReceivedAt + } + if msg.Snippet == "" { + msg.Snippet = snippetFrom(msg.BodyText, msg.BodyHTML) + } + if exists, err := a.unregisteredMaildirMessageExists(ctx, path, msg.MessageID, msg.RecipientAddr); err != nil { + return false, err + } else if exists { + return false, nil + } + _, err = a.insertMessage(ctx, msg, attachments) + return err == nil, err } func (a *App) maildirFolders(ctx context.Context, mailboxID string) ([]maildirFolder, error) { @@ -212,6 +324,26 @@ func (a *App) maildirMessageExists(ctx context.Context, mailboxID, folderID, raw return count > 0, nil } +func (a *App) unregisteredMaildirMessageExists(ctx context.Context, rawPath, messageID, recipient string) (bool, error) { + var count int + err := a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM messages WHERE mailbox_id IS NULL AND (raw_path=? OR (recipient_addr=? AND message_id=? AND message_id <> ''))`, rawPath, recipient, messageID).Scan(&count) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return false, err + } + return count > 0, nil +} + +func unregisteredRecipientFromMessage(msg storedMessage, domain string) string { + domain = normalizeDomain(domain) + for _, address := range append(append([]string{}, msg.To...), msg.CC...) { + address = normalizeEmail(address) + if strings.HasSuffix(address, "@"+domain) { + return address + } + } + return "" +} + func (a *App) parseMaildirMessage(raw []byte, fallbackTo string) (storedMessage, []AttachmentInput, error) { m, err := netmail.ReadMessage(bytes.NewReader(raw)) if err != nil { diff --git a/apps/api/internal/app/mime.go b/apps/api/internal/app/mime.go index b1403a3..0dafaad 100644 --- a/apps/api/internal/app/mime.go +++ b/apps/api/internal/app/mime.go @@ -122,20 +122,24 @@ func writeBase64(w io.Writer, data []byte) { } func (a *App) sendSMTP(from string, recipients []string, mimeBytes []byte) error { - addr := net.JoinHostPort(a.cfg.SMTPHost, a.cfg.SMTPPort) + return sendSMTPWithConfig(a.cfg, from, recipients, mimeBytes) +} + +func sendSMTPWithConfig(cfg Config, from string, recipients []string, mimeBytes []byte) error { + addr := net.JoinHostPort(cfg.SMTPHost, cfg.SMTPPort) var auth smtp.Auth - if a.cfg.SMTPUsername != "" { - auth = smtp.PlainAuth("", a.cfg.SMTPUsername, a.cfg.SMTPPassword, a.cfg.SMTPHost) + if cfg.SMTPUsername != "" { + auth = smtp.PlainAuth("", cfg.SMTPUsername, cfg.SMTPPassword, cfg.SMTPHost) } - if !a.cfg.SMTPRequireTLS { + if !cfg.SMTPRequireTLS { return smtp.SendMail(addr, auth, from, recipients, mimeBytes) } - conn, err := tls.Dial("tcp", addr, &tls.Config{ServerName: a.cfg.SMTPHost, MinVersion: tls.VersionTLS12}) + conn, err := tls.Dial("tcp", addr, &tls.Config{ServerName: cfg.SMTPHost, MinVersion: tls.VersionTLS12}) if err != nil { return err } defer conn.Close() - client, err := smtp.NewClient(conn, a.cfg.SMTPHost) + client, err := smtp.NewClient(conn, cfg.SMTPHost) if err != nil { return err } diff --git a/apps/api/internal/app/router_auth.go b/apps/api/internal/app/router_auth.go index e170afe..8f645a0 100644 --- a/apps/api/internal/app/router_auth.go +++ b/apps/api/internal/app/router_auth.go @@ -30,11 +30,15 @@ func (a *App) Router() http.Handler { }) r.Route("/api", func(r chi.Router) { + r.Get("/public/settings", a.handlePublicSettings) r.Post("/auth/login", a.handleLogin) r.Post("/auth/logout", a.handleLogout) r.With(a.requireAuth).Get("/me", a.handleMe) r.With(a.requireAuth).Post("/me/profile", a.handleUpdateProfile) r.With(a.requireAuth).Post("/me/password", a.handleChangePassword) + r.With(a.requireAuth).Post("/me/2fa/setup", a.handleTwoFactorSetup) + r.With(a.requireAuth).Post("/me/2fa/enable", a.handleTwoFactorEnable) + r.With(a.requireAuth).Post("/me/2fa/disable", a.handleTwoFactorDisable) 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) @@ -65,12 +69,33 @@ func (a *App) Router() http.Handler { r.Group(func(r chi.Router) { r.Use(a.requireAuth) r.Use(a.requireAdmin) + r.Get("/admin/overview", a.handleAdminOverview) + r.Get("/admin/users", a.handleListUsers) + r.Post("/admin/users", a.handleCreateUser) + r.Post("/admin/users/{id}", a.handleUpdateUser) + r.Post("/admin/users/{id}/password", a.handleResetUserPassword) + r.Delete("/admin/users/{id}", a.handleDeleteUser) r.Get("/admin/domains", a.handleListDomains) r.Post("/admin/domains", a.handleCreateDomain) + r.Post("/admin/domains/{id}", a.handleUpdateDomain) + r.Delete("/admin/domains/{id}", a.handleDeleteDomain) r.Get("/admin/mailboxes", a.handleListMailboxes) r.Post("/admin/mailboxes", a.handleCreateMailbox) + r.Post("/admin/mailboxes/{id}", a.handleUpdateMailbox) + r.Delete("/admin/mailboxes/{id}", a.handleDeleteMailbox) r.Get("/admin/aliases", a.handleListAliases) r.Post("/admin/aliases", a.handleCreateAlias) + r.Post("/admin/aliases/{id}", a.handleUpdateAlias) + r.Delete("/admin/aliases/{id}", a.handleDeleteAlias) + r.Get("/admin/messages", a.handleAdminMessages) + r.Get("/admin/messages/{id}", a.handleAdminMessage) + r.Get("/admin/attachments/{id}", a.handleAdminAttachment) + r.Get("/admin/settings", a.handleGetSystemSettings) + r.Post("/admin/settings", a.handleUpdateSystemSettings) + r.Post("/admin/settings/test-smtp", a.handleTestSMTP) + r.Get("/admin/mail-templates", a.handleListMailTemplates) + r.Post("/admin/mail-templates/{key}", a.handleUpdateMailTemplate) + r.Post("/admin/mail-templates/{key}/reset", a.handleResetMailTemplate) r.Get("/admin/domains/{id}/dns-records", a.handleDNSRecords) r.Post("/admin/domains/{id}/check-dns", a.handleDNSCheck) }) @@ -99,13 +124,44 @@ func (a *App) corsMiddleware(next http.Handler) http.Handler { func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) { var req struct { - Email string `json:"email"` - Password string `json:"password"` + Email string `json:"email"` + Password string `json:"password"` + TurnstileToken string `json:"turnstileToken"` + ChallengeToken string `json:"challengeToken"` + TwoFactorCode string `json:"twoFactorCode"` } if err := decodeJSON(r, &req); err != nil { badRequest(w, err) return } + if strings.TrimSpace(req.ChallengeToken) != "" { + challenge, err := a.loginChallengeByToken(r.Context(), req.ChallengeToken) + if err != nil { + respondError(w, http.StatusUnauthorized, "invalid verification challenge") + return + } + user, secret, err := a.loadUserAuthByID(r.Context(), challenge.UserID) + if err != nil || user.Disabled || !user.TwoFactorEnabled || strings.TrimSpace(secret) == "" { + a.deleteLoginChallenge(r.Context(), challenge.ID) + respondError(w, http.StatusUnauthorized, "invalid verification challenge") + return + } + if !verifyTOTP(secret, req.TwoFactorCode, a.now().UTC()) { + respondError(w, http.StatusUnauthorized, "invalid verification code") + return + } + a.deleteLoginChallenge(r.Context(), challenge.ID) + if err := a.issueSession(w, r, user.ID); err != nil { + respondError(w, http.StatusInternalServerError, "failed to create session") + return + } + respondJSON(w, http.StatusOK, map[string]any{"user": user}) + return + } + if err := a.verifyTurnstile(r.Context(), req.TurnstileToken, r.RemoteAddr); err != nil { + respondError(w, http.StatusUnauthorized, "human verification failed") + return + } email := normalizeEmail(req.Email) user, passwordHash, err := a.userByEmail(r.Context(), email) if err != nil || user.Disabled { @@ -116,25 +172,19 @@ func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) { respondError(w, http.StatusUnauthorized, "invalid email or password") return } - token := randomToken() - sessionID := newID("ses") - expires := a.now().UTC().Add(time.Duration(a.cfg.SessionTTLHours) * time.Hour) - _, err = a.db.ExecContext(r.Context(), `INSERT INTO sessions(id,user_id,token_hash,expires_at,created_at) VALUES(?,?,?,?,?)`, - sessionID, user.ID, hashToken(token), expires.Format(time.RFC3339Nano), a.now().UTC().Format(time.RFC3339Nano)) - if err != nil { + if a.cfg.TwoFactorEnabled && user.TwoFactorEnabled { + challengeToken, err := a.createLoginChallenge(r.Context(), user.ID) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to create verification challenge") + return + } + respondJSON(w, http.StatusOK, map[string]any{"twoFactorRequired": true, "challengeToken": challengeToken}) + return + } + if err := a.issueSession(w, r, user.ID); err != nil { respondError(w, http.StatusInternalServerError, "failed to create session") return } - http.SetCookie(w, &http.Cookie{ - Name: a.cfg.CookieName, - Value: token, - Path: "/", - Expires: expires, - MaxAge: int(time.Until(expires).Seconds()), - HttpOnly: true, - SameSite: http.SameSiteLaxMode, - Secure: !a.cfg.AllowInsecureHTTP, - }) respondJSON(w, http.StatusOK, map[string]any{"user": user}) } @@ -265,16 +315,17 @@ func (a *App) authenticateRequest(r *http.Request) (*User, error) { if err != nil || cookie.Value == "" { return nil, errors.New("no session") } - row := a.db.QueryRowContext(r.Context(), `SELECT u.id,u.email,u.display_name,u.role,u.disabled,u.created_at + row := a.db.QueryRowContext(r.Context(), `SELECT u.id,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.created_at FROM sessions s JOIN users u ON u.id=s.user_id WHERE s.token_hash=? AND s.expires_at > ?`, hashToken(cookie.Value), a.now().UTC().Format(time.RFC3339Nano)) var u User - var disabled int + var disabled, twoFactorEnabled int var created string - if err := row.Scan(&u.ID, &u.Email, &u.DisplayName, &u.Role, &disabled, &created); err != nil { + if err := row.Scan(&u.ID, &u.Email, &u.DisplayName, &u.Role, &disabled, &twoFactorEnabled, &created); err != nil { return nil, err } u.Disabled = intBool(disabled) + u.TwoFactorEnabled = intBool(twoFactorEnabled) u.CreatedAt = parseTime(created) if u.Disabled { return nil, errors.New("disabled") @@ -283,34 +334,36 @@ func (a *App) authenticateRequest(r *http.Request) (*User, error) { } func (a *App) userByEmail(ctx context.Context, email string) (*User, string, error) { - row := a.db.QueryRowContext(ctx, `SELECT id,email,display_name,role,password_hash,disabled,created_at FROM users WHERE email=?`, email) + row := a.db.QueryRowContext(ctx, `SELECT id,email,display_name,role,password_hash,disabled,two_factor_enabled,created_at FROM users WHERE email=?`, email) var u User var passwordHash string - var disabled int + var disabled, twoFactorEnabled int var created string - if err := row.Scan(&u.ID, &u.Email, &u.DisplayName, &u.Role, &passwordHash, &disabled, &created); err != nil { + if err := row.Scan(&u.ID, &u.Email, &u.DisplayName, &u.Role, &passwordHash, &disabled, &twoFactorEnabled, &created); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, "", errNotFound } return nil, "", err } u.Disabled = intBool(disabled) + u.TwoFactorEnabled = intBool(twoFactorEnabled) u.CreatedAt = parseTime(created) return &u, passwordHash, nil } func (a *App) userByID(ctx context.Context, id string) (*User, error) { - row := a.db.QueryRowContext(ctx, `SELECT id,email,display_name,role,disabled,created_at FROM users WHERE id=?`, id) + row := a.db.QueryRowContext(ctx, `SELECT id,email,display_name,role,disabled,two_factor_enabled,created_at FROM users WHERE id=?`, id) var u User - var disabled int + var disabled, twoFactorEnabled int var created string - if err := row.Scan(&u.ID, &u.Email, &u.DisplayName, &u.Role, &disabled, &created); err != nil { + if err := row.Scan(&u.ID, &u.Email, &u.DisplayName, &u.Role, &disabled, &twoFactorEnabled, &created); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, errNotFound } return nil, err } u.Disabled = intBool(disabled) + u.TwoFactorEnabled = intBool(twoFactorEnabled) u.CreatedAt = parseTime(created) return &u, nil } diff --git a/apps/api/internal/app/settings_handlers.go b/apps/api/internal/app/settings_handlers.go new file mode 100644 index 0000000..2856390 --- /dev/null +++ b/apps/api/internal/app/settings_handlers.go @@ -0,0 +1,346 @@ +package app + +import ( + "context" + "errors" + "net/http" + "strconv" + "strings" + "time" +) + +type SystemSettings struct { + PublicHostname string `json:"publicHostname"` + PublicBaseURL string `json:"publicBaseUrl"` + SMTPHost string `json:"smtpHost"` + SMTPPort string `json:"smtpPort"` + SMTPUsername string `json:"smtpUsername"` + SMTPPasswordSet bool `json:"smtpPasswordSet"` + SMTPRequireTLS bool `json:"smtpRequireTls"` + MaildirRoot string `json:"maildirRoot"` + MaildirScanSeconds int `json:"maildirScanSeconds"` + SessionTTLHours int `json:"sessionTtlHours"` + AllowInsecureHTTP bool `json:"allowInsecureHttp"` + OpenRegistration bool `json:"openRegistration"` + TwoFactorEnabled bool `json:"twoFactorEnabled"` + TurnstileEnabled bool `json:"turnstileEnabled"` + TurnstileSiteKey string `json:"turnstileSiteKey"` + TurnstileSecretSet bool `json:"turnstileSecretSet"` + CatchAllEnabled bool `json:"catchAllEnabled"` + MailAutoRefresh bool `json:"mailAutoRefresh"` + MailRefreshSeconds int `json:"mailRefreshSeconds"` +} + +type systemSettingsUpdate struct { + PublicHostname string `json:"publicHostname"` + PublicBaseURL string `json:"publicBaseUrl"` + SMTPHost string `json:"smtpHost"` + SMTPPort string `json:"smtpPort"` + SMTPUsername string `json:"smtpUsername"` + SMTPPassword string `json:"smtpPassword"` + SMTPRequireTLS bool `json:"smtpRequireTls"` + MaildirRoot string `json:"maildirRoot"` + MaildirScanSeconds int `json:"maildirScanSeconds"` + SessionTTLHours int `json:"sessionTtlHours"` + AllowInsecureHTTP bool `json:"allowInsecureHttp"` + OpenRegistration bool `json:"openRegistration"` + TwoFactorEnabled bool `json:"twoFactorEnabled"` + TurnstileEnabled bool `json:"turnstileEnabled"` + TurnstileSiteKey string `json:"turnstileSiteKey"` + TurnstileSecretKey string `json:"turnstileSecretKey"` + CatchAllEnabled bool `json:"catchAllEnabled"` + MailAutoRefresh bool `json:"mailAutoRefresh"` + MailRefreshSeconds int `json:"mailRefreshSeconds"` +} + +type PublicSettings struct { + TurnstileEnabled bool `json:"turnstileEnabled"` + TurnstileSiteKey string `json:"turnstileSiteKey"` + MailAutoRefresh bool `json:"mailAutoRefresh"` + MailRefreshMs int `json:"mailRefreshMs"` +} + +type smtpTestRequest struct { + To string `json:"to"` +} + +func (a *App) handleGetSystemSettings(w http.ResponseWriter, r *http.Request) { + respondJSON(w, http.StatusOK, a.systemSettingsSnapshot()) +} + +func (a *App) handlePublicSettings(w http.ResponseWriter, r *http.Request) { + enabled := a.cfg.TurnstileEnabled && strings.TrimSpace(a.cfg.TurnstileSiteKey) != "" && strings.TrimSpace(a.cfg.TurnstileSecretKey) != "" + refreshSeconds := a.cfg.MailRefreshSeconds + if refreshSeconds <= 0 { + refreshSeconds = 30 + } + respondJSON(w, http.StatusOK, PublicSettings{TurnstileEnabled: enabled, TurnstileSiteKey: a.cfg.TurnstileSiteKey, MailAutoRefresh: a.cfg.MailAutoRefresh, MailRefreshMs: refreshSeconds * 1000}) +} + +func (a *App) handleUpdateSystemSettings(w http.ResponseWriter, r *http.Request) { + var req systemSettingsUpdate + if err := decodeJSON(r, &req); err != nil { + badRequest(w, err) + return + } + next := a.cfg + next.PublicHostname = normalizeHostname(req.PublicHostname) + if next.PublicHostname == "" { + badRequest(w, errors.New("publicHostname is required")) + return + } + next.PublicBaseURL = strings.TrimSpace(req.PublicBaseURL) + next.SMTPHost = strings.TrimSpace(req.SMTPHost) + next.SMTPPort = strings.TrimSpace(req.SMTPPort) + if next.SMTPPort == "" { + next.SMTPPort = "25" + } + if _, err := strconv.Atoi(next.SMTPPort); err != nil { + badRequest(w, errors.New("smtpPort must be a number")) + return + } + next.SMTPUsername = strings.TrimSpace(req.SMTPUsername) + if strings.TrimSpace(req.SMTPPassword) != "" { + next.SMTPPassword = req.SMTPPassword + } + next.SMTPRequireTLS = req.SMTPRequireTLS + next.MaildirRoot = strings.TrimSpace(req.MaildirRoot) + if req.MaildirScanSeconds <= 0 { + req.MaildirScanSeconds = 30 + } + next.MaildirScanSeconds = req.MaildirScanSeconds + if req.SessionTTLHours <= 0 { + req.SessionTTLHours = 24 * 7 + } + next.SessionTTLHours = req.SessionTTLHours + next.AllowInsecureHTTP = req.AllowInsecureHTTP + next.OpenRegistration = req.OpenRegistration + next.TwoFactorEnabled = req.TwoFactorEnabled + next.TurnstileEnabled = req.TurnstileEnabled + next.TurnstileSiteKey = strings.TrimSpace(req.TurnstileSiteKey) + if strings.TrimSpace(req.TurnstileSecretKey) != "" { + next.TurnstileSecretKey = strings.TrimSpace(req.TurnstileSecretKey) + } + if next.TurnstileEnabled && (next.TurnstileSiteKey == "" || next.TurnstileSecretKey == "") { + badRequest(w, errors.New("turnstile keys are required when enabled")) + return + } + next.CatchAllEnabled = req.CatchAllEnabled + next.MailAutoRefresh = req.MailAutoRefresh + if req.MailRefreshSeconds <= 0 { + req.MailRefreshSeconds = 30 + } + next.MailRefreshSeconds = req.MailRefreshSeconds + + if err := a.saveSystemSettings(r.Context(), next); err != nil { + respondError(w, http.StatusInternalServerError, "failed to save settings") + return + } + a.cfg = next + respondJSON(w, http.StatusOK, a.systemSettingsSnapshot()) +} + +func (a *App) handleTestSMTP(w http.ResponseWriter, r *http.Request) { + var req smtpTestRequest + if err := decodeJSON(r, &req); err != nil { + badRequest(w, err) + return + } + cfg := a.cfg + if strings.TrimSpace(cfg.SMTPHost) == "" { + badRequest(w, errors.New("SMTP 主机未设置")) + return + } + if strings.TrimSpace(cfg.SMTPPort) == "" { + cfg.SMTPPort = "25" + } + if _, err := strconv.Atoi(cfg.SMTPPort); err != nil { + badRequest(w, errors.New("SMTP 端口无效")) + return + } + to := normalizeEmail(req.To) + if to == "" || !strings.Contains(to, "@") { + badRequest(w, errors.New("收件邮箱无效")) + return + } + from := cfg.AdminEmail + if user := currentUser(r); user != nil && strings.Contains(user.Email, "@") { + from = user.Email + } + if strings.TrimSpace(from) == "" || !strings.Contains(from, "@") { + badRequest(w, errors.New("发件邮箱无效")) + return + } + domain := cfg.PublicHostname + if parts := strings.SplitN(from, "@", 2); len(parts) == 2 && parts[1] != "" { + domain = parts[1] + } + if domain == "" { + domain = "lanqin.local" + } + now := a.now().UTC() + subject := "LanQin Email SMTP 测试" + bodyText := "这是一封 SMTP 测试邮件。" + bodyHTML := "

这是一封 SMTP 测试邮件。

" + if tpl, err := a.mailTemplate(r.Context(), smtpTestTemplateKey); err == nil { + rendered := renderMailTemplate(tpl, templateRenderData{ + To: to, + From: from, + PublicHostname: cfg.PublicHostname, + PublicBaseURL: cfg.PublicBaseURL, + Time: now, + }) + subject, bodyText, bodyHTML = rendered.Subject, rendered.Text, rendered.HTML + } + mimeBytes, err := BuildMIME(MIMEMessage{ + From: from, + To: []string{to}, + Subject: subject, + Text: bodyText, + HTML: bodyHTML, + MessageID: "<" + newID("msg") + "@" + domain + ">", + Date: now, + }) + if err != nil { + badRequest(w, err) + return + } + if err := sendSMTPWithConfig(cfg, from, []string{to}, mimeBytes); err != nil { + respondError(w, http.StatusBadGateway, err.Error()) + return + } + respondJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +func (a *App) systemSettingsSnapshot() SystemSettings { + return SystemSettings{ + PublicHostname: a.cfg.PublicHostname, + PublicBaseURL: a.cfg.PublicBaseURL, + SMTPHost: a.cfg.SMTPHost, + SMTPPort: a.cfg.SMTPPort, + SMTPUsername: a.cfg.SMTPUsername, + SMTPPasswordSet: strings.TrimSpace(a.cfg.SMTPPassword) != "", + SMTPRequireTLS: a.cfg.SMTPRequireTLS, + MaildirRoot: a.cfg.MaildirRoot, + MaildirScanSeconds: a.cfg.MaildirScanSeconds, + SessionTTLHours: a.cfg.SessionTTLHours, + AllowInsecureHTTP: a.cfg.AllowInsecureHTTP, + OpenRegistration: a.cfg.OpenRegistration, + TwoFactorEnabled: a.cfg.TwoFactorEnabled, + TurnstileEnabled: a.cfg.TurnstileEnabled, + TurnstileSiteKey: a.cfg.TurnstileSiteKey, + TurnstileSecretSet: strings.TrimSpace(a.cfg.TurnstileSecretKey) != "", + CatchAllEnabled: a.cfg.CatchAllEnabled, + MailAutoRefresh: a.cfg.MailAutoRefresh, + MailRefreshSeconds: a.cfg.MailRefreshSeconds, + } +} + +func (a *App) loadPersistedSystemSettings(ctx context.Context) error { + rows, err := a.db.QueryContext(ctx, `SELECT key,value FROM system_settings`) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var key, value string + if err := rows.Scan(&key, &value); err != nil { + return err + } + switch key { + case "publicHostname": + a.cfg.PublicHostname = value + case "publicBaseUrl": + a.cfg.PublicBaseURL = value + case "smtpHost": + a.cfg.SMTPHost = value + case "smtpPort": + a.cfg.SMTPPort = value + case "smtpUsername": + a.cfg.SMTPUsername = value + case "smtpPassword": + a.cfg.SMTPPassword = value + case "smtpRequireTls": + a.cfg.SMTPRequireTLS = value == "true" + case "maildirRoot": + a.cfg.MaildirRoot = value + case "maildirScanSeconds": + if n, err := strconv.Atoi(value); err == nil && n > 0 { + a.cfg.MaildirScanSeconds = n + } + case "sessionTtlHours": + if n, err := strconv.Atoi(value); err == nil && n > 0 { + a.cfg.SessionTTLHours = n + } + case "allowInsecureHttp": + a.cfg.AllowInsecureHTTP = value == "true" + case "openRegistration": + a.cfg.OpenRegistration = value == "true" + case "twoFactorEnabled": + a.cfg.TwoFactorEnabled = value == "true" + case "turnstileEnabled": + a.cfg.TurnstileEnabled = value == "true" + case "turnstileSiteKey": + a.cfg.TurnstileSiteKey = value + case "turnstileSecretKey": + a.cfg.TurnstileSecretKey = value + case "catchAllEnabled": + a.cfg.CatchAllEnabled = value == "true" + case "mailAutoRefresh": + a.cfg.MailAutoRefresh = value == "true" + case "mailRefreshSeconds": + if n, err := strconv.Atoi(value); err == nil && n > 0 { + a.cfg.MailRefreshSeconds = n + } + } + } + return rows.Err() +} + +func (a *App) saveSystemSettings(ctx context.Context, cfg Config) error { + values := map[string]string{ + "publicHostname": cfg.PublicHostname, + "publicBaseUrl": cfg.PublicBaseURL, + "smtpHost": cfg.SMTPHost, + "smtpPort": cfg.SMTPPort, + "smtpUsername": cfg.SMTPUsername, + "smtpPassword": cfg.SMTPPassword, + "smtpRequireTls": strconv.FormatBool(cfg.SMTPRequireTLS), + "maildirRoot": cfg.MaildirRoot, + "maildirScanSeconds": strconv.Itoa(cfg.MaildirScanSeconds), + "sessionTtlHours": strconv.Itoa(cfg.SessionTTLHours), + "allowInsecureHttp": strconv.FormatBool(cfg.AllowInsecureHTTP), + "openRegistration": strconv.FormatBool(cfg.OpenRegistration), + "twoFactorEnabled": strconv.FormatBool(cfg.TwoFactorEnabled), + "turnstileEnabled": strconv.FormatBool(cfg.TurnstileEnabled), + "turnstileSiteKey": cfg.TurnstileSiteKey, + "turnstileSecretKey": cfg.TurnstileSecretKey, + "catchAllEnabled": strconv.FormatBool(cfg.CatchAllEnabled), + "mailAutoRefresh": strconv.FormatBool(cfg.MailAutoRefresh), + "mailRefreshSeconds": strconv.Itoa(cfg.MailRefreshSeconds), + } + now := a.now().UTC().Format(time.RFC3339Nano) + tx, err := a.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + for key, value := range values { + if _, err := tx.ExecContext(ctx, `INSERT INTO system_settings(key,value,updated_at) VALUES(?,?,?) + ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`, key, value, now); err != nil { + return err + } + } + return tx.Commit() +} + +func normalizeHostname(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + value = strings.TrimSuffix(value, ".") + value = strings.TrimPrefix(value, "http://") + value = strings.TrimPrefix(value, "https://") + if i := strings.Index(value, "/"); i >= 0 { + value = value[:i] + } + return value +} diff --git a/apps/api/internal/app/template_handlers.go b/apps/api/internal/app/template_handlers.go new file mode 100644 index 0000000..2e79526 --- /dev/null +++ b/apps/api/internal/app/template_handlers.go @@ -0,0 +1,210 @@ +package app + +import ( + "context" + "errors" + "net/http" + "sort" + "strings" + "time" + + "github.com/go-chi/chi/v5" +) + +const smtpTestTemplateKey = "smtp_test" + +type MailTemplate struct { + Key string `json:"key"` + Name string `json:"name"` + Subject string `json:"subject"` + BodyText string `json:"bodyText"` + BodyHTML string `json:"bodyHtml"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type mailTemplateUpdate struct { + Subject string `json:"subject"` + BodyText string `json:"bodyText"` + BodyHTML string `json:"bodyHtml"` +} + +type templateRenderData struct { + To string + From string + Subject string + PublicHostname string + PublicBaseURL string + Time time.Time +} + +func defaultMailTemplates() []MailTemplate { + now := time.Unix(0, 0).UTC() + return []MailTemplate{ + { + Key: "welcome", + Name: "欢迎邮件", + Subject: "欢迎使用 LanQin Email", + BodyText: "你的自建邮箱 Webmail 已经初始化完成。\n\n请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。", + BodyHTML: "

你的自建邮箱 Webmail 已经初始化完成。

请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。

", + UpdatedAt: now, + }, + { + Key: smtpTestTemplateKey, + Name: "SMTP 测试", + Subject: "LanQin Email SMTP 测试", + BodyText: "这是一封 SMTP 测试邮件。\n\n发件人:{{from}}\n收件人:{{to}}\n时间:{{time}}\n主机:{{publicHostname}}", + BodyHTML: "

这是一封 SMTP 测试邮件。

发件人:{{from}}
收件人:{{to}}
时间:{{time}}
主机:{{publicHostname}}

", + UpdatedAt: now, + }, + } +} + +func (a *App) ensureDefaultMailTemplates(ctx context.Context) error { + now := a.now().UTC().Format(time.RFC3339Nano) + for _, tpl := range defaultMailTemplates() { + if _, err := a.db.ExecContext(ctx, `INSERT INTO mail_templates(key,name,subject,body_text,body_html,updated_at) + VALUES(?,?,?,?,?,?) ON CONFLICT(key) DO NOTHING`, + tpl.Key, tpl.Name, tpl.Subject, tpl.BodyText, tpl.BodyHTML, now); err != nil { + return err + } + } + return nil +} + +func (a *App) handleListMailTemplates(w http.ResponseWriter, r *http.Request) { + rows, err := a.db.QueryContext(r.Context(), `SELECT key,name,subject,body_text,body_html,updated_at FROM mail_templates ORDER BY name`) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to list templates") + return + } + defer rows.Close() + items := []MailTemplate{} + for rows.Next() { + var item MailTemplate + var updated string + if err := rows.Scan(&item.Key, &item.Name, &item.Subject, &item.BodyText, &item.BodyHTML, &updated); err != nil { + respondError(w, http.StatusInternalServerError, "failed to scan templates") + return + } + item.UpdatedAt = parseTime(updated) + items = append(items, item) + } + if err := rows.Err(); err != nil { + respondError(w, http.StatusInternalServerError, "failed to list templates") + return + } + respondJSON(w, http.StatusOK, map[string]any{"items": items}) +} + +func (a *App) handleUpdateMailTemplate(w http.ResponseWriter, r *http.Request) { + key := strings.TrimSpace(chi.URLParam(r, "key")) + var req mailTemplateUpdate + if err := decodeJSON(r, &req); err != nil { + badRequest(w, err) + return + } + subject := strings.TrimSpace(req.Subject) + if subject == "" { + badRequest(w, errors.New("subject is required")) + return + } + bodyText := strings.TrimSpace(req.BodyText) + bodyHTML := strings.TrimSpace(req.BodyHTML) + if bodyText == "" && bodyHTML == "" { + badRequest(w, errors.New("template body is required")) + return + } + if bodyText == "" { + bodyText = stripTags(bodyHTML) + } + if bodyHTML == "" { + bodyHTML = "

" + htmlEscape(bodyText) + "

" + } + bodyHTML = a.policy.Sanitize(bodyHTML) + now := a.now().UTC().Format(time.RFC3339Nano) + res, err := a.db.ExecContext(r.Context(), `UPDATE mail_templates SET subject=?,body_text=?,body_html=?,updated_at=? WHERE key=?`, + subject, bodyText, bodyHTML, now, key) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to update template") + return + } + affected, _ := res.RowsAffected() + if affected == 0 { + respondError(w, http.StatusNotFound, "template not found") + return + } + tpl, err := a.mailTemplate(r.Context(), key) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to load template") + return + } + respondJSON(w, http.StatusOK, tpl) +} + +func (a *App) handleResetMailTemplate(w http.ResponseWriter, r *http.Request) { + key := strings.TrimSpace(chi.URLParam(r, "key")) + var defaults = defaultMailTemplates() + for _, tpl := range defaults { + if tpl.Key != key { + continue + } + now := a.now().UTC().Format(time.RFC3339Nano) + res, err := a.db.ExecContext(r.Context(), `UPDATE mail_templates SET name=?,subject=?,body_text=?,body_html=?,updated_at=? WHERE key=?`, + tpl.Name, tpl.Subject, tpl.BodyText, tpl.BodyHTML, now, key) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to reset template") + return + } + if affected, _ := res.RowsAffected(); affected == 0 { + respondError(w, http.StatusNotFound, "template not found") + return + } + updated, err := a.mailTemplate(r.Context(), key) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to load template") + return + } + respondJSON(w, http.StatusOK, updated) + return + } + respondError(w, http.StatusNotFound, "template not found") +} + +func (a *App) mailTemplate(ctx context.Context, key string) (MailTemplate, error) { + row := a.db.QueryRowContext(ctx, `SELECT key,name,subject,body_text,body_html,updated_at FROM mail_templates WHERE key=?`, key) + var tpl MailTemplate + var updated string + if err := row.Scan(&tpl.Key, &tpl.Name, &tpl.Subject, &tpl.BodyText, &tpl.BodyHTML, &updated); err != nil { + return MailTemplate{}, err + } + tpl.UpdatedAt = parseTime(updated) + return tpl, nil +} + +func renderMailTemplate(tpl MailTemplate, data templateRenderData) MIMEMessage { + values := map[string]string{ + "to": data.To, + "from": data.From, + "subject": data.Subject, + "publicHostname": data.PublicHostname, + "publicBaseUrl": data.PublicBaseURL, + "time": data.Time.Format("2006-01-02 15:04:05 MST"), + } + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Slice(keys, func(i, j int) bool { return len(keys[i]) > len(keys[j]) }) + apply := func(input string) string { + out := input + for _, key := range keys { + out = strings.ReplaceAll(out, "{{"+key+"}}", values[key]) + } + return out + } + return MIMEMessage{ + Subject: apply(tpl.Subject), + Text: apply(tpl.BodyText), + HTML: apply(tpl.BodyHTML), + } +} diff --git a/apps/api/internal/app/turnstile.go b/apps/api/internal/app/turnstile.go new file mode 100644 index 0000000..2cdc95b --- /dev/null +++ b/apps/api/internal/app/turnstile.go @@ -0,0 +1,61 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "net" + "net/http" + "net/url" + "strings" + "time" +) + +type turnstileVerifyResponse struct { + Success bool `json:"success"` + ErrorCodes []string `json:"error-codes"` +} + +func (a *App) verifyTurnstile(ctx context.Context, token, remoteIP string) error { + if !a.cfg.TurnstileEnabled { + return nil + } + token = strings.TrimSpace(token) + secret := strings.TrimSpace(a.cfg.TurnstileSecretKey) + if secret == "" || token == "" { + return errors.New("turnstile verification required") + } + form := url.Values{} + form.Set("secret", secret) + form.Set("response", token) + if ip := normalizeRemoteIP(remoteIP); ip != "" { + form.Set("remoteip", ip) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://challenges.cloudflare.com/turnstile/v0/siteverify", strings.NewReader(form.Encode())) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + client := &http.Client{Timeout: 8 * time.Second} + res, err := client.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + var out turnstileVerifyResponse + if err := json.NewDecoder(res.Body).Decode(&out); err != nil { + return err + } + if !out.Success { + return errors.New("turnstile verification failed") + } + return nil +} + +func normalizeRemoteIP(value string) string { + host, _, err := net.SplitHostPort(strings.TrimSpace(value)) + if err == nil { + return host + } + return strings.TrimSpace(value) +} diff --git a/apps/api/internal/app/two_factor.go b/apps/api/internal/app/two_factor.go new file mode 100644 index 0000000..d45f51b --- /dev/null +++ b/apps/api/internal/app/two_factor.go @@ -0,0 +1,279 @@ +package app + +import ( + "context" + "crypto/hmac" + "crypto/rand" + "crypto/sha1" + "database/sql" + "encoding/base32" + "encoding/binary" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "time" +) + +type loginChallenge struct { + ID string + UserID string + ExpiresAt time.Time +} + +func newTOTPSecret() (string, error) { + buf := make([]byte, 20) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf), nil +} + +func totpProvisioningURI(issuer, account, secret string) string { + issuer = strings.TrimSpace(issuer) + account = strings.TrimSpace(account) + secret = strings.TrimSpace(secret) + label := url.PathEscape(issuer + ":" + account) + return fmt.Sprintf("otpauth://totp/%s?secret=%s&issuer=%s&digits=6&period=30", label, url.QueryEscape(secret), url.QueryEscape(issuer)) +} + +func generateTOTP(secret string, now time.Time) (string, error) { + key, err := decodeTOTPSecret(secret) + if err != nil { + return "", err + } + counter := now.Unix() / 30 + return generateTOTPForCounter(key, counter), nil +} + +func verifyTOTP(secret, code string, now time.Time) bool { + code = strings.TrimSpace(code) + if len(code) != 6 { + return false + } + for _, r := range code { + if r < '0' || r > '9' { + return false + } + } + key, err := decodeTOTPSecret(secret) + if err != nil { + return false + } + counter := now.Unix() / 30 + for delta := int64(-1); delta <= 1; delta++ { + if generateTOTPForCounter(key, counter+delta) == code { + return true + } + } + return false +} + +func decodeTOTPSecret(secret string) ([]byte, error) { + secret = strings.ToUpper(strings.TrimSpace(secret)) + if secret == "" { + return nil, errors.New("empty secret") + } + return base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(secret) +} + +func generateTOTPForCounter(key []byte, counter int64) string { + var msg [8]byte + binary.BigEndian.PutUint64(msg[:], uint64(counter)) + mac := hmac.New(sha1.New, key) + _, _ = mac.Write(msg[:]) + sum := mac.Sum(nil) + offset := sum[len(sum)-1] & 0x0f + value := binary.BigEndian.Uint32(sum[offset : offset+4]) + value &= 0x7fffffff + return fmt.Sprintf("%06d", value%1000000) +} + +func (a *App) issueSession(w http.ResponseWriter, r *http.Request, userID string) error { + token := randomToken() + sessionID := newID("ses") + expires := a.now().UTC().Add(time.Duration(a.cfg.SessionTTLHours) * time.Hour) + if _, err := a.db.ExecContext(r.Context(), `INSERT INTO sessions(id,user_id,token_hash,expires_at,created_at) VALUES(?,?,?,?,?)`, + sessionID, userID, hashToken(token), expires.Format(time.RFC3339Nano), a.now().UTC().Format(time.RFC3339Nano)); err != nil { + return err + } + http.SetCookie(w, &http.Cookie{ + Name: a.cfg.CookieName, + Value: token, + Path: "/", + Expires: expires, + MaxAge: int(time.Until(expires).Seconds()), + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: !a.cfg.AllowInsecureHTTP, + }) + return nil +} + +func (a *App) createLoginChallenge(ctx context.Context, userID string) (string, error) { + token := randomToken() + now := a.now().UTC() + expires := now.Add(5 * time.Minute) + _, err := a.db.ExecContext(ctx, `INSERT INTO login_challenges(id,user_id,token_hash,expires_at,created_at) VALUES(?,?,?,?,?)`, + newID("lch"), userID, hashToken(token), expires.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano)) + if err != nil { + return "", err + } + return token, nil +} + +func (a *App) loginChallengeByToken(ctx context.Context, token string) (*loginChallenge, error) { + row := a.db.QueryRowContext(ctx, `SELECT id,user_id,expires_at FROM login_challenges WHERE token_hash=?`, hashToken(token)) + var challenge loginChallenge + var expires string + if err := row.Scan(&challenge.ID, &challenge.UserID, &expires); err != nil { + return nil, err + } + challenge.ExpiresAt = parseTime(expires) + if !challenge.ExpiresAt.IsZero() && !challenge.ExpiresAt.After(a.now().UTC()) { + _, _ = a.db.ExecContext(ctx, `DELETE FROM login_challenges WHERE id=?`, challenge.ID) + return nil, errors.New("challenge expired") + } + return &challenge, nil +} + +func (a *App) deleteLoginChallenge(ctx context.Context, id string) { + _, _ = a.db.ExecContext(ctx, `DELETE FROM login_challenges WHERE id=?`, id) +} + +func (a *App) loadUserAuthByID(ctx context.Context, id string) (*User, string, error) { + row := a.db.QueryRowContext(ctx, `SELECT id,email,display_name,role,disabled,two_factor_enabled,two_factor_secret,created_at FROM users WHERE id=?`, id) + var u User + var disabled, twoFactorEnabled int + var secret, created string + if err := row.Scan(&u.ID, &u.Email, &u.DisplayName, &u.Role, &disabled, &twoFactorEnabled, &secret, &created); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, "", errNotFound + } + return nil, "", err + } + u.Disabled = intBool(disabled) + u.TwoFactorEnabled = intBool(twoFactorEnabled) + u.CreatedAt = parseTime(created) + return &u, secret, nil +} + +func (a *App) handleTwoFactorSetup(w http.ResponseWriter, r *http.Request) { + if !a.cfg.TwoFactorEnabled { + respondError(w, http.StatusBadRequest, "two-factor authentication is disabled") + return + } + user := currentUser(r) + if user == nil { + respondError(w, http.StatusUnauthorized, "authentication required") + return + } + current, _, err := a.loadUserAuthByID(r.Context(), user.ID) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to load user") + return + } + if current.TwoFactorEnabled { + respondError(w, http.StatusBadRequest, "two-factor authentication is already enabled") + return + } + secret, err := newTOTPSecret() + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to generate secret") + return + } + now := a.now().UTC().Format(time.RFC3339Nano) + if _, err := a.db.ExecContext(r.Context(), `UPDATE users SET two_factor_secret=?, two_factor_enabled=0, updated_at=? WHERE id=?`, secret, now, user.ID); err != nil { + respondError(w, http.StatusInternalServerError, "failed to save secret") + return + } + respondJSON(w, http.StatusOK, map[string]any{ + "secret": secret, + "otpauthUrl": totpProvisioningURI("LanQin Email", current.Email, secret), + }) +} + +func (a *App) handleTwoFactorEnable(w http.ResponseWriter, r *http.Request) { + if !a.cfg.TwoFactorEnabled { + respondError(w, http.StatusBadRequest, "two-factor authentication is disabled") + return + } + user := currentUser(r) + if user == nil { + respondError(w, http.StatusUnauthorized, "authentication required") + return + } + var req struct { + Code string `json:"code"` + } + if err := decodeJSON(r, &req); err != nil { + badRequest(w, err) + return + } + current, secret, err := a.loadUserAuthByID(r.Context(), user.ID) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to load user") + return + } + if current.TwoFactorEnabled { + respondJSON(w, http.StatusOK, map[string]any{"user": current}) + return + } + if strings.TrimSpace(secret) == "" { + badRequest(w, errors.New("two-factor secret not set")) + return + } + if !verifyTOTP(secret, req.Code, a.now().UTC()) { + respondError(w, http.StatusUnauthorized, "invalid verification code") + return + } + if _, err := a.db.ExecContext(r.Context(), `UPDATE users SET two_factor_enabled=1, updated_at=? WHERE id=?`, a.now().UTC().Format(time.RFC3339Nano), user.ID); err != nil { + respondError(w, http.StatusInternalServerError, "failed to enable two-factor authentication") + return + } + updated, _, err := a.loadUserAuthByID(r.Context(), user.ID) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to load user") + return + } + respondJSON(w, http.StatusOK, map[string]any{"user": updated}) +} + +func (a *App) handleTwoFactorDisable(w http.ResponseWriter, r *http.Request) { + user := currentUser(r) + if user == nil { + respondError(w, http.StatusUnauthorized, "authentication required") + return + } + var req struct { + Code string `json:"code"` + } + if err := decodeJSON(r, &req); err != nil { + badRequest(w, err) + return + } + current, secret, err := a.loadUserAuthByID(r.Context(), user.ID) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to load user") + return + } + if !current.TwoFactorEnabled && strings.TrimSpace(secret) == "" { + respondJSON(w, http.StatusOK, map[string]any{"user": current}) + return + } + if strings.TrimSpace(secret) != "" && current.TwoFactorEnabled && !verifyTOTP(secret, req.Code, a.now().UTC()) { + respondError(w, http.StatusUnauthorized, "invalid verification code") + return + } + if _, err := a.db.ExecContext(r.Context(), `UPDATE users SET two_factor_secret='', two_factor_enabled=0, updated_at=? WHERE id=?`, a.now().UTC().Format(time.RFC3339Nano), user.ID); err != nil { + respondError(w, http.StatusInternalServerError, "failed to disable two-factor authentication") + return + } + updated, _, err := a.loadUserAuthByID(r.Context(), user.ID) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to load user") + return + } + respondJSON(w, http.StatusOK, map[string]any{"user": updated}) +} diff --git a/apps/api/internal/app/types.go b/apps/api/internal/app/types.go index 61fe9e0..83e4ad9 100644 --- a/apps/api/internal/app/types.go +++ b/apps/api/internal/app/types.go @@ -3,12 +3,19 @@ package app import "time" type User struct { - ID string `json:"id"` - Email string `json:"email"` - DisplayName string `json:"displayName"` - Role string `json:"role"` - Disabled bool `json:"disabled"` - CreatedAt time.Time `json:"createdAt"` + ID string `json:"id"` + Email string `json:"email"` + DisplayName string `json:"displayName"` + Role string `json:"role"` + Disabled bool `json:"disabled"` + TwoFactorEnabled bool `json:"twoFactorEnabled"` + CreatedAt time.Time `json:"createdAt"` +} + +type AdminUser struct { + User + MailboxCount int `json:"mailboxCount"` + Mailboxes []string `json:"mailboxes"` } type Domain struct { @@ -55,6 +62,9 @@ type MailFolder struct { type MailMessage struct { ID string `json:"id"` MailboxID string `json:"mailboxId,omitempty"` + MailboxAddress string `json:"mailboxAddress,omitempty"` + OwnerEmail string `json:"ownerEmail,omitempty"` + RecipientAddr string `json:"recipientAddress,omitempty"` FolderID string `json:"folderId"` Folder string `json:"folder"` MessageUID string `json:"messageUid"` diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json index efc5c05..e6a4b53 100644 --- a/apps/web/package-lock.json +++ b/apps/web/package-lock.json @@ -16,6 +16,7 @@ "@radix-ui/react-select": "^2.3.0", "@radix-ui/react-separator": "^1.1.9", "@radix-ui/react-slot": "^1.2.5", + "@radix-ui/react-switch": "^1.3.0", "@radix-ui/react-toast": "^1.2.2", "@radix-ui/react-tooltip": "^1.2.9", "@tanstack/react-query": "5.59.16", @@ -23,6 +24,7 @@ "clsx": "2.1.1", "dompurify": "3.1.7", "lucide-react": "^0.468.0", + "qrcode.react": "^4.2.0", "react": "18.3.1", "react-dom": "18.3.1", "react-resizable-panels": "^2.1.7", @@ -2508,6 +2510,146 @@ } } }, + "node_modules/@radix-ui/react-switch": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.3.0.tgz", + "integrity": "sha512-GP1EZwhoZO/GGnhM1P5/2Vpm8iN8EnngyU0oezn2l78kN8tj25pyrvjIaT7azBhK615KSt+P2w39y57YV5jVkA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-previous": "1.1.2", + "@radix-ui/react-use-size": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/primitive": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.4.tgz", + "integrity": "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", + "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-context": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.4.tgz", + "integrity": "sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-primitive": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.5.tgz", + "integrity": "sha512-zifXeB8Y88qCYx8PLZ5oQb32KwZub+s925mMoZsBBq9KUQqWKkREubTfs6ASjRPPBe7Jt9O8OHH89+95VG+grA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.3.tgz", + "integrity": "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.3", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", + "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-use-size": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.2.tgz", + "integrity": "sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-toast": { "version": "1.2.2", "resolved": "https://registry.npmmirror.com/@radix-ui/react-toast/-/react-toast-1.2.2.tgz", @@ -4656,6 +4798,15 @@ "dev": true, "license": "MIT" }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz", diff --git a/apps/web/package.json b/apps/web/package.json index ae63ef0..786d27b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -19,6 +19,7 @@ "@radix-ui/react-select": "^2.3.0", "@radix-ui/react-separator": "^1.1.9", "@radix-ui/react-slot": "^1.2.5", + "@radix-ui/react-switch": "^1.3.0", "@radix-ui/react-toast": "^1.2.2", "@radix-ui/react-tooltip": "^1.2.9", "@tanstack/react-query": "5.59.16", @@ -26,6 +27,7 @@ "clsx": "2.1.1", "dompurify": "3.1.7", "lucide-react": "^0.468.0", + "qrcode.react": "^4.2.0", "react": "18.3.1", "react-dom": "18.3.1", "react-resizable-panels": "^2.1.7", diff --git a/apps/web/src/components/protected-layout.tsx b/apps/web/src/components/protected-layout.tsx index 7caede6..a157d6f 100644 --- a/apps/web/src/components/protected-layout.tsx +++ b/apps/web/src/components/protected-layout.tsx @@ -1,6 +1,6 @@ import * as React from "react" import { Navigate, Outlet, Link, useLocation, useNavigate } from "react-router-dom" -import { Inbox, LogOut, Mail, Settings } from "lucide-react" +import { BarChart3, Copy, Globe2, Inbox, LogOut, Mail, Mailbox, Settings, Users } from "lucide-react" import { useQueryClient } from "@tanstack/react-query" import { api } from "@/lib/api" import { useMe } from "@/hooks/use-me" @@ -23,6 +23,16 @@ import { SidebarTrigger, } from "@/components/ui/sidebar" +const adminSections = [ + { key: "overview", label: "概览", icon: }, + { key: "users", label: "用户", icon: }, + { key: "domains", label: "域名", icon: }, + { key: "mailboxes", label: "邮箱账号", icon: }, + { key: "aliases", label: "别名转发", icon: }, + { key: "messages", label: "全部邮件", icon: }, + { key: "settings", label: "系统设置", icon: }, +] + export function ProtectedLayout() { const me = useMe() const location = useLocation() @@ -35,6 +45,8 @@ export function ProtectedLayout() { const user = me.data.user const isMailRoute = location.pathname.startsWith("/mail") const isProfileRoute = location.pathname.startsWith("/profile") + const isAdminRoute = location.pathname.startsWith("/admin") + const adminSection = new URLSearchParams(location.search).get("section") || "overview" async function logout() { await api.logout().catch(() => undefined) @@ -66,14 +78,24 @@ export function ProtectedLayout() { - - - - } label="Webmail" /> - {user.role === "admin" && } label="系统管理" />} - - - + {user.role === "admin" && isAdminRoute && ( + + + + {adminSections.map((item) => ( + + + + {item.icon} + {item.label} + + + + ))} + + + + )} @@ -115,18 +137,3 @@ export function ProtectedLayout() { ) } - -function NavItem({ to, icon, label }: { to: string; icon: React.ReactNode; label: string }) { - const location = useLocation() - const active = location.pathname.startsWith(to) - return ( - - - - {icon} - {label} - - - - ) -} diff --git a/apps/web/src/components/ui/switch.tsx b/apps/web/src/components/ui/switch.tsx new file mode 100644 index 0000000..aa58baa --- /dev/null +++ b/apps/web/src/components/ui/switch.tsx @@ -0,0 +1,27 @@ +import * as React from "react" +import * as SwitchPrimitives from "@radix-ui/react-switch" + +import { cn } from "@/lib/utils" + +const Switch = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +Switch.displayName = SwitchPrimitives.Root.displayName + +export { Switch } diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 448f024..55ef58a 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -149,3 +149,16 @@ html.theme-transition::before { background-color: hsl(var(--primary) / 0.15); color: hsl(var(--foreground)); } + +/* ===== 隐藏调试工具浮层 ===== */ +#__vue-devtools-container__, +#__vue-devtools-overlay__, +#__vue-devtools-frame__, +#__nuxt-devtools__, +vue-devtools, +vue-devtools-anchor, +[data-vue-devtools], +[id*="vue-devtools"], +[class*="vue-devtools"] { + display: none !important; +} diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index b834c22..388b6f1 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -1,11 +1,13 @@ -export type User = { id: string; email: string; displayName: string; role: "admin" | "user"; disabled: boolean; createdAt: string } +export type User = { id: string; email: string; displayName: string; role: "admin" | "user"; disabled: boolean; twoFactorEnabled: boolean; createdAt: string } +export type AdminUser = User & { mailboxCount: number; mailboxes?: string[] } +export type AdminOverview = { users: number; activeUsers: number; domains: number; mailboxes: number; activeMailboxes: number; aliases: number; messages: number; unreadMessages: number; storageBytes: number } export type Domain = { id: string; name: string; status: string; dkimSelector: string; dkimPublicKey?: string; dnsStatus: string; dnsCheckedAt?: string; createdAt: string } export type Mailbox = { id: string; userId: string; userEmail?: string; domainId: string; localPart: string; address: string; displayName: string; quotaMb: number; status: string; createdAt: string } export type Alias = { id: string; domainId: string; source: string; destination: string; enabled: boolean; createdAt: string } export type MailFolder = { id: string; name: string; role: string; unreadCount: number; totalCount: number } export type Attachment = { id: string; messageId: string; filename: string; contentType: string; sizeBytes: number; createdAt: string } export type MailMessage = { - id: string; mailboxId?: string; folderId: string; folder: string; messageUid: string; messageId: string; subject: string; from: string; to: string[]; cc: string[]; bcc?: string[]; sentAt: string; receivedAt: string; snippet: string; bodyText?: string; bodyHtml?: string; isRead: boolean; isStarred: boolean; hasAttachments: boolean; sizeBytes: number; attachments?: Attachment[] + id: string; mailboxId?: string; mailboxAddress?: string; ownerEmail?: string; recipientAddress?: string; folderId: string; folder: string; messageUid: string; messageId: string; subject: string; from: string; to: string[]; cc: string[]; bcc?: string[]; sentAt: string; receivedAt: string; snippet: string; bodyText?: string; bodyHtml?: string; isRead: boolean; isStarred: boolean; hasAttachments: boolean; sizeBytes: number; attachments?: Attachment[] } export type DNSRecord = { type: string; name: string; value: string; ttl: number } export type DNSCheckResult = { domain: string; status: string; checks: Record } @@ -15,6 +17,32 @@ export type Contact = { id: string; name: string; email: string; note: string; c export type MailRule = { id: string; mailboxId: string; name: string; fromContains: string; subjectContains: string; action: "archive" | "trash" | "star" | "mark-read"; enabled: boolean; createdAt: string } export type BlockedSender = { id: string; mailboxId: string; email: string; reason: string; createdAt: string } export type MailStats = { totalMessages: number; unreadMessages: number; starredMessages: number; attachmentCount: number; storageBytes: number; byFolder: { folder: string; role: string; count: number; unread: number; bytes: number }[] } +export type MailTemplate = { key: string; name: string; subject: string; bodyText: string; bodyHtml: string; updatedAt: string } +export type SystemSettings = { + publicHostname: string + publicBaseUrl: string + smtpHost: string + smtpPort: string + smtpUsername: string + smtpPasswordSet: boolean + smtpRequireTls: boolean + maildirRoot: string + maildirScanSeconds: number + sessionTtlHours: number + allowInsecureHttp: boolean + openRegistration: boolean + twoFactorEnabled: boolean + turnstileEnabled: boolean + turnstileSiteKey: string + turnstileSecretSet: boolean + catchAllEnabled: boolean + mailAutoRefresh: boolean + mailRefreshSeconds: number +} +export type SystemSettingsPayload = Omit & { smtpPassword: string; turnstileSecretKey: string } +export type PublicSettings = { turnstileEnabled: boolean; turnstileSiteKey: string; mailAutoRefresh: boolean; mailRefreshMs: number } +export type LoginPayload = { email?: string; password?: string; turnstileToken?: string; challengeToken?: string; twoFactorCode?: string } +export type LoginResponse = { user?: User; twoFactorRequired?: boolean; challengeToken?: string } async function request(path: string, init: RequestInit = {}): Promise { const res = await fetch(path, { credentials: "include", headers: { "Content-Type": "application/json", ...(init.headers || {}) }, ...init }) @@ -27,11 +55,15 @@ async function request(path: string, init: RequestInit = {}): Promise { } export const api = { - login: (email: string, password: string) => request<{ user: User }>("/api/auth/login", { method: "POST", body: JSON.stringify({ email, password }) }), + publicSettings: () => request("/api/public/settings"), + login: (payload: LoginPayload) => request("/api/auth/login", { method: "POST", body: JSON.stringify(payload) }), logout: () => request<{ ok: boolean }>("/api/auth/logout", { method: "POST" }), me: () => request<{ user: User }>("/api/me"), updateProfile: (payload: { displayName: string }) => request<{ user: User }>("/api/me/profile", { method: "POST", body: JSON.stringify(payload) }), changePassword: (payload: { currentPassword: string; newPassword: string }) => request<{ ok: boolean }>("/api/me/password", { method: "POST", body: JSON.stringify(payload) }), + setupTwoFactor: () => request<{ secret: string; otpauthUrl: string }>("/api/me/2fa/setup", { method: "POST" }), + enableTwoFactor: (code: string) => request<{ user: User }>("/api/me/2fa/enable", { method: "POST", body: JSON.stringify({ code }) }), + disableTwoFactor: (code: string) => request<{ user: User }>("/api/me/2fa/disable", { method: "POST", body: JSON.stringify({ code }) }), contacts: () => request>("/api/me/contacts"), createContact: (payload: { name: string; email: string; note: string }) => request("/api/me/contacts", { method: "POST", body: JSON.stringify(payload) }), deleteContact: (id: string) => request<{ ok: boolean }>(`/api/me/contacts/${id}`, { method: "DELETE" }), @@ -43,12 +75,40 @@ export const api = { deleteBlockedSender: (id: string) => request<{ ok: boolean }>(`/api/me/blocked-senders/${id}`, { method: "DELETE" }), mailStats: (mailboxId?: string) => request(`/api/me/stats${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`), cleanupMail: (payload: { mailboxId: string; target: "empty-trash" | "empty-spam" | "archive-read-inbox" }) => request<{ ok: boolean; affected: number }>("/api/me/cleanup", { method: "POST", body: JSON.stringify(payload) }), + adminOverview: () => request("/api/admin/overview"), + users: () => request>("/api/admin/users"), + createUser: (payload: { email: string; displayName: string; role: "admin" | "user"; password: string; disabled: boolean }) => request("/api/admin/users", { method: "POST", body: JSON.stringify(payload) }), + updateUser: (id: string, payload: { displayName: string; role: "admin" | "user"; disabled: boolean }) => request(`/api/admin/users/${id}`, { method: "POST", body: JSON.stringify(payload) }), + resetUserPassword: (id: string, password: string) => request<{ ok: boolean }>(`/api/admin/users/${id}/password`, { method: "POST", body: JSON.stringify({ password }) }), + deleteUser: (id: string) => request<{ ok: boolean }>(`/api/admin/users/${id}`, { method: "DELETE" }), domains: () => request>("/api/admin/domains"), createDomain: (name: string) => request("/api/admin/domains", { method: "POST", body: JSON.stringify({ name }) }), + updateDomain: (id: string, payload: { status: string }) => request(`/api/admin/domains/${id}`, { method: "POST", body: JSON.stringify(payload) }), + deleteDomain: (id: string) => request<{ ok: boolean }>(`/api/admin/domains/${id}`, { method: "DELETE" }), mailboxes: () => request>("/api/admin/mailboxes"), - createMailbox: (payload: { domainId: string; localPart: string; displayName: string; password: string; quotaMb: number; role: "admin" | "user"; ownerEmail?: string }) => request("/api/admin/mailboxes", { method: "POST", body: JSON.stringify(payload) }), + createMailbox: (payload: { domainId: string; localPart: string; displayName: string; password: string; quotaMb: number; role: "admin" | "user"; ownerEmail?: string; userId?: string }) => request("/api/admin/mailboxes", { method: "POST", body: JSON.stringify(payload) }), + updateMailbox: (id: string, payload: { userId: string; displayName: string; quotaMb: number; status: string }) => request(`/api/admin/mailboxes/${id}`, { method: "POST", body: JSON.stringify(payload) }), + deleteMailbox: (id: string) => request<{ ok: boolean }>(`/api/admin/mailboxes/${id}`, { method: "DELETE" }), aliases: () => request>("/api/admin/aliases"), createAlias: (payload: { domainId: string; source: string; destination: string; enabled: boolean }) => request("/api/admin/aliases", { method: "POST", body: JSON.stringify(payload) }), + updateAlias: (id: string, payload: { source: string; destination: string; enabled: boolean }) => request(`/api/admin/aliases/${id}`, { method: "POST", body: JSON.stringify(payload) }), + deleteAlias: (id: string) => request<{ ok: boolean }>(`/api/admin/aliases/${id}`, { method: "DELETE" }), + adminMessages: (params: { mailboxId?: string; folder?: string; q?: string; cursor?: string } = {}) => { + const query = new URLSearchParams() + if (params.mailboxId) query.set("mailboxId", params.mailboxId) + if (params.folder) query.set("folder", params.folder) + if (params.q) query.set("q", params.q) + if (params.cursor) query.set("cursor", params.cursor) + const suffix = query.toString() + return request>(`/api/admin/messages${suffix ? `?${suffix}` : ""}`) + }, + adminMessage: (id: string) => request(`/api/admin/messages/${id}`), + systemSettings: () => request("/api/admin/settings"), + updateSystemSettings: (payload: SystemSettingsPayload) => request("/api/admin/settings", { method: "POST", body: JSON.stringify(payload) }), + testSmtp: (to: string) => request<{ ok: boolean }>("/api/admin/settings/test-smtp", { method: "POST", body: JSON.stringify({ to }) }), + mailTemplates: () => request>("/api/admin/mail-templates"), + updateMailTemplate: (key: string, payload: { subject: string; bodyText: string; bodyHtml: string }) => request(`/api/admin/mail-templates/${encodeURIComponent(key)}`, { method: "POST", body: JSON.stringify(payload) }), + resetMailTemplate: (key: string) => request(`/api/admin/mail-templates/${encodeURIComponent(key)}/reset`, { method: "POST" }), dnsRecords: (domainId: string) => request<{ items: DNSRecord[] }>(`/api/admin/domains/${domainId}/dns-records`), checkDns: (domainId: string) => request(`/api/admin/domains/${domainId}/check-dns`, { method: "POST" }), myMailboxes: () => request>("/api/mail/mailboxes"), @@ -65,3 +125,6 @@ export const api = { move: (id: string, folder: string) => request<{ ok: boolean }>(`/api/mail/messages/${id}/move`, { method: "POST", body: JSON.stringify({ folder }) }), delete: (id: string) => request<{ ok: boolean }>(`/api/mail/messages/${id}`, { method: "DELETE" }), } + + + diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index cfcc9eb..6380240 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -8,6 +8,7 @@ import { LoginPage } from "@/pages/login" import { MailPage } from "@/pages/mail" import { AdminPage } from "@/pages/admin" import { ProfilePage } from "@/pages/profile" +import { useMe } from "@/hooks/use-me" import "./index.css" const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false, staleTime: 10_000 } } }) @@ -17,10 +18,18 @@ const router = createBrowserRouter([ { index: true, element: }, { path: "mail", element: }, { path: "profile", element: }, - { path: "admin", element: }, + { path: "admin", element: }, ] }, ]) +function AdminOnly({ children }: { children: React.ReactNode }) { + const me = useMe() + if (me.isLoading) return null + if (!me.data?.user) return + if (me.data.user.role !== "admin") return + return <>{children} +} + ReactDOM.createRoot(document.getElementById("root")!).render( diff --git a/apps/web/src/pages/admin.tsx b/apps/web/src/pages/admin.tsx index 205d078..e8d698d 100644 --- a/apps/web/src/pages/admin.tsx +++ b/apps/web/src/pages/admin.tsx @@ -1,332 +1,834 @@ import * as React from "react" +import DOMPurify from "dompurify" +import { useSearchParams } from "react-router-dom" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" -import { CheckCircle2, Copy, Globe2, Mailbox, Plus, RefreshCcw, ShieldCheck, Users } from "lucide-react" -import { api, DNSRecord, Domain } from "@/lib/api" +import { CheckCircle2, Copy, Globe2, Mailbox, MoreHorizontal, Plus, RefreshCcw, Search, ShieldCheck, Trash2, Users } from "lucide-react" +import { api, AdminUser, Alias, DNSRecord, Domain, Mailbox as MailboxType, MailMessage, MailTemplate, SystemSettings } from "@/lib/api" +import { formatBytes, formatDate } from "@/lib/utils" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Badge } from "@/components/ui/badge" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" import { ScrollArea } from "@/components/ui/scroll-area" import { Separator } from "@/components/ui/separator" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Switch } from "@/components/ui/switch" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { Textarea } from "@/components/ui/textarea" import { useToast } from "@/hooks/use-toast" +type Section = "overview" | "users" | "domains" | "mailboxes" | "aliases" | "messages" | "settings" + +const sectionLabels: Record = { + overview: "概览", + users: "用户", + domains: "域名", + mailboxes: "邮箱账号", + aliases: "别名转发", + messages: "全部邮件", + settings: "系统设置", +} +const sectionKeys = Object.keys(sectionLabels) as Section[] + export function AdminPage() { + const overview = useQuery({ queryKey: ["admin", "overview"], queryFn: api.adminOverview }) + const users = useQuery({ queryKey: ["admin", "users"], queryFn: api.users }) const domains = useQuery({ queryKey: ["admin", "domains"], queryFn: api.domains }) const mailboxes = useQuery({ queryKey: ["admin", "mailboxes"], queryFn: api.mailboxes }) const aliases = useQuery({ queryKey: ["admin", "aliases"], queryFn: api.aliases }) - const [selectedDomain, setSelectedDomain] = React.useState(null) + const settings = useQuery({ queryKey: ["admin", "settings"], queryFn: api.systemSettings }) + const [params, setParams] = useSearchParams() - React.useEffect(() => { - if (!selectedDomain && domains.data?.items?.[0]) setSelectedDomain(domains.data.items[0].id) - }, [domains.data, selectedDomain]) + const domainItems = domains.data?.items || [] + const mailboxItems = mailboxes.data?.items || [] + const aliasItems = aliases.data?.items || [] + const userItems = users.data?.items || [] + const rawSection = params.get("section") as Section | null + const section: Section = rawSection && sectionKeys.includes(rawSection) ? rawSection : "overview" - const domain = domains.data?.items.find((d) => d.id === selectedDomain) return ( -
+
-
-

系统管理

+

{sectionLabels[section]}

+
+ +
+ {sectionKeys.map((key) => ( + + ))} +
+ + {section === "overview" && ( +
+ } label="用户" value={overview.data?.users || 0} /> + } label="域名" value={overview.data?.domains || 0} /> + } label="邮箱账号" value={overview.data?.mailboxes || 0} /> + } label="存储" value={formatBytes(overview.data?.storageBytes || 0)} />
-
- - - -
-
+ )} -
- } label="域名" value={domains.data?.items.length || 0} /> - } label="邮箱账号" value={mailboxes.data?.items.length || 0} /> - } label="别名" value={aliases.data?.items.length || 0} /> - } label="DNS 正常" value={(domains.data?.items || []).filter((d) => d.dnsStatus === "ok").length} /> -
- -
- - - 域名 - - -
- {domains.data?.items.map((d) => ( - - ))} -
-
-
- - - - - - 邮箱账号 - - - - - - 地址 - 归属用户 - 名称 - 配额 - 状态 - - - - {mailboxes.data?.items.map((m) => ( - - {m.address} - {m.userEmail || m.userId} - {m.displayName} - {m.quotaMb} MB - {m.status} - - ))} - -
-
-
- - - - 别名/转发 - - - - - - 来源 - 目标 - 状态 - - - - {aliases.data?.items.map((a) => ( - - {a.source} - {a.destination} - {a.enabled ? "启用" : "停用"} - - ))} - -
-
-
-
-
+ {section === "overview" && } + {section === "users" && } + {section === "domains" && } + {section === "mailboxes" && } + {section === "aliases" && } + {section === "messages" && } + {section === "settings" && } +
) } - -function Stat({ icon, label, value }: { icon: React.ReactNode; label: string; value: number }) { +function OverviewSection({ overview, domains }: { overview?: { activeUsers: number; activeMailboxes: number; aliases: number; messages: number; unreadMessages: number }; domains: Domain[] }) { return ( - - -
{icon}
-
-
{value}
-
{label}
-
-
-
- ) -} - -function DNSPanel({ domain }: { domain?: Domain }) { - const { toast } = useToast() - const qc = useQueryClient() - const records = useQuery({ queryKey: ["dns-records", domain?.id], queryFn: () => api.dnsRecords(domain!.id), enabled: !!domain }) - const check = useMutation({ - mutationFn: () => api.checkDns(domain!.id), - onSuccess: (res) => { - qc.invalidateQueries({ queryKey: ["admin", "domains"] }) - toast({ title: res.status === "ok" ? "DNS 检测通过" : "DNS 检测未通过", description: Object.values(res.checks).map((c) => c.message).join(";") }) - }, - }) - if (!domain) return 请选择域名 - return ( - - -
-
- DNS 记录 -
- -
-
- -
{records.data?.items.map((r) => )}
- {check.data && ( - <> - -
- {Object.entries(check.data.checks).map(([k, v]) => ( -
- - {k.toUpperCase()}: {v.message} -
- ))} -
- - )} -
-
- ) -} - -function DNSRecordRow({ record }: { record: DNSRecord }) { - const { toast } = useToast() - const text = `${record.type} ${record.name} ${record.value}` - return ( -
-
- {record.type} - -
-
-
Name: {record.name}
-
Value: {record.value}
-
TTL: {record.ttl}s
+
+
+ + 系统状态 + + + + + + + + + DNS 状态 + + {domains.map((domain) => )} + {domains.length === 0 && } + +
) } -function CreateDomainDialog() { +function UsersSection({ users }: { users: AdminUser[] }) { const qc = useQueryClient() + const { toast } = useToast() + const [query, setQuery] = React.useState("") + const [roleFilter, setRoleFilter] = React.useState("all") + const [statusFilter, setStatusFilter] = React.useState("all") + const filteredUsers = users.filter((user) => { + const keyword = query.trim().toLowerCase() + const matchesKeyword = !keyword || [user.email, user.displayName, ...(user.mailboxes || [])].some((value) => value.toLowerCase().includes(keyword)) + const matchesRole = roleFilter === "all" || user.role === roleFilter + const matchesStatus = statusFilter === "all" || (statusFilter === "active" ? !user.disabled : user.disabled) + return matchesKeyword && matchesRole && matchesStatus + }) + const remove = useMutation({ mutationFn: api.deleteUser, onSuccess: () => { invalidateAdmin(qc); toast({ title: "用户已删除" }) }, onError: (e) => toast({ title: "删除失败", description: e.message }) }) + return ( + + +
+ 用户管理 + +
+
+ +
+
+ + setQuery(event.target.value)} placeholder="搜索用户、邮箱、显示名称" className="pl-9" /> +
+ + +
+ + 用户角色邮箱状态创建时间 + + {filteredUsers.map((user) => ( + + +
{user.displayName}
+
{user.email}
+
+ {user.role === "admin" ? "管理员" : "普通用户"} + + {user.disabled ? "停用" : "正常"} + {new Date(user.createdAt).toLocaleDateString()} + remove.mutate(user.id)} /> +
+ ))} +
+
+ {filteredUsers.length === 0 && } +
+
+ ) +} + +function DomainsSection({ domains }: { domains: Domain[] }) { + const qc = useQueryClient() + const { toast } = useToast() + const update = useMutation({ mutationFn: ({ id, status }: { id: string; status: string }) => api.updateDomain(id, { status }), onSuccess: () => { invalidateAdmin(qc); toast({ title: "域名已更新" }) }, onError: (e) => toast({ title: "更新失败", description: e.message }) }) + const remove = useMutation({ mutationFn: api.deleteDomain, onSuccess: () => { invalidateAdmin(qc); toast({ title: "域名已删除" }) }, onError: (e) => toast({ title: "删除失败", description: e.message }) }) + return ( + + 域名管理 + + {domains.map((domain) => ( +
+
+
{domain.name}
+
selector: {domain.dkimSelector}
+
+
+ {domain.status === "active" ? "启用" : "停用"} + {domain.dnsStatus === "ok" ? "DNS 正常" : domain.dnsStatus} + + + +
+
+ ))} + {domains.length === 0 && } +
+
+ ) +} + +function DomainDNSDialog({ domain }: { domain: Domain }) { + return ( + + + + + + {domain.name} DNS + + + + ) +} + +function MailboxesSection({ mailboxes, users }: { mailboxes: MailboxType[]; users: AdminUser[] }) { + const qc = useQueryClient() + const { toast } = useToast() + const remove = useMutation({ mutationFn: api.deleteMailbox, onSuccess: () => { invalidateAdmin(qc); toast({ title: "邮箱已删除" }) }, onError: (e) => toast({ title: "删除失败", description: e.message }) }) + return ( + + 邮箱账号管理 + + + 地址归属用户名称配额状态 + + {mailboxes.map((mailbox) => ( + + {mailbox.address} + {mailbox.userEmail || mailbox.userId} + {mailbox.displayName} + {mailbox.quotaMb} MB + {mailbox.status === "active" ? "启用" : "停用"} + remove.mutate(mailbox.id)} /> + + ))} + +
+
+
+ ) +} + +function AliasesSection({ aliases, domains }: { aliases: Alias[]; domains: Domain[] }) { + const qc = useQueryClient() + const { toast } = useToast() + const update = useMutation({ mutationFn: ({ id, payload }: { id: string; payload: { source: string; destination: string; enabled: boolean } }) => api.updateAlias(id, payload), onSuccess: () => { invalidateAdmin(qc); toast({ title: "别名已更新" }) }, onError: (e) => toast({ title: "更新失败", description: e.message }) }) + const remove = useMutation({ mutationFn: api.deleteAlias, onSuccess: () => { invalidateAdmin(qc); toast({ title: "别名已删除" }) }, onError: (e) => toast({ title: "删除失败", description: e.message }) }) + return ( + + 别名/转发管理 + + + 来源目标域名状态 + + {aliases.map((alias) => ( + + {alias.source} + {alias.destination} + {domains.find((d) => d.id === alias.domainId)?.name || alias.domainId} + {alias.enabled ? "启用" : "停用"} + update.mutate({ id: alias.id, payload: { source: alias.source, destination: alias.destination, enabled: !alias.enabled } })} onDelete={() => remove.mutate(alias.id)} /> + + ))} + +
+
+
+ ) +} + +function AdminMessagesSection({ mailboxes }: { mailboxes: MailboxType[] }) { + const qc = useQueryClient() + const [query, setQuery] = React.useState("") + const [mailboxId, setMailboxId] = React.useState("all") + const [folder, setFolder] = React.useState("all") + const [selectedId, setSelectedId] = React.useState(null) + const messages = useQuery({ + queryKey: ["admin", "messages", mailboxId, folder, query], + queryFn: () => api.adminMessages({ + mailboxId: mailboxId === "all" ? "" : mailboxId, + folder: folder === "all" ? "" : folder, + q: query, + }), + }) + const detail = useQuery({ queryKey: ["admin", "message", selectedId], queryFn: () => api.adminMessage(selectedId!), enabled: !!selectedId }) + const items = messages.data?.items || [] + return ( + + +
+ 全部邮件 + +
+
+ +
+
+ + setQuery(event.target.value)} placeholder="搜索主题、发件人、收件人、邮箱" className="pl-9" /> +
+ + +
+ + + + 邮件 + 邮箱 + 发件人 + 收件人 + 文件夹 + 时间 + + + + + {items.map((message) => ( + + +
{message.subject}
+
{message.snippet}
+
+ +
{message.mailboxAddress || message.recipientAddress || "-"}
+ {message.ownerEmail &&
{message.ownerEmail}
} +
+ {message.from} + {message.recipientAddress || message.to.join(", ")} + {folderName(message.folder)} + {formatDate(message.receivedAt)} + +
+ ))} +
+
+ {messages.isLoading && } + {!messages.isLoading && items.length === 0 && } +
+ { if (!open) setSelectedId(null) }} /> +
+ ) +} + +function SystemSettingsSection({ settings }: { settings?: SystemSettings }) { + const qc = useQueryClient() + const { toast } = useToast() + const templates = useQuery({ queryKey: ["admin", "mail-templates"], queryFn: api.mailTemplates }) + const [settingsTab, setSettingsTab] = React.useState<"base" | "smtp" | "storage" | "mail" | "templates" | "security">("base") + const [smtpRequireTls, setSmtpRequireTls] = React.useState(false) + const [allowInsecureHttp, setAllowInsecureHttp] = React.useState(true) + const [openRegistration, setOpenRegistration] = React.useState(false) + const [twoFactorEnabled, setTwoFactorEnabled] = React.useState(false) + const [turnstileEnabled, setTurnstileEnabled] = React.useState(false) + const [catchAllEnabled, setCatchAllEnabled] = React.useState(false) + const [mailAutoRefresh, setMailAutoRefresh] = React.useState(true) + React.useEffect(() => { + if (!settings) return + setSmtpRequireTls(settings.smtpRequireTls) + setAllowInsecureHttp(settings.allowInsecureHttp) + setOpenRegistration(settings.openRegistration) + setTwoFactorEnabled(settings.twoFactorEnabled) + setTurnstileEnabled(settings.turnstileEnabled) + setCatchAllEnabled(settings.catchAllEnabled) + setMailAutoRefresh(settings.mailAutoRefresh) + }, [settings]) + const save = useMutation({ + mutationFn: (form: FormData) => api.updateSystemSettings({ + publicHostname: fieldValue(form, "publicHostname", settings?.publicHostname || ""), + publicBaseUrl: fieldValue(form, "publicBaseUrl", settings?.publicBaseUrl || ""), + smtpHost: fieldValue(form, "smtpHost", settings?.smtpHost || ""), + smtpPort: fieldValue(form, "smtpPort", settings?.smtpPort || "25"), + smtpUsername: fieldValue(form, "smtpUsername", settings?.smtpUsername || ""), + smtpPassword: fieldValue(form, "smtpPassword", ""), + smtpRequireTls, + maildirRoot: fieldValue(form, "maildirRoot", settings?.maildirRoot || ""), + maildirScanSeconds: fieldNumber(form, "maildirScanSeconds", settings?.maildirScanSeconds || 30), + sessionTtlHours: fieldNumber(form, "sessionTtlHours", settings?.sessionTtlHours || 168), + allowInsecureHttp, + openRegistration, + twoFactorEnabled, + turnstileEnabled, + turnstileSiteKey: fieldValue(form, "turnstileSiteKey", settings?.turnstileSiteKey || ""), + turnstileSecretKey: fieldValue(form, "turnstileSecretKey", ""), + catchAllEnabled, + mailAutoRefresh, + mailRefreshSeconds: fieldNumber(form, "mailRefreshSeconds", settings?.mailRefreshSeconds || 30), + }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["admin", "settings"] }) + qc.invalidateQueries({ queryKey: ["dns-records"] }) + qc.invalidateQueries({ queryKey: ["public-settings"] }) + toast({ title: "系统设置已保存" }) + }, + onError: (e) => toast({ title: "保存失败", description: e.message }), + }) + const formKey = settings ? [ + settings.publicHostname, + settings.publicBaseUrl, + settings.smtpHost, + settings.smtpPort, + settings.smtpUsername, + settings.smtpPasswordSet, + settings.smtpRequireTls, + settings.maildirRoot, + settings.maildirScanSeconds, + settings.sessionTtlHours, + settings.allowInsecureHttp, + settings.openRegistration, + settings.twoFactorEnabled, + settings.turnstileEnabled, + settings.turnstileSiteKey, + settings.turnstileSecretSet, + settings.catchAllEnabled, + settings.mailAutoRefresh, + settings.mailRefreshSeconds, + ].join("|") : "loading" + const tabs: { key: typeof settingsTab; label: string }[] = [ + { key: "base", label: "基础" }, + { key: "smtp", label: "SMTP" }, + { key: "storage", label: "存储" }, + { key: "mail", label: "邮件" }, + { key: "templates", label: "模板" }, + { key: "security", label: "安全" }, + ] + return ( +
{ event.preventDefault(); save.mutate(new FormData(event.currentTarget)) }} className="space-y-6"> +
+ {tabs.map((tab) => ( + + ))} +
+ + {settingsTab === "base" && + 基础设置 + + + + + + + + } + + {settingsTab === "smtp" && + +
+ SMTP 设置 + +
+
+ + + + + + + +
} + + {settingsTab === "storage" && + 存储设置 + + + + } + + {settingsTab === "mail" && + 邮件设置 + + + + + {mailAutoRefresh && ( +
+ +
+ )} +
+
} + + {settingsTab === "templates" && } + + {settingsTab === "security" && + 安全设置 + + + + + + + {turnstileEnabled && ( +
+ + +
+ )} +
+
} + +
+ +
+ + ) +} + +function TestSMTPDialog({ disabled }: { disabled?: boolean }) { const { toast } = useToast() const [open, setOpen] = React.useState(false) - const mut = useMutation({ - mutationFn: (form: FormData) => api.createDomain(String(form.get("name"))), - onSuccess: () => { qc.invalidateQueries({ queryKey: ["admin", "domains"] }); setOpen(false); toast({ title: "域名已创建" }) }, - onError: (e) => toast({ title: "创建失败", description: e.message }), + const test = useMutation({ + mutationFn: (form: FormData) => api.testSmtp(String(form.get("to") || "")), + onSuccess: () => { + setOpen(false) + toast({ title: "测试邮件已发送" }) + }, + onError: (e) => toast({ title: "发送失败", description: e.message }), }) return ( - + - - 添加域名 - -
{ e.preventDefault(); mut.mutate(new FormData(e.currentTarget)) }}> -
- + SMTP 测试发送 + { event.preventDefault(); test.mutate(new FormData(event.currentTarget)) }}> + +
) } -function CreateMailboxDialog({ domains }: { domains: Domain[] }) { +function MailTemplatesPanel({ templates, loading }: { templates: MailTemplate[]; loading: boolean }) { + const qc = useQueryClient() + const { toast } = useToast() + const [selectedKey, setSelectedKey] = React.useState("") + const selected = templates.find((template) => template.key === selectedKey) || templates[0] + const [subject, setSubject] = React.useState("") + const [bodyText, setBodyText] = React.useState("") + const [bodyHtml, setBodyHtml] = React.useState("") + React.useEffect(() => { + if (!selectedKey && templates[0]) setSelectedKey(templates[0].key) + }, [selectedKey, templates]) + React.useEffect(() => { + if (!selected) return + setSubject(selected.subject) + setBodyText(selected.bodyText) + setBodyHtml(selected.bodyHtml) + }, [selected]) + const save = useMutation({ + mutationFn: () => api.updateMailTemplate(selected!.key, { subject, bodyText, bodyHtml }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["admin", "mail-templates"] }) + toast({ title: "模板已保存" }) + }, + onError: (e) => toast({ title: "保存失败", description: e.message }), + }) + const reset = useMutation({ + mutationFn: () => api.resetMailTemplate(selected!.key), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["admin", "mail-templates"] }) + toast({ title: "模板已恢复" }) + }, + onError: (e) => toast({ title: "恢复失败", description: e.message }), + }) + if (loading) return + if (!selected) return + return ( + + 邮件模板 + + [template.key, template.name])} /> +
+ + setSubject(event.target.value)} /> +
+
+
+ +