feat(admin): 扩展后台管理与登录安全能力。

- 新增用户、域名、邮箱、别名、邮件、系统设置与模板的管理接口和页面。
- 支持双因素认证、Turnstile、人机验证与管理员 SMTP 测试。
- 增加无人收件/未注册邮件归档、Maildir 同步和数据库迁移支持。
- 更新前端导航、个人中心 2FA 配置以及相关部署示例。
This commit is contained in:
LanQin
2026-06-15 00:37:43 +08:00
parent 3ef8caa319
commit 8a042ae3dc
27 changed files with 3558 additions and 384 deletions
+611 -2
View File
@@ -5,12 +5,252 @@ import (
"database/sql" "database/sql"
"errors" "errors"
"net/http" "net/http"
"strconv"
"strings" "strings"
"time" "time"
"github.com/go-chi/chi/v5"
"golang.org/x/crypto/bcrypt" "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) { 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`) 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 { if err != nil {
@@ -55,6 +295,63 @@ func (a *App) handleCreateDomain(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusCreated, d) 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) { 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 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`) 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) 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) { 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`) rows, err := a.db.QueryContext(r.Context(), `SELECT id,domain_id,source,destination,enabled,created_at FROM aliases ORDER BY source`)
if err != nil { 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}) 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) { func (a *App) handleCreateAlias(w http.ResponseWriter, r *http.Request) {
var req struct { var req struct {
DomainID string `json:"domainId"` 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)}) 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) { 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) 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 var d Domain
@@ -273,11 +821,72 @@ func (a *App) domainByID(ctx context.Context, id string) (*Domain, error) {
return &d, nil 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) { 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 m Mailbox
var created string 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 return nil, err
} }
m.CreatedAt = parseTime(created) m.CreatedAt = parseTime(created)
+203 -7
View File
@@ -55,6 +55,14 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
db.Close() db.Close()
return nil, err 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 { if err := a.seed(context.Background()); err != nil {
db.Close() db.Close()
return nil, err return nil, err
@@ -99,6 +107,8 @@ func (a *App) migrate(ctx context.Context) error {
display_name TEXT NOT NULL, display_name TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('admin','user')), role TEXT NOT NULL CHECK(role IN ('admin','user')),
password_hash TEXT NOT NULL, 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, disabled INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_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, expires_at TEXT NOT NULL,
created_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 ( `CREATE TABLE IF NOT EXISTS domains (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE, name TEXT NOT NULL UNIQUE,
@@ -155,8 +185,9 @@ func (a *App) migrate(ctx context.Context) error {
)`, )`,
`CREATE TABLE IF NOT EXISTS messages ( `CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
mailbox_id TEXT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE, mailbox_id TEXT REFERENCES mailboxes(id) ON DELETE CASCADE,
folder_id TEXT NOT NULL REFERENCES folders(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_uid TEXT NOT NULL,
message_id TEXT NOT NULL, message_id TEXT NOT NULL,
subject 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_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 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 ( `CREATE TABLE IF NOT EXISTS attachments (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE, 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 return err
} }
} }
if err := a.migrateMessagesForUnregistered(ctx); err != nil {
return err
}
if err := a.migrateUsersForTwoFactor(ctx); err != nil {
return err
}
return nil 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, &notnull, &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, &notnull, &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 { func (a *App) seed(ctx context.Context) error {
var count int var count int
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&count); err != nil { 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 return err
} }
now := a.now().UTC() now := a.now().UTC()
subject := "欢迎使用 LanQin Email"
bodyText := "你的自建邮箱 Webmail 已经初始化完成。请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。"
bodyHTML := "<p>你的自建邮箱 Webmail 已经初始化完成。</p><p>请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。</p>"
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{ msg := storedMessage{
MailboxID: mailboxID, MailboxID: mailboxID,
FolderID: folderID, FolderID: folderID,
MessageUID: newID("uid"), MessageUID: newID("uid"),
MessageID: fmt.Sprintf("<%s@lanqin.local>", newID("msg")), MessageID: fmt.Sprintf("<%s@lanqin.local>", newID("msg")),
Subject: "欢迎使用 LanQin Email", Subject: subject,
From: "system@lanqin.local", From: "system@lanqin.local",
To: []string{a.cfg.AdminEmail}, To: []string{a.cfg.AdminEmail},
SentAt: now, SentAt: now,
ReceivedAt: now, ReceivedAt: now,
Snippet: "你的自建邮箱 Webmail 已经初始化完成。", Snippet: snippetFrom(bodyText, bodyHTML),
BodyText: "你的自建邮箱 Webmail 已经初始化完成。请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。", BodyText: bodyText,
BodyHTML: "<p>你的自建邮箱 Webmail 已经初始化完成。</p><p>请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。</p>", BodyHTML: bodyHTML,
IsRead: false, IsRead: false,
} }
_, err = a.insertMessage(ctx, msg, nil) _, err = a.insertMessage(ctx, msg, nil)
+239
View File
@@ -1,18 +1,21 @@
package app package app
import ( import (
"bufio"
"bytes" "bytes"
"context" "context"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"io" "io"
"log/slog" "log/slog"
"net"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"time"
) )
func newTestApp(t *testing.T) *App { func newTestApp(t *testing.T) *App {
@@ -38,6 +41,70 @@ func newTestApp(t *testing.T) *App {
return a 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 <CR><LF>.<CR><LF>\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 { type testClient struct {
t *testing.T t *testing.T
server *httptest.Server 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": "<p>hello {{to}} from {{from}}</p>",
}, &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) { func TestProfileAndPasswordUpdate(t *testing.T) {
a := newTestApp(t) a := newTestApp(t)
ts := httptest.NewServer(a.Router()) 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) { func TestDNSRecords(t *testing.T) {
a := newTestApp(t) a := newTestApp(t)
d, err := a.domainByID(context.Background(), mustDefaultDomainID(t, a)) d, err := a.domainByID(context.Background(), mustDefaultDomainID(t, a))
+16
View File
@@ -25,6 +25,14 @@ type Config struct {
MaildirRoot string MaildirRoot string
MaildirScanSeconds int MaildirScanSeconds int
AllowInsecureHTTP bool AllowInsecureHTTP bool
OpenRegistration bool
TwoFactorEnabled bool
TurnstileEnabled bool
TurnstileSiteKey string
TurnstileSecretKey string
CatchAllEnabled bool
MailAutoRefresh bool
MailRefreshSeconds int
} }
func LoadConfig() Config { func LoadConfig() Config {
@@ -47,6 +55,14 @@ func LoadConfig() Config {
MaildirRoot: getenv("LANQIN_MAILDIR_ROOT", ""), MaildirRoot: getenv("LANQIN_MAILDIR_ROOT", ""),
MaildirScanSeconds: getenvInt("LANQIN_MAILDIR_SCAN_SECONDS", 30), MaildirScanSeconds: getenvInt("LANQIN_MAILDIR_SCAN_SECONDS", 30),
AllowInsecureHTTP: getenvBool("LANQIN_ALLOW_INSECURE_HTTP", true), 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),
} }
} }
+84 -6
View File
@@ -25,6 +25,7 @@ type AttachmentInput struct {
type storedMessage struct { type storedMessage struct {
MailboxID string MailboxID string
FolderID string FolderID string
RecipientAddr string
MessageUID string MessageUID string
MessageID string MessageID string
Subject string Subject string
@@ -225,12 +226,36 @@ func (a *App) handleMailSend(w http.ResponseWriter, r *http.Request) {
return 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(req.To, req.CC...)
localRecipients = append(localRecipients, req.BCC...) localRecipients = append(localRecipients, req.BCC...)
for _, rcpt := range localRecipients { for _, rcpt := range localRecipients {
rcptMailbox, err := a.mailboxByAddress(r.Context(), rcpt) rcptMailbox, err := a.mailboxByAddress(r.Context(), rcpt)
if err != nil { 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 continue
} }
inboxID, err := a.ensureFolder(r.Context(), rcptMailbox.ID, "Inbox") 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) 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) { func (a *App) handleMarkRead(w http.ResponseWriter, r *http.Request) {
msg, err := a.loadMessageForRequest(r, chi.URLParam(r, "id"), false) msg, err := a.loadMessageForRequest(r, chi.URLParam(r, "id"), false)
if err != nil { if err != nil {
@@ -369,6 +404,27 @@ func (a *App) handleAttachment(w http.ResponseWriter, r *http.Request) {
_, _ = io.Copy(w, f) _, _ = 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) { func (a *App) handleEvents(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache") 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) { 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 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 JOIN folders f ON f.id=m.folder_id WHERE m.id=?`, id) FROM messages m LEFT JOIN folders f ON f.id=m.folder_id WHERE m.id=?`, id)
msg, err := scanMessageFull(row, includeBody) msg, err := scanMessageFull(row, includeBody)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -466,8 +522,16 @@ func (a *App) insertMessage(ctx context.Context, msg storedMessage, attachments
size += int64(len(decoded)) 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) var mailboxID, folderID any
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) 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 { if err != nil {
return "", err return "", err
} }
@@ -541,6 +605,20 @@ func (a *App) deleteMessageFiles(ctx context.Context, messageID string) {
type messageSummaryScanner interface{ Scan(dest ...any) error } 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) { func scanMessageSummary(row messageSummaryScanner, folder string) (MailMessage, error) {
var msg MailMessage var msg MailMessage
var toJSON, ccJSON, bccJSON, sent, received string 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 toJSON, ccJSON, bccJSON, sent, received string
var read, starred, hasAtt int var read, starred, hasAtt int
var bodyText, bodyHTML string 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 { if err != nil {
return msg, err return msg, err
} }
+133 -1
View File
@@ -24,6 +24,8 @@ type maildirMailbox struct {
Address string Address string
LocalPart string LocalPart string
Domain string Domain string
Unregistered bool
RecipientDomain string
} }
type maildirFolder struct { type maildirFolder struct {
@@ -80,6 +82,14 @@ func (a *App) syncMaildirOnce(ctx context.Context) (int, error) {
} }
imported := 0 imported := 0
for _, mb := range mailboxes { 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) folders, err := a.maildirFolders(ctx, mb.ID)
if err != nil { if err != nil {
return imported, err return imported, err
@@ -135,7 +145,109 @@ func (a *App) maildirMailboxes(ctx context.Context) ([]maildirMailbox, error) {
} }
out = append(out, mb) 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) { 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 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) { func (a *App) parseMaildirMessage(raw []byte, fallbackTo string) (storedMessage, []AttachmentInput, error) {
m, err := netmail.ReadMessage(bytes.NewReader(raw)) m, err := netmail.ReadMessage(bytes.NewReader(raw))
if err != nil { if err != nil {
+11 -7
View File
@@ -122,20 +122,24 @@ func writeBase64(w io.Writer, data []byte) {
} }
func (a *App) sendSMTP(from string, recipients []string, mimeBytes []byte) error { 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)
var auth smtp.Auth
if a.cfg.SMTPUsername != "" {
auth = smtp.PlainAuth("", a.cfg.SMTPUsername, a.cfg.SMTPPassword, a.cfg.SMTPHost)
} }
if !a.cfg.SMTPRequireTLS {
func sendSMTPWithConfig(cfg Config, from string, recipients []string, mimeBytes []byte) error {
addr := net.JoinHostPort(cfg.SMTPHost, cfg.SMTPPort)
var auth smtp.Auth
if cfg.SMTPUsername != "" {
auth = smtp.PlainAuth("", cfg.SMTPUsername, cfg.SMTPPassword, cfg.SMTPHost)
}
if !cfg.SMTPRequireTLS {
return smtp.SendMail(addr, auth, from, recipients, mimeBytes) 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 { if err != nil {
return err return err
} }
defer conn.Close() defer conn.Close()
client, err := smtp.NewClient(conn, a.cfg.SMTPHost) client, err := smtp.NewClient(conn, cfg.SMTPHost)
if err != nil { if err != nil {
return err return err
} }
+77 -24
View File
@@ -30,11 +30,15 @@ func (a *App) Router() http.Handler {
}) })
r.Route("/api", func(r chi.Router) { r.Route("/api", func(r chi.Router) {
r.Get("/public/settings", a.handlePublicSettings)
r.Post("/auth/login", a.handleLogin) r.Post("/auth/login", a.handleLogin)
r.Post("/auth/logout", a.handleLogout) r.Post("/auth/logout", a.handleLogout)
r.With(a.requireAuth).Get("/me", a.handleMe) r.With(a.requireAuth).Get("/me", a.handleMe)
r.With(a.requireAuth).Post("/me/profile", a.handleUpdateProfile) r.With(a.requireAuth).Post("/me/profile", a.handleUpdateProfile)
r.With(a.requireAuth).Post("/me/password", a.handleChangePassword) 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).Get("/me/contacts", a.handleListContacts)
r.With(a.requireAuth).Post("/me/contacts", a.handleCreateContact) r.With(a.requireAuth).Post("/me/contacts", a.handleCreateContact)
r.With(a.requireAuth).Delete("/me/contacts/{id}", a.handleDeleteContact) r.With(a.requireAuth).Delete("/me/contacts/{id}", a.handleDeleteContact)
@@ -65,12 +69,33 @@ func (a *App) Router() http.Handler {
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(a.requireAuth) r.Use(a.requireAuth)
r.Use(a.requireAdmin) 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.Get("/admin/domains", a.handleListDomains)
r.Post("/admin/domains", a.handleCreateDomain) 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.Get("/admin/mailboxes", a.handleListMailboxes)
r.Post("/admin/mailboxes", a.handleCreateMailbox) 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.Get("/admin/aliases", a.handleListAliases)
r.Post("/admin/aliases", a.handleCreateAlias) 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.Get("/admin/domains/{id}/dns-records", a.handleDNSRecords)
r.Post("/admin/domains/{id}/check-dns", a.handleDNSCheck) r.Post("/admin/domains/{id}/check-dns", a.handleDNSCheck)
}) })
@@ -101,11 +126,42 @@ func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
var req struct { var req struct {
Email string `json:"email"` Email string `json:"email"`
Password string `json:"password"` Password string `json:"password"`
TurnstileToken string `json:"turnstileToken"`
ChallengeToken string `json:"challengeToken"`
TwoFactorCode string `json:"twoFactorCode"`
} }
if err := decodeJSON(r, &req); err != nil { if err := decodeJSON(r, &req); err != nil {
badRequest(w, err) badRequest(w, err)
return 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) email := normalizeEmail(req.Email)
user, passwordHash, err := a.userByEmail(r.Context(), email) user, passwordHash, err := a.userByEmail(r.Context(), email)
if err != nil || user.Disabled { 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") respondError(w, http.StatusUnauthorized, "invalid email or password")
return return
} }
token := randomToken() if a.cfg.TwoFactorEnabled && user.TwoFactorEnabled {
sessionID := newID("ses") challengeToken, err := a.createLoginChallenge(r.Context(), user.ID)
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 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") respondError(w, http.StatusInternalServerError, "failed to create session")
return 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}) 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 == "" { if err != nil || cookie.Value == "" {
return nil, errors.New("no session") 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 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)) WHERE s.token_hash=? AND s.expires_at > ?`, hashToken(cookie.Value), a.now().UTC().Format(time.RFC3339Nano))
var u User var u User
var disabled int var disabled, twoFactorEnabled int
var created string 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 return nil, err
} }
u.Disabled = intBool(disabled) u.Disabled = intBool(disabled)
u.TwoFactorEnabled = intBool(twoFactorEnabled)
u.CreatedAt = parseTime(created) u.CreatedAt = parseTime(created)
if u.Disabled { if u.Disabled {
return nil, errors.New("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) { 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 u User
var passwordHash string var passwordHash string
var disabled int var disabled, twoFactorEnabled int
var created string 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) { if errors.Is(err, sql.ErrNoRows) {
return nil, "", errNotFound return nil, "", errNotFound
} }
return nil, "", err return nil, "", err
} }
u.Disabled = intBool(disabled) u.Disabled = intBool(disabled)
u.TwoFactorEnabled = intBool(twoFactorEnabled)
u.CreatedAt = parseTime(created) u.CreatedAt = parseTime(created)
return &u, passwordHash, nil return &u, passwordHash, nil
} }
func (a *App) userByID(ctx context.Context, id string) (*User, error) { 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 u User
var disabled int var disabled, twoFactorEnabled int
var created string 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) { if errors.Is(err, sql.ErrNoRows) {
return nil, errNotFound return nil, errNotFound
} }
return nil, err return nil, err
} }
u.Disabled = intBool(disabled) u.Disabled = intBool(disabled)
u.TwoFactorEnabled = intBool(twoFactorEnabled)
u.CreatedAt = parseTime(created) u.CreatedAt = parseTime(created)
return &u, nil return &u, nil
} }
+346
View File
@@ -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 := "<p>这是一封 SMTP 测试邮件。</p>"
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
}
+210
View File
@@ -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: "<p>你的自建邮箱 Webmail 已经初始化完成。</p><p>请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。</p>",
UpdatedAt: now,
},
{
Key: smtpTestTemplateKey,
Name: "SMTP 测试",
Subject: "LanQin Email SMTP 测试",
BodyText: "这是一封 SMTP 测试邮件。\n\n发件人:{{from}}\n收件人:{{to}}\n时间:{{time}}\n主机:{{publicHostname}}",
BodyHTML: "<p>这是一封 SMTP 测试邮件。</p><p>发件人:{{from}}<br>收件人:{{to}}<br>时间:{{time}}<br>主机:{{publicHostname}}</p>",
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 = "<p>" + htmlEscape(bodyText) + "</p>"
}
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),
}
}
+61
View File
@@ -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)
}
+279
View File
@@ -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})
}
+10
View File
@@ -8,9 +8,16 @@ type User struct {
DisplayName string `json:"displayName"` DisplayName string `json:"displayName"`
Role string `json:"role"` Role string `json:"role"`
Disabled bool `json:"disabled"` Disabled bool `json:"disabled"`
TwoFactorEnabled bool `json:"twoFactorEnabled"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
} }
type AdminUser struct {
User
MailboxCount int `json:"mailboxCount"`
Mailboxes []string `json:"mailboxes"`
}
type Domain struct { type Domain struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -55,6 +62,9 @@ type MailFolder struct {
type MailMessage struct { type MailMessage struct {
ID string `json:"id"` ID string `json:"id"`
MailboxID string `json:"mailboxId,omitempty"` MailboxID string `json:"mailboxId,omitempty"`
MailboxAddress string `json:"mailboxAddress,omitempty"`
OwnerEmail string `json:"ownerEmail,omitempty"`
RecipientAddr string `json:"recipientAddress,omitempty"`
FolderID string `json:"folderId"` FolderID string `json:"folderId"`
Folder string `json:"folder"` Folder string `json:"folder"`
MessageUID string `json:"messageUid"` MessageUID string `json:"messageUid"`
+151
View File
@@ -16,6 +16,7 @@
"@radix-ui/react-select": "^2.3.0", "@radix-ui/react-select": "^2.3.0",
"@radix-ui/react-separator": "^1.1.9", "@radix-ui/react-separator": "^1.1.9",
"@radix-ui/react-slot": "^1.2.5", "@radix-ui/react-slot": "^1.2.5",
"@radix-ui/react-switch": "^1.3.0",
"@radix-ui/react-toast": "^1.2.2", "@radix-ui/react-toast": "^1.2.2",
"@radix-ui/react-tooltip": "^1.2.9", "@radix-ui/react-tooltip": "^1.2.9",
"@tanstack/react-query": "5.59.16", "@tanstack/react-query": "5.59.16",
@@ -23,6 +24,7 @@
"clsx": "2.1.1", "clsx": "2.1.1",
"dompurify": "3.1.7", "dompurify": "3.1.7",
"lucide-react": "^0.468.0", "lucide-react": "^0.468.0",
"qrcode.react": "^4.2.0",
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-resizable-panels": "^2.1.7", "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": { "node_modules/@radix-ui/react-toast": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmmirror.com/@radix-ui/react-toast/-/react-toast-1.2.2.tgz", "resolved": "https://registry.npmmirror.com/@radix-ui/react-toast/-/react-toast-1.2.2.tgz",
@@ -4656,6 +4798,15 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz",
+2
View File
@@ -19,6 +19,7 @@
"@radix-ui/react-select": "^2.3.0", "@radix-ui/react-select": "^2.3.0",
"@radix-ui/react-separator": "^1.1.9", "@radix-ui/react-separator": "^1.1.9",
"@radix-ui/react-slot": "^1.2.5", "@radix-ui/react-slot": "^1.2.5",
"@radix-ui/react-switch": "^1.3.0",
"@radix-ui/react-toast": "^1.2.2", "@radix-ui/react-toast": "^1.2.2",
"@radix-ui/react-tooltip": "^1.2.9", "@radix-ui/react-tooltip": "^1.2.9",
"@tanstack/react-query": "5.59.16", "@tanstack/react-query": "5.59.16",
@@ -26,6 +27,7 @@
"clsx": "2.1.1", "clsx": "2.1.1",
"dompurify": "3.1.7", "dompurify": "3.1.7",
"lucide-react": "^0.468.0", "lucide-react": "^0.468.0",
"qrcode.react": "^4.2.0",
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-resizable-panels": "^2.1.7", "react-resizable-panels": "^2.1.7",
+25 -18
View File
@@ -1,6 +1,6 @@
import * as React from "react" import * as React from "react"
import { Navigate, Outlet, Link, useLocation, useNavigate } from "react-router-dom" 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 { useQueryClient } from "@tanstack/react-query"
import { api } from "@/lib/api" import { api } from "@/lib/api"
import { useMe } from "@/hooks/use-me" import { useMe } from "@/hooks/use-me"
@@ -23,6 +23,16 @@ import {
SidebarTrigger, SidebarTrigger,
} from "@/components/ui/sidebar" } from "@/components/ui/sidebar"
const adminSections = [
{ key: "overview", label: "概览", icon: <BarChart3 /> },
{ key: "users", label: "用户", icon: <Users /> },
{ key: "domains", label: "域名", icon: <Globe2 /> },
{ key: "mailboxes", label: "邮箱账号", icon: <Mailbox /> },
{ key: "aliases", label: "别名转发", icon: <Copy /> },
{ key: "messages", label: "全部邮件", icon: <Inbox /> },
{ key: "settings", label: "系统设置", icon: <Settings /> },
]
export function ProtectedLayout() { export function ProtectedLayout() {
const me = useMe() const me = useMe()
const location = useLocation() const location = useLocation()
@@ -35,6 +45,8 @@ export function ProtectedLayout() {
const user = me.data.user const user = me.data.user
const isMailRoute = location.pathname.startsWith("/mail") const isMailRoute = location.pathname.startsWith("/mail")
const isProfileRoute = location.pathname.startsWith("/profile") const isProfileRoute = location.pathname.startsWith("/profile")
const isAdminRoute = location.pathname.startsWith("/admin")
const adminSection = new URLSearchParams(location.search).get("section") || "overview"
async function logout() { async function logout() {
await api.logout().catch(() => undefined) await api.logout().catch(() => undefined)
@@ -66,14 +78,24 @@ export function ProtectedLayout() {
</SidebarMenu> </SidebarMenu>
</SidebarHeader> </SidebarHeader>
<SidebarContent> <SidebarContent>
{user.role === "admin" && isAdminRoute && (
<SidebarGroup> <SidebarGroup>
<SidebarGroupContent> <SidebarGroupContent>
<SidebarMenu> <SidebarMenu>
<NavItem to="/mail" icon={<Inbox />} label="Webmail" /> {adminSections.map((item) => (
{user.role === "admin" && <NavItem to="/admin" icon={<Settings />} label="系统管理" />} <SidebarMenuItem key={item.key}>
<SidebarMenuButton asChild isActive={adminSection === item.key} tooltip={item.label}>
<Link to={`/admin?section=${item.key}`}>
{item.icon}
<span>{item.label}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu> </SidebarMenu>
</SidebarGroupContent> </SidebarGroupContent>
</SidebarGroup> </SidebarGroup>
)}
</SidebarContent> </SidebarContent>
<SidebarFooter> <SidebarFooter>
<SidebarMenu> <SidebarMenu>
@@ -115,18 +137,3 @@ export function ProtectedLayout() {
</SidebarProvider> </SidebarProvider>
) )
} }
function NavItem({ to, icon, label }: { to: string; icon: React.ReactNode; label: string }) {
const location = useLocation()
const active = location.pathname.startsWith(to)
return (
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={active} tooltip={label}>
<Link to={to}>
{icon}
<span>{label}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
)
}
+27
View File
@@ -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<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }
+13
View File
@@ -149,3 +149,16 @@ html.theme-transition::before {
background-color: hsl(var(--primary) / 0.15); background-color: hsl(var(--primary) / 0.15);
color: hsl(var(--foreground)); 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;
}
+67 -4
View File
@@ -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 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 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 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 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 Attachment = { id: string; messageId: string; filename: string; contentType: string; sizeBytes: number; createdAt: string }
export type MailMessage = { 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 DNSRecord = { type: string; name: string; value: string; ttl: number }
export type DNSCheckResult = { domain: string; status: string; checks: Record<string, { ok: boolean; message: string; found?: string[] }> } export type DNSCheckResult = { domain: string; status: string; checks: Record<string, { ok: boolean; message: string; found?: string[] }> }
@@ -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 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 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 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<SystemSettings, "smtpPasswordSet" | "turnstileSecretSet"> & { 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<T>(path: string, init: RequestInit = {}): Promise<T> { async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const res = await fetch(path, { credentials: "include", headers: { "Content-Type": "application/json", ...(init.headers || {}) }, ...init }) const res = await fetch(path, { credentials: "include", headers: { "Content-Type": "application/json", ...(init.headers || {}) }, ...init })
@@ -27,11 +55,15 @@ async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
} }
export const api = { export const api = {
login: (email: string, password: string) => request<{ user: User }>("/api/auth/login", { method: "POST", body: JSON.stringify({ email, password }) }), publicSettings: () => request<PublicSettings>("/api/public/settings"),
login: (payload: LoginPayload) => request<LoginResponse>("/api/auth/login", { method: "POST", body: JSON.stringify(payload) }),
logout: () => request<{ ok: boolean }>("/api/auth/logout", { method: "POST" }), logout: () => request<{ ok: boolean }>("/api/auth/logout", { method: "POST" }),
me: () => request<{ user: User }>("/api/me"), me: () => request<{ user: User }>("/api/me"),
updateProfile: (payload: { displayName: string }) => request<{ user: User }>("/api/me/profile", { method: "POST", body: JSON.stringify(payload) }), 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) }), 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<ListResponse<Contact>>("/api/me/contacts"), contacts: () => request<ListResponse<Contact>>("/api/me/contacts"),
createContact: (payload: { name: string; email: string; note: string }) => request<Contact>("/api/me/contacts", { method: "POST", body: JSON.stringify(payload) }), createContact: (payload: { name: string; email: string; note: string }) => request<Contact>("/api/me/contacts", { method: "POST", body: JSON.stringify(payload) }),
deleteContact: (id: string) => request<{ ok: boolean }>(`/api/me/contacts/${id}`, { method: "DELETE" }), deleteContact: (id: string) => request<{ ok: boolean }>(`/api/me/contacts/${id}`, { method: "DELETE" }),
@@ -43,12 +75,40 @@ export const api = {
deleteBlockedSender: (id: string) => request<{ ok: boolean }>(`/api/me/blocked-senders/${id}`, { method: "DELETE" }), deleteBlockedSender: (id: string) => request<{ ok: boolean }>(`/api/me/blocked-senders/${id}`, { method: "DELETE" }),
mailStats: (mailboxId?: string) => request<MailStats>(`/api/me/stats${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`), mailStats: (mailboxId?: string) => request<MailStats>(`/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) }), 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<AdminOverview>("/api/admin/overview"),
users: () => request<ListResponse<AdminUser>>("/api/admin/users"),
createUser: (payload: { email: string; displayName: string; role: "admin" | "user"; password: string; disabled: boolean }) => request<AdminUser>("/api/admin/users", { method: "POST", body: JSON.stringify(payload) }),
updateUser: (id: string, payload: { displayName: string; role: "admin" | "user"; disabled: boolean }) => request<AdminUser>(`/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<ListResponse<Domain>>("/api/admin/domains"), domains: () => request<ListResponse<Domain>>("/api/admin/domains"),
createDomain: (name: string) => request<Domain>("/api/admin/domains", { method: "POST", body: JSON.stringify({ name }) }), createDomain: (name: string) => request<Domain>("/api/admin/domains", { method: "POST", body: JSON.stringify({ name }) }),
updateDomain: (id: string, payload: { status: string }) => request<Domain>(`/api/admin/domains/${id}`, { method: "POST", body: JSON.stringify(payload) }),
deleteDomain: (id: string) => request<{ ok: boolean }>(`/api/admin/domains/${id}`, { method: "DELETE" }),
mailboxes: () => request<ListResponse<Mailbox>>("/api/admin/mailboxes"), mailboxes: () => request<ListResponse<Mailbox>>("/api/admin/mailboxes"),
createMailbox: (payload: { domainId: string; localPart: string; displayName: string; password: string; quotaMb: number; role: "admin" | "user"; ownerEmail?: string }) => request<Mailbox>("/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<Mailbox>("/api/admin/mailboxes", { method: "POST", body: JSON.stringify(payload) }),
updateMailbox: (id: string, payload: { userId: string; displayName: string; quotaMb: number; status: string }) => request<Mailbox>(`/api/admin/mailboxes/${id}`, { method: "POST", body: JSON.stringify(payload) }),
deleteMailbox: (id: string) => request<{ ok: boolean }>(`/api/admin/mailboxes/${id}`, { method: "DELETE" }),
aliases: () => request<ListResponse<Alias>>("/api/admin/aliases"), aliases: () => request<ListResponse<Alias>>("/api/admin/aliases"),
createAlias: (payload: { domainId: string; source: string; destination: string; enabled: boolean }) => request<Alias>("/api/admin/aliases", { method: "POST", body: JSON.stringify(payload) }), createAlias: (payload: { domainId: string; source: string; destination: string; enabled: boolean }) => request<Alias>("/api/admin/aliases", { method: "POST", body: JSON.stringify(payload) }),
updateAlias: (id: string, payload: { source: string; destination: string; enabled: boolean }) => request<Alias>(`/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<ListResponse<MailMessage>>(`/api/admin/messages${suffix ? `?${suffix}` : ""}`)
},
adminMessage: (id: string) => request<MailMessage>(`/api/admin/messages/${id}`),
systemSettings: () => request<SystemSettings>("/api/admin/settings"),
updateSystemSettings: (payload: SystemSettingsPayload) => request<SystemSettings>("/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<ListResponse<MailTemplate>>("/api/admin/mail-templates"),
updateMailTemplate: (key: string, payload: { subject: string; bodyText: string; bodyHtml: string }) => request<MailTemplate>(`/api/admin/mail-templates/${encodeURIComponent(key)}`, { method: "POST", body: JSON.stringify(payload) }),
resetMailTemplate: (key: string) => request<MailTemplate>(`/api/admin/mail-templates/${encodeURIComponent(key)}/reset`, { method: "POST" }),
dnsRecords: (domainId: string) => request<{ items: DNSRecord[] }>(`/api/admin/domains/${domainId}/dns-records`), dnsRecords: (domainId: string) => request<{ items: DNSRecord[] }>(`/api/admin/domains/${domainId}/dns-records`),
checkDns: (domainId: string) => request<DNSCheckResult>(`/api/admin/domains/${domainId}/check-dns`, { method: "POST" }), checkDns: (domainId: string) => request<DNSCheckResult>(`/api/admin/domains/${domainId}/check-dns`, { method: "POST" }),
myMailboxes: () => request<ListResponse<Mailbox>>("/api/mail/mailboxes"), myMailboxes: () => request<ListResponse<Mailbox>>("/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 }) }), 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" }), delete: (id: string) => request<{ ok: boolean }>(`/api/mail/messages/${id}`, { method: "DELETE" }),
} }
+10 -1
View File
@@ -8,6 +8,7 @@ import { LoginPage } from "@/pages/login"
import { MailPage } from "@/pages/mail" import { MailPage } from "@/pages/mail"
import { AdminPage } from "@/pages/admin" import { AdminPage } from "@/pages/admin"
import { ProfilePage } from "@/pages/profile" import { ProfilePage } from "@/pages/profile"
import { useMe } from "@/hooks/use-me"
import "./index.css" import "./index.css"
const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false, staleTime: 10_000 } } }) const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false, staleTime: 10_000 } } })
@@ -17,10 +18,18 @@ const router = createBrowserRouter([
{ index: true, element: <Navigate to="/mail" replace /> }, { index: true, element: <Navigate to="/mail" replace /> },
{ path: "mail", element: <MailPage /> }, { path: "mail", element: <MailPage /> },
{ path: "profile", element: <ProfilePage /> }, { path: "profile", element: <ProfilePage /> },
{ path: "admin", element: <AdminPage /> }, { path: "admin", element: <AdminOnly><AdminPage /></AdminOnly> },
] }, ] },
]) ])
function AdminOnly({ children }: { children: React.ReactNode }) {
const me = useMe()
if (me.isLoading) return null
if (!me.data?.user) return <Navigate to="/login" replace />
if (me.data.user.role !== "admin") return <Navigate to="/mail" replace />
return <>{children}</>
}
ReactDOM.createRoot(document.getElementById("root")!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
File diff suppressed because it is too large Load Diff
+81 -5
View File
@@ -1,5 +1,6 @@
import * as React from "react"
import { Navigate } from "react-router-dom" import { Navigate } from "react-router-dom"
import { useMutation, useQueryClient } from "@tanstack/react-query" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { api } from "@/lib/api" import { api } from "@/lib/api"
import { useMe } from "@/hooks/use-me" import { useMe } from "@/hooks/use-me"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@@ -11,11 +12,24 @@ export function LoginPage() {
const me = useMe() const me = useMe()
const qc = useQueryClient() const qc = useQueryClient()
const { toast } = useToast() const { toast } = useToast()
const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings })
const [turnstileToken, setTurnstileToken] = React.useState("")
const [challengeToken, setChallengeToken] = React.useState("")
const login = useMutation({ const login = useMutation({
mutationFn: (form: FormData) => api.login(String(form.get("email")), String(form.get("password"))), mutationFn: (form: FormData) => challengeToken
onSuccess: async () => { await qc.invalidateQueries({ queryKey: ["me"] }) }, ? api.login({ challengeToken, twoFactorCode: String(form.get("twoFactorCode") || "") })
: api.login({ email: String(form.get("email") || ""), password: String(form.get("password") || ""), turnstileToken }),
onSuccess: async (data) => {
if (data.twoFactorRequired && data.challengeToken) {
setChallengeToken(data.challengeToken)
toast({ title: "请输入双因素验证码" })
return
}
await qc.invalidateQueries({ queryKey: ["me"] })
},
onError: (e) => toast({ title: "登录失败", description: e.message }), onError: (e) => toast({ title: "登录失败", description: e.message }),
}) })
const turnstileRequired = !!publicSettings.data?.turnstileEnabled
if (me.data?.user) return <Navigate to="/mail" replace /> if (me.data?.user) return <Navigate to="/mail" replace />
return ( return (
<div className="grid min-h-screen place-items-center bg-background px-4"> <div className="grid min-h-screen place-items-center bg-background px-4">
@@ -23,7 +37,9 @@ export function LoginPage() {
<div className="mb-10 text-center"> <div className="mb-10 text-center">
<h1 className="text-3xl font-bold tracking-tight">LanQin Email</h1> <h1 className="text-3xl font-bold tracking-tight">LanQin Email</h1>
</div> </div>
<form className="space-y-5" onSubmit={(e) => { e.preventDefault(); login.mutate(new FormData(e.currentTarget)) }}> <form className="space-y-5" onSubmit={(e) => { e.preventDefault(); if (!challengeToken && turnstileRequired && !turnstileToken) { toast({ title: "请先完成人机验证" }); return }; login.mutate(new FormData(e.currentTarget)) }}>
{!challengeToken ? (
<>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="email"></Label> <Label htmlFor="email"></Label>
<Input id="email" name="email" type="email" defaultValue="admin@lanqin.local" required className="h-11 text-base" /> <Input id="email" name="email" type="email" defaultValue="admin@lanqin.local" required className="h-11 text-base" />
@@ -32,11 +48,71 @@ export function LoginPage() {
<Label htmlFor="password"></Label> <Label htmlFor="password"></Label>
<Input id="password" name="password" type="password" defaultValue="ChangeMe123!" required className="h-11 text-base" /> <Input id="password" name="password" type="password" defaultValue="ChangeMe123!" required className="h-11 text-base" />
</div> </div>
</>
) : (
<div className="space-y-2">
<Label htmlFor="twoFactorCode"></Label>
<Input id="twoFactorCode" name="twoFactorCode" inputMode="numeric" autoComplete="one-time-code" minLength={6} maxLength={6} required className="h-11 text-base" />
</div>
)}
{!challengeToken && turnstileRequired && (
<TurnstileBox siteKey={publicSettings.data?.turnstileSiteKey || ""} onToken={setTurnstileToken} />
)}
<Button className="h-11 w-full text-base" disabled={login.isPending}> <Button className="h-11 w-full text-base" disabled={login.isPending}>
{login.isPending ? "登录中..." : "登录"} {login.isPending ? "登录中..." : challengeToken ? "验证登录" : "登录"}
</Button> </Button>
{challengeToken && <Button type="button" variant="ghost" className="w-full" onClick={() => setChallengeToken("")}></Button>}
</form> </form>
</div> </div>
</div> </div>
) )
} }
declare global {
interface Window {
turnstile?: {
render: (container: HTMLElement, options: { sitekey: string; callback: (token: string) => void; "expired-callback": () => void; "error-callback": () => void }) => string
remove: (widgetId: string) => void
}
}
}
function TurnstileBox({ siteKey, onToken }: { siteKey: string; onToken: (token: string) => void }) {
const ref = React.useRef<HTMLDivElement | null>(null)
React.useEffect(() => {
if (!siteKey || !ref.current) return
let cancelled = false
let widgetId = ""
function render() {
if (cancelled || !ref.current || !window.turnstile) return
ref.current.innerHTML = ""
widgetId = window.turnstile.render(ref.current, {
sitekey: siteKey,
callback: onToken,
"expired-callback": () => onToken(""),
"error-callback": () => onToken(""),
})
}
if (window.turnstile) {
render()
} else {
const existing = document.querySelector('script[src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"]')
if (existing) {
existing.addEventListener("load", render, { once: true })
} else {
const script = document.createElement("script")
script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"
script.async = true
script.defer = true
script.addEventListener("load", render, { once: true })
document.head.appendChild(script)
}
}
return () => {
cancelled = true
onToken("")
if (widgetId && window.turnstile) window.turnstile.remove(widgetId)
}
}, [siteKey, onToken])
return <div className="flex justify-center rounded-md border p-2"><div ref={ref} /></div>
}
+11
View File
@@ -73,6 +73,7 @@ export function MailPage() {
const themeMountedRef = React.useRef(false) const themeMountedRef = React.useRef(false)
const mailboxList = useQuery({ queryKey: ["mailboxes", "mine"], queryFn: api.myMailboxes }) const mailboxList = useQuery({ queryKey: ["mailboxes", "mine"], queryFn: api.myMailboxes })
const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings })
const selectedMailbox = React.useMemo(() => mailboxList.data?.items.find((item) => item.id === selectedMailboxId), [mailboxList.data?.items, selectedMailboxId]) const selectedMailbox = React.useMemo(() => mailboxList.data?.items.find((item) => item.id === selectedMailboxId), [mailboxList.data?.items, selectedMailboxId])
const folders = useQuery({ queryKey: ["folders", selectedMailboxId], queryFn: () => api.folders(selectedMailboxId), enabled: !!selectedMailboxId }) const folders = useQuery({ queryKey: ["folders", selectedMailboxId], queryFn: () => api.folders(selectedMailboxId), enabled: !!selectedMailboxId })
const messages = useQuery({ queryKey: ["messages", selectedMailboxId, folder, query], queryFn: () => api.messages(folder, query, "", selectedMailboxId), enabled: !!selectedMailboxId }) const messages = useQuery({ queryKey: ["messages", selectedMailboxId, folder, query], queryFn: () => api.messages(folder, query, "", selectedMailboxId), enabled: !!selectedMailboxId })
@@ -125,6 +126,16 @@ export function MailPage() {
return () => events.close() return () => events.close()
}, [qc]) }, [qc])
React.useEffect(() => {
if (!publicSettings.data?.mailAutoRefresh) return
const interval = Math.max(publicSettings.data.mailRefreshMs || 30000, 5000)
const timer = window.setInterval(() => {
qc.invalidateQueries({ queryKey: ["messages"] })
qc.invalidateQueries({ queryKey: ["folders"] })
}, interval)
return () => window.clearInterval(timer)
}, [publicSettings.data?.mailAutoRefresh, publicSettings.data?.mailRefreshMs, qc])
const selected = detail.data const selected = detail.data
const allMessages = messages.data?.items || [] const allMessages = messages.data?.items || []
const visibleMessages = allMessages.filter((message) => { const visibleMessages = allMessages.filter((message) => {
+81 -3
View File
@@ -2,7 +2,8 @@ import * as React from "react"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import type { ImperativePanelHandle } from "react-resizable-panels" import type { ImperativePanelHandle } from "react-resizable-panels"
import { useNavigate, useSearchParams } from "react-router-dom" import { useNavigate, useSearchParams } from "react-router-dom"
import { ArrowLeft, BarChart3, Ban, Contact, Copy, LogOut, Mail, MailCheck, MailX, Moon, PanelLeftClose, PanelLeftOpen, RefreshCcw, Settings, ShieldCheck, SlidersHorizontal, Sun, Trash2 } from "lucide-react" import { ArrowLeft, BarChart3, Ban, Contact, Copy, KeyRound, LogOut, Mail, MailCheck, MailX, Moon, PanelLeftClose, PanelLeftOpen, RefreshCcw, Settings, ShieldCheck, SlidersHorizontal, Sun, Trash2 } from "lucide-react"
import { QRCodeSVG } from "qrcode.react"
import { api, Mailbox, MailStats } from "@/lib/api" import { api, Mailbox, MailStats } from "@/lib/api"
import { cn, formatBytes } from "@/lib/utils" import { cn, formatBytes } from "@/lib/utils"
import { applyTheme, getInitialTheme } from "@/lib/theme" import { applyTheme, getInitialTheme } from "@/lib/theme"
@@ -40,6 +41,7 @@ export function ProfilePage() {
const [params, setParams] = useSearchParams() const [params, setParams] = useSearchParams()
const { toast } = useToast() const { toast } = useToast()
const passwordFormRef = React.useRef<HTMLFormElement>(null) const passwordFormRef = React.useRef<HTMLFormElement>(null)
const twoFactorFormRef = React.useRef<HTMLFormElement>(null)
const sidebarPanelRef = React.useRef<ImperativePanelHandle>(null) const sidebarPanelRef = React.useRef<ImperativePanelHandle>(null)
const [sidebarCollapsed, setSidebarCollapsed] = React.useState(false) const [sidebarCollapsed, setSidebarCollapsed] = React.useState(false)
const [mailboxId, setMailboxId] = React.useState(() => localStorage.getItem("lanqin:selected-mailbox") || "") const [mailboxId, setMailboxId] = React.useState(() => localStorage.getItem("lanqin:selected-mailbox") || "")
@@ -73,6 +75,21 @@ export function ProfilePage() {
onSuccess: () => { passwordFormRef.current?.reset(); toast({ title: "密码已更新" }) }, onSuccess: () => { passwordFormRef.current?.reset(); toast({ title: "密码已更新" }) },
onError: (error) => toast({ title: "修改失败", description: error.message }), onError: (error) => toast({ title: "修改失败", description: error.message }),
}) })
const setupTwoFactor = useMutation({
mutationFn: api.setupTwoFactor,
onSuccess: () => toast({ title: "双因素密钥已生成" }),
onError: (error) => toast({ title: "生成失败", description: error.message }),
})
const enableTwoFactor = useMutation({
mutationFn: (form: FormData) => api.enableTwoFactor(String(form.get("code") || "")),
onSuccess: (data) => { qc.setQueryData(["me"], data); setupTwoFactor.reset(); twoFactorFormRef.current?.reset(); toast({ title: "双因素认证已启用" }) },
onError: (error) => toast({ title: "启用失败", description: error.message }),
})
const disableTwoFactor = useMutation({
mutationFn: (form: FormData) => api.disableTwoFactor(String(form.get("code") || "")),
onSuccess: (data) => { qc.setQueryData(["me"], data); twoFactorFormRef.current?.reset(); toast({ title: "双因素认证已关闭" }) },
onError: (error) => toast({ title: "关闭失败", description: error.message }),
})
const createContact = useMutation({ const createContact = useMutation({
mutationFn: (form: FormData) => api.createContact({ name: String(form.get("name") || ""), email: String(form.get("email") || ""), note: String(form.get("note") || "") }), mutationFn: (form: FormData) => api.createContact({ name: String(form.get("name") || ""), email: String(form.get("email") || ""), note: String(form.get("note") || "") }),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["contacts"] }); toast({ title: "联系人已保存" }) }, onSuccess: () => { qc.invalidateQueries({ queryKey: ["contacts"] }); toast({ title: "联系人已保存" }) },
@@ -158,11 +175,11 @@ export function ProfilePage() {
if (tab === "rules") return <RulesSection items={rules.data?.items || []} mailboxes={mailboxes.data?.items || []} mailboxId={ruleMailboxId} action={ruleAction} onMailboxChange={setRuleMailboxId} onActionChange={setRuleAction} onCreate={(form) => createRule.mutate(form)} onDelete={(id) => deleteRule.mutate(id)} pending={createRule.isPending} /> if (tab === "rules") return <RulesSection items={rules.data?.items || []} mailboxes={mailboxes.data?.items || []} mailboxId={ruleMailboxId} action={ruleAction} onMailboxChange={setRuleMailboxId} onActionChange={setRuleAction} onCreate={(form) => createRule.mutate(form)} onDelete={(id) => deleteRule.mutate(id)} pending={createRule.isPending} />
if (tab === "blocked") return <BlockedSection items={blocked.data?.items || []} mailboxes={mailboxes.data?.items || []} mailboxId={blockedMailboxId} spamCount={stats.data?.byFolder.find((f) => f.role === "spam")?.count || 0} onMailboxChange={setBlockedMailboxId} onCreate={(form) => createBlocked.mutate(form)} onDelete={(id) => deleteBlocked.mutate(id)} pending={createBlocked.isPending} /> if (tab === "blocked") return <BlockedSection items={blocked.data?.items || []} mailboxes={mailboxes.data?.items || []} mailboxId={blockedMailboxId} spamCount={stats.data?.byFolder.find((f) => f.role === "spam")?.count || 0} onMailboxChange={setBlockedMailboxId} onCreate={(form) => createBlocked.mutate(form)} onDelete={(id) => deleteBlocked.mutate(id)} pending={createBlocked.isPending} />
if (tab === "stats") return <StatsSection stats={stats.data} mailbox={selectedMailbox} onRefresh={() => stats.refetch()} /> if (tab === "stats") return <StatsSection stats={stats.data} mailbox={selectedMailbox} onRefresh={() => stats.refetch()} />
return <ProfileOverview user={user!} profile={profile} password={password} passwordFormRef={passwordFormRef} stats={stats.data} /> return <ProfileOverview user={user!} profile={profile} password={password} passwordFormRef={passwordFormRef} stats={stats.data} twoFactorFormRef={twoFactorFormRef} setupTwoFactor={setupTwoFactor} enableTwoFactor={enableTwoFactor} disableTwoFactor={disableTwoFactor} onCopy={copy} />
} }
} }
function ProfileOverview({ user, profile, password, passwordFormRef, stats }: { user: { email: string; displayName: string; role: string; disabled: boolean; createdAt: string }; profile: { mutate: (form: FormData) => void; isPending: boolean }; password: { mutate: (form: FormData) => void; isPending: boolean }; passwordFormRef: React.RefObject<HTMLFormElement>; stats?: MailStats }) { function ProfileOverview({ user, profile, password, passwordFormRef, stats, twoFactorFormRef, setupTwoFactor, enableTwoFactor, disableTwoFactor, onCopy }: { user: { email: string; displayName: string; role: string; disabled: boolean; twoFactorEnabled: boolean; createdAt: string }; profile: { mutate: (form: FormData) => void; isPending: boolean }; password: { mutate: (form: FormData) => void; isPending: boolean }; passwordFormRef: React.RefObject<HTMLFormElement>; stats?: MailStats; twoFactorFormRef: React.RefObject<HTMLFormElement>; setupTwoFactor: { data?: { secret: string; otpauthUrl: string }; mutate: () => void; reset: () => void; isPending: boolean }; enableTwoFactor: { mutate: (form: FormData) => void; isPending: boolean }; disableTwoFactor: { mutate: (form: FormData) => void; isPending: boolean }; onCopy: (text: string) => void }) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<Card> <Card>
@@ -206,6 +223,67 @@ function ProfileOverview({ user, profile, password, passwordFormRef, stats }: {
</CardContent> </CardContent>
</Card> </Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="flex items-center gap-2 text-sm">
<KeyRound className="h-4 w-4" />
</div>
<Badge variant={user.twoFactorEnabled ? "default" : "secondary"}>{user.twoFactorEnabled ? "已启用" : "未启用"}</Badge>
</div>
{!user.twoFactorEnabled && !setupTwoFactor.data && (
<Button onClick={() => setupTwoFactor.mutate()} disabled={setupTwoFactor.isPending}>{setupTwoFactor.isPending ? "生成中..." : "启用双因素认证"}</Button>
)}
{!user.twoFactorEnabled && setupTwoFactor.data && (
<form ref={twoFactorFormRef} className="space-y-4" onSubmit={(e) => { e.preventDefault(); enableTwoFactor.mutate(new FormData(e.currentTarget)) }}>
<div className="grid gap-4 lg:grid-cols-[220px_minmax(0,1fr)]">
<div className="flex justify-center rounded-lg border bg-white p-4">
<QRCodeSVG value={setupTwoFactor.data.otpauthUrl} size={184} level="M" />
</div>
<div className="space-y-4">
<Field label="密钥">
<div className="flex gap-2">
<Input value={setupTwoFactor.data.secret} readOnly />
<Button type="button" variant="outline" onClick={() => onCopy(setupTwoFactor.data!.secret)}><Copy className="h-4 w-4" /></Button>
</div>
</Field>
<Field label="绑定地址">
<div className="flex gap-2">
<Input value={setupTwoFactor.data.otpauthUrl} readOnly />
<Button type="button" variant="outline" onClick={() => onCopy(setupTwoFactor.data!.otpauthUrl)}><Copy className="h-4 w-4" /></Button>
</div>
</Field>
</div>
</div>
<Field label="验证码">
<Input name="code" inputMode="numeric" autoComplete="one-time-code" minLength={6} maxLength={6} required />
</Field>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setupTwoFactor.reset()}></Button>
<Button disabled={enableTwoFactor.isPending}>{enableTwoFactor.isPending ? "启用中..." : "确认启用"}</Button>
</div>
</form>
)}
{user.twoFactorEnabled && (
<form ref={twoFactorFormRef} className="space-y-4" onSubmit={(e) => { e.preventDefault(); disableTwoFactor.mutate(new FormData(e.currentTarget)) }}>
<Field label="当前验证码">
<Input name="code" inputMode="numeric" autoComplete="one-time-code" minLength={6} maxLength={6} required />
</Field>
<div className="flex justify-end">
<Button variant="destructive" disabled={disableTwoFactor.isPending}>{disableTwoFactor.isPending ? "关闭中..." : "关闭双因素认证"}</Button>
</div>
</form>
)}
</CardContent>
</Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle></CardTitle> <CardTitle></CardTitle>
+2
View File
@@ -6,6 +6,8 @@ LANQIN_DATA_DIR=/data
LANQIN_DB_PATH=/data/lanqin.db LANQIN_DB_PATH=/data/lanqin.db
LANQIN_ADDR=:8080 LANQIN_ADDR=:8080
LANQIN_ALLOW_INSECURE_HTTP=false LANQIN_ALLOW_INSECURE_HTTP=false
LANQIN_OPEN_REGISTRATION=false
LANQIN_TWO_FACTOR_ENABLED=false
LANQIN_SMTP_HOST=postfix LANQIN_SMTP_HOST=postfix
LANQIN_SMTP_PORT=25 LANQIN_SMTP_PORT=25
LANQIN_SMTP_REQUIRE_TLS=false LANQIN_SMTP_REQUIRE_TLS=false
+1 -1
View File
@@ -2,4 +2,4 @@ driver = sqlite
connect = /data/lanqin.db connect = /data/lanqin.db
default_pass_scheme = BLF-CRYPT default_pass_scheme = BLF-CRYPT
password_query = SELECT address AS user, password_hash AS password FROM mailboxes WHERE address = '%u' AND status = 'active' password_query = SELECT address AS user, password_hash AS password FROM mailboxes WHERE address = '%u' AND status = 'active'
user_query = SELECT '/var/mail/vhosts/' || substr(address, instr(address, '@') + 1) || '/' || local_part AS home, 'maildir:/var/mail/vhosts/' || substr(address, instr(address, '@') + 1) || '/' || local_part || '/Maildir' AS mail, 5000 AS uid, 5000 AS gid FROM mailboxes WHERE address = '%u' AND status = 'active' user_query = SELECT '/var/mail/vhosts/' || substr(address, instr(address, '@') + 1) || '/' || local_part AS home, 'maildir:/var/mail/vhosts/' || substr(address, instr(address, '@') + 1) || '/' || local_part || '/Maildir' AS mail, 5000 AS uid, 5000 AS gid FROM mailboxes WHERE address = '%u' AND status = 'active' UNION SELECT '/var/mail/vhosts/' || substr('%u', instr('%u', '@') + 1) || '/__unregistered__' AS home, 'maildir:/var/mail/vhosts/' || substr('%u', instr('%u', '@') + 1) || '/__unregistered__/Maildir' AS mail, 5000 AS uid, 5000 AS gid WHERE EXISTS (SELECT 1 FROM system_settings WHERE key='catchAllEnabled' AND value='true') AND EXISTS (SELECT 1 FROM domains WHERE name=substr('%u', instr('%u', '@') + 1) AND status='active') AND NOT EXISTS (SELECT 1 FROM mailboxes WHERE address='%u' AND status='active')
+1 -1
View File
@@ -1,2 +1,2 @@
dbpath = /data/lanqin.db dbpath = /data/lanqin.db
query = SELECT 'vhosts/' || substr(address, instr(address, '@') + 1) || '/' || local_part || '/Maildir/' FROM mailboxes WHERE address='%s' AND status='active' query = SELECT 'vhosts/' || substr(address, instr(address, '@') + 1) || '/' || local_part || '/Maildir/' FROM mailboxes WHERE address='%s' AND status='active' UNION SELECT 'vhosts/' || substr('%s', instr('%s', '@') + 1) || '/__unregistered__/Maildir/' WHERE EXISTS (SELECT 1 FROM system_settings WHERE key='catchAllEnabled' AND value='true') AND EXISTS (SELECT 1 FROM domains WHERE name=substr('%s', instr('%s', '@') + 1) AND status='active') AND NOT EXISTS (SELECT 1 FROM mailboxes WHERE address='%s' AND status='active')