feat(admin): 扩展后台管理与登录安全能力。
- 新增用户、域名、邮箱、别名、邮件、系统设置与模板的管理接口和页面。 - 支持双因素认证、Turnstile、人机验证与管理员 SMTP 测试。 - 增加无人收件/未注册邮件归档、Maildir 同步和数据库迁移支持。 - 更新前端导航、个人中心 2FA 配置以及相关部署示例。
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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, ¬null, &dflt, &pk); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
columns[name] = true
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !columns["two_factor_secret"] {
|
||||||
|
if _, err := a.db.ExecContext(ctx, `ALTER TABLE users ADD COLUMN two_factor_secret TEXT NOT NULL DEFAULT ''`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !columns["two_factor_enabled"] {
|
||||||
|
if _, err := a.db.ExecContext(ctx, `ALTER TABLE users ADD COLUMN two_factor_enabled INTEGER NOT NULL DEFAULT 0`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) migrateMessagesForUnregistered(ctx context.Context) error {
|
||||||
|
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(messages)`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
hasRecipientAddr := false
|
||||||
|
mailboxNullable := false
|
||||||
|
folderNullable := false
|
||||||
|
for rows.Next() {
|
||||||
|
var cid int
|
||||||
|
var name, typ string
|
||||||
|
var notnull int
|
||||||
|
var dflt any
|
||||||
|
var pk int
|
||||||
|
if err := rows.Scan(&cid, &name, &typ, ¬null, &dflt, &pk); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch name {
|
||||||
|
case "recipient_addr":
|
||||||
|
hasRecipientAddr = true
|
||||||
|
case "mailbox_id":
|
||||||
|
mailboxNullable = notnull == 0
|
||||||
|
case "folder_id":
|
||||||
|
folderNullable = notnull == 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if hasRecipientAddr && mailboxNullable && folderNullable {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := a.db.ExecContext(ctx, `PRAGMA foreign_keys = OFF`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer a.db.ExecContext(context.Background(), `PRAGMA foreign_keys = ON`)
|
||||||
|
|
||||||
|
tx, err := a.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
for _, stmt := range []string{
|
||||||
|
`DROP INDEX IF EXISTS idx_messages_mailbox_folder_received`,
|
||||||
|
`DROP INDEX IF EXISTS idx_messages_search`,
|
||||||
|
`DROP INDEX IF EXISTS idx_messages_mailbox_raw_path`,
|
||||||
|
`DROP INDEX IF EXISTS idx_messages_unregistered_raw_path`,
|
||||||
|
} {
|
||||||
|
if _, err := tx.ExecContext(ctx, stmt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `CREATE TABLE messages_new (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
mailbox_id TEXT REFERENCES mailboxes(id) ON DELETE CASCADE,
|
||||||
|
folder_id TEXT REFERENCES folders(id) ON DELETE CASCADE,
|
||||||
|
recipient_addr TEXT NOT NULL DEFAULT '',
|
||||||
|
message_uid TEXT NOT NULL,
|
||||||
|
message_id TEXT NOT NULL,
|
||||||
|
subject TEXT NOT NULL,
|
||||||
|
from_addr TEXT NOT NULL,
|
||||||
|
to_addrs TEXT NOT NULL,
|
||||||
|
cc_addrs TEXT NOT NULL DEFAULT '[]',
|
||||||
|
bcc_addrs TEXT NOT NULL DEFAULT '[]',
|
||||||
|
sent_at TEXT NOT NULL,
|
||||||
|
received_at TEXT NOT NULL,
|
||||||
|
snippet TEXT NOT NULL,
|
||||||
|
body_text TEXT NOT NULL,
|
||||||
|
body_html TEXT NOT NULL,
|
||||||
|
is_read INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_starred INTEGER NOT NULL DEFAULT 0,
|
||||||
|
has_attachments INTEGER NOT NULL DEFAULT 0,
|
||||||
|
size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||||
|
raw_path TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `INSERT INTO messages_new(id,mailbox_id,folder_id,recipient_addr,message_uid,message_id,subject,from_addr,to_addrs,cc_addrs,bcc_addrs,sent_at,received_at,snippet,body_text,body_html,is_read,is_starred,has_attachments,size_bytes,raw_path,created_at,updated_at)
|
||||||
|
SELECT id,mailbox_id,folder_id,'',message_uid,message_id,subject,from_addr,to_addrs,cc_addrs,bcc_addrs,sent_at,received_at,snippet,body_text,body_html,is_read,is_starred,has_attachments,size_bytes,raw_path,created_at,updated_at FROM messages`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `DROP TABLE messages`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `ALTER TABLE messages_new RENAME TO messages`); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, stmt := range messageIndexes() {
|
||||||
|
if _, err := a.db.ExecContext(ctx, stmt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func messageIndexes() []string {
|
||||||
|
return []string{
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_messages_mailbox_folder_received ON messages(mailbox_id, folder_id, received_at DESC)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_messages_search ON messages(mailbox_id, subject, from_addr, snippet)`,
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_mailbox_raw_path ON messages(mailbox_id, raw_path) WHERE raw_path <> '' AND mailbox_id IS NOT NULL`,
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_unregistered_raw_path ON messages(raw_path) WHERE raw_path <> '' AND mailbox_id IS NULL`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) seed(ctx context.Context) error {
|
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)
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,23 +23,24 @@ type AttachmentInput struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type storedMessage struct {
|
type storedMessage struct {
|
||||||
MailboxID string
|
MailboxID string
|
||||||
FolderID string
|
FolderID string
|
||||||
MessageUID string
|
RecipientAddr string
|
||||||
MessageID string
|
MessageUID string
|
||||||
Subject string
|
MessageID string
|
||||||
From string
|
Subject string
|
||||||
To []string
|
From string
|
||||||
CC []string
|
To []string
|
||||||
BCC []string
|
CC []string
|
||||||
SentAt time.Time
|
BCC []string
|
||||||
ReceivedAt time.Time
|
SentAt time.Time
|
||||||
Snippet string
|
ReceivedAt time.Time
|
||||||
BodyText string
|
Snippet string
|
||||||
BodyHTML string
|
BodyText string
|
||||||
IsRead bool
|
BodyHTML string
|
||||||
IsStarred bool
|
IsRead bool
|
||||||
RawPath string
|
IsStarred bool
|
||||||
|
RawPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) handleMyMailboxes(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handleMyMailboxes(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -225,12 +226,36 @@ func (a *App) handleMailSend(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,10 +20,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type maildirMailbox struct {
|
type maildirMailbox struct {
|
||||||
ID string
|
ID string
|
||||||
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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendSMTPWithConfig(cfg Config, from string, recipients []string, mimeBytes []byte) error {
|
||||||
|
addr := net.JoinHostPort(cfg.SMTPHost, cfg.SMTPPort)
|
||||||
var auth smtp.Auth
|
var auth smtp.Auth
|
||||||
if a.cfg.SMTPUsername != "" {
|
if cfg.SMTPUsername != "" {
|
||||||
auth = smtp.PlainAuth("", a.cfg.SMTPUsername, a.cfg.SMTPPassword, a.cfg.SMTPHost)
|
auth = smtp.PlainAuth("", cfg.SMTPUsername, cfg.SMTPPassword, cfg.SMTPHost)
|
||||||
}
|
}
|
||||||
if !a.cfg.SMTPRequireTLS {
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
@@ -99,13 +124,44 @@ func (a *App) corsMiddleware(next http.Handler) http.Handler {
|
|||||||
|
|
||||||
func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
|
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)
|
if err != nil {
|
||||||
_, err = a.db.ExecContext(r.Context(), `INSERT INTO sessions(id,user_id,token_hash,expires_at,created_at) VALUES(?,?,?,?,?)`,
|
respondError(w, http.StatusInternalServerError, "failed to create verification challenge")
|
||||||
sessionID, user.ID, hashToken(token), expires.Format(time.RFC3339Nano), a.now().UTC().Format(time.RFC3339Nano))
|
return
|
||||||
if err != nil {
|
}
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -3,12 +3,19 @@ package app
|
|||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Disabled bool `json:"disabled"`
|
Disabled bool `json:"disabled"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
TwoFactorEnabled bool `json:"twoFactorEnabled"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminUser struct {
|
||||||
|
User
|
||||||
|
MailboxCount int `json:"mailboxCount"`
|
||||||
|
Mailboxes []string `json:"mailboxes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Domain struct {
|
type Domain struct {
|
||||||
@@ -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"`
|
||||||
|
|||||||
Generated
+151
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
<SidebarGroup>
|
{user.role === "admin" && isAdminRoute && (
|
||||||
<SidebarGroupContent>
|
<SidebarGroup>
|
||||||
<SidebarMenu>
|
<SidebarGroupContent>
|
||||||
<NavItem to="/mail" icon={<Inbox />} label="Webmail" />
|
<SidebarMenu>
|
||||||
{user.role === "admin" && <NavItem to="/admin" icon={<Settings />} label="系统管理" />}
|
{adminSections.map((item) => (
|
||||||
</SidebarMenu>
|
<SidebarMenuItem key={item.key}>
|
||||||
</SidebarGroupContent>
|
<SidebarMenuButton asChild isActive={adminSection === item.key} tooltip={item.label}>
|
||||||
</SidebarGroup>
|
<Link to={`/admin?section=${item.key}`}>
|
||||||
|
{item.icon}
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</Link>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
))}
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
</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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 }
|
||||||
@@ -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
@@ -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
@@ -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}>
|
||||||
|
|||||||
+763
-261
File diff suppressed because it is too large
Load Diff
@@ -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,20 +37,82 @@ 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)) }}>
|
||||||
<div className="space-y-2">
|
{!challengeToken ? (
|
||||||
<Label htmlFor="email">邮箱</Label>
|
<>
|
||||||
<Input id="email" name="email" type="email" defaultValue="admin@lanqin.local" required className="h-11 text-base" />
|
<div className="space-y-2">
|
||||||
</div>
|
<Label htmlFor="email">邮箱</Label>
|
||||||
<div className="space-y-2">
|
<Input id="email" name="email" type="email" defaultValue="admin@lanqin.local" required className="h-11 text-base" />
|
||||||
<Label htmlFor="password">密码</Label>
|
</div>
|
||||||
<Input id="password" name="password" type="password" defaultValue="ChangeMe123!" required className="h-11 text-base" />
|
<div className="space-y-2">
|
||||||
</div>
|
<Label htmlFor="password">密码</Label>
|
||||||
|
<Input id="password" name="password" type="password" defaultValue="ChangeMe123!" required className="h-11 text-base" />
|
||||||
|
</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>
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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')
|
||||||
|
|||||||
Reference in New Issue
Block a user