feat(admin): 扩展后台管理与登录安全能力。
- 新增用户、域名、邮箱、别名、邮件、系统设置与模板的管理接口和页面。 - 支持双因素认证、Turnstile、人机验证与管理员 SMTP 测试。 - 增加无人收件/未注册邮件归档、Maildir 同步和数据库迁移支持。 - 更新前端导航、个人中心 2FA 配置以及相关部署示例。
This commit is contained in:
@@ -5,12 +5,252 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func (a *App) handleAdminOverview(w http.ResponseWriter, r *http.Request) {
|
||||
var out struct {
|
||||
Users int64 `json:"users"`
|
||||
ActiveUsers int64 `json:"activeUsers"`
|
||||
Domains int64 `json:"domains"`
|
||||
Mailboxes int64 `json:"mailboxes"`
|
||||
ActiveMailboxes int64 `json:"activeMailboxes"`
|
||||
Aliases int64 `json:"aliases"`
|
||||
Messages int64 `json:"messages"`
|
||||
UnreadMessages int64 `json:"unreadMessages"`
|
||||
StorageBytes int64 `json:"storageBytes"`
|
||||
}
|
||||
queries := []struct {
|
||||
q string
|
||||
dest *int64
|
||||
}{
|
||||
{`SELECT COUNT(*) FROM users`, &out.Users},
|
||||
{`SELECT COUNT(*) FROM users WHERE disabled=0`, &out.ActiveUsers},
|
||||
{`SELECT COUNT(*) FROM domains`, &out.Domains},
|
||||
{`SELECT COUNT(*) FROM mailboxes`, &out.Mailboxes},
|
||||
{`SELECT COUNT(*) FROM mailboxes WHERE status='active'`, &out.ActiveMailboxes},
|
||||
{`SELECT COUNT(*) FROM aliases`, &out.Aliases},
|
||||
{`SELECT COUNT(*) FROM messages`, &out.Messages},
|
||||
{`SELECT COUNT(*) FROM messages WHERE is_read=0`, &out.UnreadMessages},
|
||||
{`SELECT COALESCE(SUM(size_bytes),0) FROM messages`, &out.StorageBytes},
|
||||
}
|
||||
for _, item := range queries {
|
||||
if err := a.db.QueryRowContext(r.Context(), item.q).Scan(item.dest); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load overview")
|
||||
return
|
||||
}
|
||||
}
|
||||
respondJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (a *App) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT u.id,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.created_at,COUNT(mb.id),COALESCE(GROUP_CONCAT(mb.address), '')
|
||||
FROM users u LEFT JOIN mailboxes mb ON mb.user_id=u.id
|
||||
GROUP BY u.id,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.created_at
|
||||
ORDER BY u.created_at DESC`)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to list users")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []AdminUser{}
|
||||
for rows.Next() {
|
||||
var item AdminUser
|
||||
var disabled, twoFactorEnabled int
|
||||
var created, mailboxCSV string
|
||||
if err := rows.Scan(&item.ID, &item.Email, &item.DisplayName, &item.Role, &disabled, &twoFactorEnabled, &created, &item.MailboxCount, &mailboxCSV); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to scan users")
|
||||
return
|
||||
}
|
||||
item.Disabled = intBool(disabled)
|
||||
item.TwoFactorEnabled = intBool(twoFactorEnabled)
|
||||
item.CreatedAt = parseTime(created)
|
||||
item.Mailboxes = splitCSV(mailboxCSV)
|
||||
items = append(items, item)
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Role string `json:"role"`
|
||||
Password string `json:"password"`
|
||||
Disabled bool `json:"disabled"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
email := normalizeEmail(req.Email)
|
||||
if email == "" || !strings.Contains(email, "@") {
|
||||
badRequest(w, errors.New("invalid email"))
|
||||
return
|
||||
}
|
||||
displayName := strings.TrimSpace(req.DisplayName)
|
||||
if displayName == "" {
|
||||
displayName = email
|
||||
}
|
||||
role := strings.TrimSpace(req.Role)
|
||||
if role == "" {
|
||||
role = "user"
|
||||
}
|
||||
if role != "admin" && role != "user" {
|
||||
badRequest(w, errors.New("invalid role"))
|
||||
return
|
||||
}
|
||||
if len(req.Password) < 8 {
|
||||
badRequest(w, errors.New("password must be at least 8 characters"))
|
||||
return
|
||||
}
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to hash password")
|
||||
return
|
||||
}
|
||||
id := newID("usr")
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
_, err = a.db.ExecContext(r.Context(), `INSERT INTO users(id,email,display_name,role,password_hash,disabled,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?)`, id, email, displayName, role, string(passwordHash), boolInt(req.Disabled), now, now)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
user, err := a.adminUserByID(r.Context(), id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load user")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusCreated, user)
|
||||
}
|
||||
|
||||
func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
current := currentUser(r)
|
||||
var req struct {
|
||||
DisplayName string `json:"displayName"`
|
||||
Role string `json:"role"`
|
||||
Disabled *bool `json:"disabled"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
displayName := strings.TrimSpace(req.DisplayName)
|
||||
if displayName == "" {
|
||||
badRequest(w, errors.New("displayName is required"))
|
||||
return
|
||||
}
|
||||
role := strings.TrimSpace(req.Role)
|
||||
if role == "" {
|
||||
role = "user"
|
||||
}
|
||||
if role != "admin" && role != "user" {
|
||||
badRequest(w, errors.New("invalid role"))
|
||||
return
|
||||
}
|
||||
disabled := false
|
||||
if req.Disabled != nil {
|
||||
disabled = *req.Disabled
|
||||
}
|
||||
if current != nil && current.ID == id && (disabled || role != "admin") {
|
||||
badRequest(w, errors.New("cannot remove your own admin access"))
|
||||
return
|
||||
}
|
||||
if err := a.ensureAdminRemains(r.Context(), id, role, disabled); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
_, err := a.db.ExecContext(r.Context(), `UPDATE users SET display_name=?, role=?, disabled=?, updated_at=? WHERE id=?`,
|
||||
displayName, role, boolInt(disabled), a.now().UTC().Format(time.RFC3339Nano), id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to update user")
|
||||
return
|
||||
}
|
||||
user, err := a.adminUserByID(r.Context(), id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, user)
|
||||
}
|
||||
|
||||
func (a *App) handleResetUserPassword(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
var req struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
if len(req.Password) < 8 {
|
||||
badRequest(w, errors.New("password must be at least 8 characters"))
|
||||
return
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to hash password")
|
||||
return
|
||||
}
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
tx, err := a.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to start transaction")
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
res, err := tx.ExecContext(r.Context(), `UPDATE users SET password_hash=?, updated_at=? WHERE id=?`, string(hash), now, id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to reset password")
|
||||
return
|
||||
}
|
||||
affected, _ := res.RowsAffected()
|
||||
if affected == 0 {
|
||||
respondError(w, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
if _, err := tx.ExecContext(r.Context(), `UPDATE mailboxes SET password_hash=?, updated_at=? WHERE user_id=?`, string(hash), now, id); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to update mailbox passwords")
|
||||
return
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to save password")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *App) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
current := currentUser(r)
|
||||
if current != nil && current.ID == id {
|
||||
badRequest(w, errors.New("cannot delete your own user"))
|
||||
return
|
||||
}
|
||||
if err := a.ensureAdminRemains(r.Context(), id, "user", true); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
res, err := a.db.ExecContext(r.Context(), `DELETE FROM users WHERE id=?`, id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to delete user")
|
||||
return
|
||||
}
|
||||
affected, _ := res.RowsAffected()
|
||||
if affected == 0 {
|
||||
respondError(w, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *App) handleListDomains(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT id,name,status,dkim_selector,dkim_public_key,dns_status,dns_checked_at,created_at FROM domains ORDER BY name`)
|
||||
if err != nil {
|
||||
@@ -55,6 +295,63 @@ func (a *App) handleCreateDomain(w http.ResponseWriter, r *http.Request) {
|
||||
respondJSON(w, http.StatusCreated, d)
|
||||
}
|
||||
|
||||
func (a *App) handleUpdateDomain(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
var req struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
status := strings.TrimSpace(req.Status)
|
||||
if status != "active" && status != "disabled" {
|
||||
badRequest(w, errors.New("invalid status"))
|
||||
return
|
||||
}
|
||||
res, err := a.db.ExecContext(r.Context(), `UPDATE domains SET status=?, updated_at=? WHERE id=?`,
|
||||
status, a.now().UTC().Format(time.RFC3339Nano), id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to update domain")
|
||||
return
|
||||
}
|
||||
affected, _ := res.RowsAffected()
|
||||
if affected == 0 {
|
||||
respondError(w, http.StatusNotFound, "domain not found")
|
||||
return
|
||||
}
|
||||
d, err := a.domainByID(r.Context(), id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load domain")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, d)
|
||||
}
|
||||
|
||||
func (a *App) handleDeleteDomain(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
var count int
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM mailboxes WHERE domain_id=?`, id).Scan(&count); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to check domain")
|
||||
return
|
||||
}
|
||||
if count > 0 {
|
||||
badRequest(w, errors.New("domain still has mailboxes"))
|
||||
return
|
||||
}
|
||||
res, err := a.db.ExecContext(r.Context(), `DELETE FROM domains WHERE id=?`, id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to delete domain")
|
||||
return
|
||||
}
|
||||
affected, _ := res.RowsAffected()
|
||||
if affected == 0 {
|
||||
respondError(w, http.StatusNotFound, "domain not found")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *App) handleListMailboxes(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT mb.id,mb.user_id,u.email,mb.domain_id,mb.local_part,mb.address,mb.display_name,mb.quota_mb,mb.status,mb.created_at
|
||||
FROM mailboxes mb JOIN users u ON u.id=mb.user_id ORDER BY mb.address`)
|
||||
@@ -197,6 +494,119 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
respondJSON(w, http.StatusCreated, m)
|
||||
}
|
||||
|
||||
func (a *App) handleUpdateMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
var req struct {
|
||||
DisplayName string `json:"displayName"`
|
||||
QuotaMB int `json:"quotaMb"`
|
||||
Status string `json:"status"`
|
||||
UserID string `json:"userId"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
displayName := strings.TrimSpace(req.DisplayName)
|
||||
if displayName == "" {
|
||||
badRequest(w, errors.New("displayName is required"))
|
||||
return
|
||||
}
|
||||
if req.QuotaMB <= 0 {
|
||||
req.QuotaMB = 1024
|
||||
}
|
||||
status := strings.TrimSpace(req.Status)
|
||||
if status == "" {
|
||||
status = "active"
|
||||
}
|
||||
if status != "active" && status != "disabled" {
|
||||
badRequest(w, errors.New("invalid status"))
|
||||
return
|
||||
}
|
||||
userID := strings.TrimSpace(req.UserID)
|
||||
if userID == "" {
|
||||
badRequest(w, errors.New("userId is required"))
|
||||
return
|
||||
}
|
||||
var disabled int
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT disabled FROM users WHERE id=?`, userID).Scan(&disabled); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
respondError(w, http.StatusNotFound, "owner user not found")
|
||||
} else {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load owner user")
|
||||
}
|
||||
return
|
||||
}
|
||||
if intBool(disabled) {
|
||||
badRequest(w, errors.New("owner user is disabled"))
|
||||
return
|
||||
}
|
||||
res, err := a.db.ExecContext(r.Context(), `UPDATE mailboxes SET user_id=?,display_name=?,quota_mb=?,status=?,updated_at=? WHERE id=?`,
|
||||
userID, displayName, req.QuotaMB, status, a.now().UTC().Format(time.RFC3339Nano), id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to update mailbox")
|
||||
return
|
||||
}
|
||||
affected, _ := res.RowsAffected()
|
||||
if affected == 0 {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
}
|
||||
m, err := a.mailboxByID(r.Context(), id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load mailbox")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, m)
|
||||
}
|
||||
|
||||
func (a *App) handleDeleteMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
current := currentUser(r)
|
||||
var owner string
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT user_id FROM mailboxes WHERE id=?`, id).Scan(&owner); err != nil {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
}
|
||||
var count int
|
||||
if current != nil && owner == current.ID {
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM mailboxes WHERE user_id=?`, owner).Scan(&count); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to check mailbox")
|
||||
return
|
||||
}
|
||||
if count <= 1 {
|
||||
badRequest(w, errors.New("cannot delete your last mailbox"))
|
||||
return
|
||||
}
|
||||
}
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT id FROM messages WHERE mailbox_id=?`, id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load mailbox messages")
|
||||
return
|
||||
}
|
||||
messageIDs := []string{}
|
||||
for rows.Next() {
|
||||
var messageID string
|
||||
if rows.Scan(&messageID) == nil {
|
||||
messageIDs = append(messageIDs, messageID)
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
for _, messageID := range messageIDs {
|
||||
a.deleteMessageFiles(r.Context(), messageID)
|
||||
}
|
||||
res, err := a.db.ExecContext(r.Context(), `DELETE FROM mailboxes WHERE id=?`, id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to delete mailbox")
|
||||
return
|
||||
}
|
||||
affected, _ := res.RowsAffected()
|
||||
if affected == 0 {
|
||||
respondError(w, http.StatusNotFound, "mailbox not found")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *App) handleListAliases(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT id,domain_id,source,destination,enabled,created_at FROM aliases ORDER BY source`)
|
||||
if err != nil {
|
||||
@@ -220,6 +630,86 @@ func (a *App) handleListAliases(w http.ResponseWriter, r *http.Request) {
|
||||
respondJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
func (a *App) handleAdminMessages(w http.ResponseWriter, r *http.Request) {
|
||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
mailboxID := strings.TrimSpace(r.URL.Query().Get("mailboxId"))
|
||||
folder := strings.TrimSpace(r.URL.Query().Get("folder"))
|
||||
offset, _ := strconv.Atoi(r.URL.Query().Get("cursor"))
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
limit := 50
|
||||
|
||||
where := []string{"1=1"}
|
||||
args := []any{}
|
||||
if mailboxID == "unregistered" {
|
||||
where = append(where, "m.mailbox_id IS NULL")
|
||||
} else if mailboxID != "" && mailboxID != "all" {
|
||||
where = append(where, "m.mailbox_id=?")
|
||||
args = append(args, mailboxID)
|
||||
}
|
||||
if folder != "" && folder != "all" {
|
||||
if strings.EqualFold(folder, "Unregistered") {
|
||||
where = append(where, "m.mailbox_id IS NULL")
|
||||
} else {
|
||||
where = append(where, "lower(f.name)=lower(?)")
|
||||
args = append(args, folder)
|
||||
}
|
||||
}
|
||||
if q != "" {
|
||||
where = append(where, "(m.subject LIKE ? OR m.from_addr LIKE ? OR m.to_addrs LIKE ? OR m.recipient_addr LIKE ? OR m.snippet LIKE ? OR m.body_text LIKE ? OR mb.address LIKE ? OR u.email LIKE ?)")
|
||||
like := "%" + q + "%"
|
||||
args = append(args, like, like, like, like, like, like, like, like)
|
||||
}
|
||||
args = append(args, limit+1, offset)
|
||||
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT m.id,COALESCE(m.mailbox_id,''),COALESCE(mb.address,''),COALESCE(u.email,''),COALESCE(m.recipient_addr,''),COALESCE(m.folder_id,''),COALESCE(f.name,'Unregistered'),m.message_uid,m.message_id,m.subject,m.from_addr,m.to_addrs,m.cc_addrs,m.bcc_addrs,m.sent_at,m.received_at,m.snippet,m.is_read,m.is_starred,m.has_attachments,m.size_bytes
|
||||
FROM messages m
|
||||
LEFT JOIN folders f ON f.id=m.folder_id
|
||||
LEFT JOIN mailboxes mb ON mb.id=m.mailbox_id
|
||||
LEFT JOIN users u ON u.id=mb.user_id
|
||||
WHERE `+strings.Join(where, " AND ")+`
|
||||
ORDER BY m.received_at DESC LIMIT ? OFFSET ?`, args...)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load messages")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []MailMessage{}
|
||||
for rows.Next() {
|
||||
msg, err := scanAdminMessageSummary(rows)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to scan messages")
|
||||
return
|
||||
}
|
||||
items = append(items, msg)
|
||||
}
|
||||
next := ""
|
||||
if len(items) > limit {
|
||||
items = items[:limit]
|
||||
next = strconv.Itoa(offset + limit)
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"items": items, "nextCursor": next})
|
||||
}
|
||||
|
||||
func (a *App) handleAdminMessage(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
msg, err := a.messageByID(r.Context(), id, true)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "message not found")
|
||||
return
|
||||
}
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT COALESCE(mb.address,''),COALESCE(u.email,''),COALESCE(m.recipient_addr,'')
|
||||
FROM messages m
|
||||
LEFT JOIN mailboxes mb ON mb.id=m.mailbox_id
|
||||
LEFT JOIN users u ON u.id=mb.user_id
|
||||
WHERE m.id=?`, id).Scan(&msg.MailboxAddress, &msg.OwnerEmail, &msg.RecipientAddr); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load message owner")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, msg)
|
||||
}
|
||||
|
||||
func (a *App) handleCreateAlias(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
DomainID string `json:"domainId"`
|
||||
@@ -260,6 +750,64 @@ func (a *App) handleCreateAlias(w http.ResponseWriter, r *http.Request) {
|
||||
respondJSON(w, http.StatusCreated, Alias{ID: id, DomainID: req.DomainID, Source: source, Destination: destination, Enabled: enabled, CreatedAt: parseTime(now)})
|
||||
}
|
||||
|
||||
func (a *App) handleUpdateAlias(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
var req struct {
|
||||
Source string `json:"source"`
|
||||
Destination string `json:"destination"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
var domainID string
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT domain_id FROM aliases WHERE id=?`, id).Scan(&domainID); err != nil {
|
||||
respondError(w, http.StatusNotFound, "alias not found")
|
||||
return
|
||||
}
|
||||
domain, err := a.domainByID(r.Context(), domainID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "domain not found")
|
||||
return
|
||||
}
|
||||
source := normalizeEmail(req.Source)
|
||||
if !strings.Contains(source, "@") {
|
||||
source = normalizeLocalPart(source) + "@" + domain.Name
|
||||
}
|
||||
destination := normalizeEmail(req.Destination)
|
||||
if source == "" || destination == "" || !strings.Contains(destination, "@") {
|
||||
badRequest(w, errors.New("invalid alias"))
|
||||
return
|
||||
}
|
||||
enabled := true
|
||||
if req.Enabled != nil {
|
||||
enabled = *req.Enabled
|
||||
}
|
||||
_, err = a.db.ExecContext(r.Context(), `UPDATE aliases SET source=?,destination=?,enabled=?,updated_at=? WHERE id=?`,
|
||||
source, destination, boolInt(enabled), a.now().UTC().Format(time.RFC3339Nano), id)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, Alias{ID: id, DomainID: domainID, Source: source, Destination: destination, Enabled: enabled, CreatedAt: a.now().UTC()})
|
||||
}
|
||||
|
||||
func (a *App) handleDeleteAlias(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
res, err := a.db.ExecContext(r.Context(), `DELETE FROM aliases WHERE id=?`, id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to delete alias")
|
||||
return
|
||||
}
|
||||
affected, _ := res.RowsAffected()
|
||||
if affected == 0 {
|
||||
respondError(w, http.StatusNotFound, "alias not found")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (a *App) domainByID(ctx context.Context, id string) (*Domain, error) {
|
||||
row := a.db.QueryRowContext(ctx, `SELECT id,name,status,dkim_selector,dkim_public_key,dns_status,dns_checked_at,created_at FROM domains WHERE id=?`, id)
|
||||
var d Domain
|
||||
@@ -273,11 +821,72 @@ func (a *App) domainByID(ctx context.Context, id string) (*Domain, error) {
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
func (a *App) adminUserByID(ctx context.Context, id string) (*AdminUser, error) {
|
||||
row := a.db.QueryRowContext(ctx, `SELECT u.id,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.created_at,COUNT(mb.id),COALESCE(GROUP_CONCAT(mb.address), '')
|
||||
FROM users u LEFT JOIN mailboxes mb ON mb.user_id=u.id
|
||||
WHERE u.id=?
|
||||
GROUP BY u.id,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.created_at`, id)
|
||||
var item AdminUser
|
||||
var disabled, twoFactorEnabled int
|
||||
var created, mailboxCSV string
|
||||
if err := row.Scan(&item.ID, &item.Email, &item.DisplayName, &item.Role, &disabled, &twoFactorEnabled, &created, &item.MailboxCount, &mailboxCSV); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.Disabled = intBool(disabled)
|
||||
item.TwoFactorEnabled = intBool(twoFactorEnabled)
|
||||
item.CreatedAt = parseTime(created)
|
||||
item.Mailboxes = splitCSV(mailboxCSV)
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (a *App) ensureAdminRemains(ctx context.Context, targetID, nextRole string, nextDisabled bool) error {
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT id,role,disabled FROM users`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
admins := 0
|
||||
for rows.Next() {
|
||||
var id, role string
|
||||
var disabled int
|
||||
if err := rows.Scan(&id, &role, &disabled); err != nil {
|
||||
return err
|
||||
}
|
||||
if id == targetID {
|
||||
role = nextRole
|
||||
disabled = boolInt(nextDisabled)
|
||||
}
|
||||
if role == "admin" && disabled == 0 {
|
||||
admins++
|
||||
}
|
||||
}
|
||||
if admins == 0 {
|
||||
return errors.New("at least one active admin is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func splitCSV(s string) []string {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(s, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" {
|
||||
out = append(out, part)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *App) mailboxByID(ctx context.Context, id string) (*Mailbox, error) {
|
||||
row := a.db.QueryRowContext(ctx, `SELECT id,user_id,domain_id,local_part,address,display_name,quota_mb,status,created_at FROM mailboxes WHERE id=?`, id)
|
||||
row := a.db.QueryRowContext(ctx, `SELECT mb.id,mb.user_id,u.email,mb.domain_id,mb.local_part,mb.address,mb.display_name,mb.quota_mb,mb.status,mb.created_at
|
||||
FROM mailboxes mb JOIN users u ON u.id=mb.user_id WHERE mb.id=?`, id)
|
||||
var m Mailbox
|
||||
var created string
|
||||
if err := row.Scan(&m.ID, &m.UserID, &m.DomainID, &m.LocalPart, &m.Address, &m.DisplayName, &m.QuotaMB, &m.Status, &created); err != nil {
|
||||
if err := row.Scan(&m.ID, &m.UserID, &m.UserEmail, &m.DomainID, &m.LocalPart, &m.Address, &m.DisplayName, &m.QuotaMB, &m.Status, &created); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.CreatedAt = parseTime(created)
|
||||
|
||||
@@ -55,6 +55,14 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := a.ensureDefaultMailTemplates(context.Background()); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := a.loadPersistedSystemSettings(context.Background()); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := a.seed(context.Background()); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
@@ -99,6 +107,8 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
display_name TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK(role IN ('admin','user')),
|
||||
password_hash TEXT NOT NULL,
|
||||
two_factor_secret TEXT NOT NULL DEFAULT '',
|
||||
two_factor_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
disabled INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
@@ -110,6 +120,26 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS login_challenges (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS system_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS mail_templates (
|
||||
key TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
body_text TEXT NOT NULL,
|
||||
body_html TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS domains (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
@@ -155,8 +185,9 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
mailbox_id TEXT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
|
||||
folder_id TEXT NOT NULL REFERENCES folders(id) ON DELETE CASCADE,
|
||||
mailbox_id TEXT REFERENCES mailboxes(id) ON DELETE CASCADE,
|
||||
folder_id TEXT REFERENCES folders(id) ON DELETE CASCADE,
|
||||
recipient_addr TEXT NOT NULL DEFAULT '',
|
||||
message_uid TEXT NOT NULL,
|
||||
message_id TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
@@ -179,7 +210,8 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_messages_mailbox_folder_received ON messages(mailbox_id, folder_id, received_at DESC)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_messages_search ON messages(mailbox_id, subject, from_addr, snippet)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_mailbox_raw_path ON messages(mailbox_id, raw_path) WHERE raw_path <> ''`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_mailbox_raw_path ON messages(mailbox_id, raw_path) WHERE raw_path <> '' AND mailbox_id IS NOT NULL`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_unregistered_raw_path ON messages(raw_path) WHERE raw_path <> '' AND mailbox_id IS NULL`,
|
||||
`CREATE TABLE IF NOT EXISTS attachments (
|
||||
id TEXT PRIMARY KEY,
|
||||
message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
||||
@@ -230,9 +262,160 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := a.migrateMessagesForUnregistered(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.migrateUsersForTwoFactor(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) migrateUsersForTwoFactor(ctx context.Context) error {
|
||||
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(users)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
columns := map[string]bool{}
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name, typ string
|
||||
var notnull int
|
||||
var dflt any
|
||||
var pk int
|
||||
if err := rows.Scan(&cid, &name, &typ, ¬null, &dflt, &pk); err != nil {
|
||||
return err
|
||||
}
|
||||
columns[name] = true
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if !columns["two_factor_secret"] {
|
||||
if _, err := a.db.ExecContext(ctx, `ALTER TABLE users ADD COLUMN two_factor_secret TEXT NOT NULL DEFAULT ''`); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !columns["two_factor_enabled"] {
|
||||
if _, err := a.db.ExecContext(ctx, `ALTER TABLE users ADD COLUMN two_factor_enabled INTEGER NOT NULL DEFAULT 0`); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) migrateMessagesForUnregistered(ctx context.Context) error {
|
||||
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(messages)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
hasRecipientAddr := false
|
||||
mailboxNullable := false
|
||||
folderNullable := false
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name, typ string
|
||||
var notnull int
|
||||
var dflt any
|
||||
var pk int
|
||||
if err := rows.Scan(&cid, &name, &typ, ¬null, &dflt, &pk); err != nil {
|
||||
return err
|
||||
}
|
||||
switch name {
|
||||
case "recipient_addr":
|
||||
hasRecipientAddr = true
|
||||
case "mailbox_id":
|
||||
mailboxNullable = notnull == 0
|
||||
case "folder_id":
|
||||
folderNullable = notnull == 0
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if hasRecipientAddr && mailboxNullable && folderNullable {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := a.db.ExecContext(ctx, `PRAGMA foreign_keys = OFF`); err != nil {
|
||||
return err
|
||||
}
|
||||
defer a.db.ExecContext(context.Background(), `PRAGMA foreign_keys = ON`)
|
||||
|
||||
tx, err := a.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
for _, stmt := range []string{
|
||||
`DROP INDEX IF EXISTS idx_messages_mailbox_folder_received`,
|
||||
`DROP INDEX IF EXISTS idx_messages_search`,
|
||||
`DROP INDEX IF EXISTS idx_messages_mailbox_raw_path`,
|
||||
`DROP INDEX IF EXISTS idx_messages_unregistered_raw_path`,
|
||||
} {
|
||||
if _, err := tx.ExecContext(ctx, stmt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `CREATE TABLE messages_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
mailbox_id TEXT REFERENCES mailboxes(id) ON DELETE CASCADE,
|
||||
folder_id TEXT REFERENCES folders(id) ON DELETE CASCADE,
|
||||
recipient_addr TEXT NOT NULL DEFAULT '',
|
||||
message_uid TEXT NOT NULL,
|
||||
message_id TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
from_addr TEXT NOT NULL,
|
||||
to_addrs TEXT NOT NULL,
|
||||
cc_addrs TEXT NOT NULL DEFAULT '[]',
|
||||
bcc_addrs TEXT NOT NULL DEFAULT '[]',
|
||||
sent_at TEXT NOT NULL,
|
||||
received_at TEXT NOT NULL,
|
||||
snippet TEXT NOT NULL,
|
||||
body_text TEXT NOT NULL,
|
||||
body_html TEXT NOT NULL,
|
||||
is_read INTEGER NOT NULL DEFAULT 0,
|
||||
is_starred INTEGER NOT NULL DEFAULT 0,
|
||||
has_attachments INTEGER NOT NULL DEFAULT 0,
|
||||
size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
raw_path TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO messages_new(id,mailbox_id,folder_id,recipient_addr,message_uid,message_id,subject,from_addr,to_addrs,cc_addrs,bcc_addrs,sent_at,received_at,snippet,body_text,body_html,is_read,is_starred,has_attachments,size_bytes,raw_path,created_at,updated_at)
|
||||
SELECT id,mailbox_id,folder_id,'',message_uid,message_id,subject,from_addr,to_addrs,cc_addrs,bcc_addrs,sent_at,received_at,snippet,body_text,body_html,is_read,is_starred,has_attachments,size_bytes,raw_path,created_at,updated_at FROM messages`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `DROP TABLE messages`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `ALTER TABLE messages_new RENAME TO messages`); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, stmt := range messageIndexes() {
|
||||
if _, err := a.db.ExecContext(ctx, stmt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func messageIndexes() []string {
|
||||
return []string{
|
||||
`CREATE INDEX IF NOT EXISTS idx_messages_mailbox_folder_received ON messages(mailbox_id, folder_id, received_at DESC)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_messages_search ON messages(mailbox_id, subject, from_addr, snippet)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_mailbox_raw_path ON messages(mailbox_id, raw_path) WHERE raw_path <> '' AND mailbox_id IS NOT NULL`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_unregistered_raw_path ON messages(raw_path) WHERE raw_path <> '' AND mailbox_id IS NULL`,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) seed(ctx context.Context) error {
|
||||
var count int
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&count); err != nil {
|
||||
@@ -378,19 +561,32 @@ func (a *App) seedWelcomeMessage(ctx context.Context, mailboxID string) error {
|
||||
return err
|
||||
}
|
||||
now := a.now().UTC()
|
||||
subject := "欢迎使用 LanQin Email"
|
||||
bodyText := "你的自建邮箱 Webmail 已经初始化完成。请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。"
|
||||
bodyHTML := "<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{
|
||||
MailboxID: mailboxID,
|
||||
FolderID: folderID,
|
||||
MessageUID: newID("uid"),
|
||||
MessageID: fmt.Sprintf("<%s@lanqin.local>", newID("msg")),
|
||||
Subject: "欢迎使用 LanQin Email",
|
||||
Subject: subject,
|
||||
From: "system@lanqin.local",
|
||||
To: []string{a.cfg.AdminEmail},
|
||||
SentAt: now,
|
||||
ReceivedAt: now,
|
||||
Snippet: "你的自建邮箱 Webmail 已经初始化完成。",
|
||||
BodyText: "你的自建邮箱 Webmail 已经初始化完成。请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。",
|
||||
BodyHTML: "<p>你的自建邮箱 Webmail 已经初始化完成。</p><p>请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。</p>",
|
||||
Snippet: snippetFrom(bodyText, bodyHTML),
|
||||
BodyText: bodyText,
|
||||
BodyHTML: bodyHTML,
|
||||
IsRead: false,
|
||||
}
|
||||
_, err = a.insertMessage(ctx, msg, nil)
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newTestApp(t *testing.T) *App {
|
||||
@@ -38,6 +41,70 @@ func newTestApp(t *testing.T) *App {
|
||||
return a
|
||||
}
|
||||
|
||||
func startFakeSMTP(t *testing.T) (string, string, <-chan string) {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
received := make(chan string, 1)
|
||||
t.Cleanup(func() { _ = ln.Close() })
|
||||
go func() {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go handleFakeSMTPConn(conn, received)
|
||||
}
|
||||
}()
|
||||
host, port, err := net.SplitHostPort(ln.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return host, port, received
|
||||
}
|
||||
|
||||
func handleFakeSMTPConn(conn net.Conn, received chan<- string) {
|
||||
defer conn.Close()
|
||||
reader := bufio.NewReader(conn)
|
||||
_, _ = io.WriteString(conn, "220 lanqin.test ESMTP\r\n")
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cmd := strings.ToUpper(strings.TrimSpace(line))
|
||||
switch {
|
||||
case strings.HasPrefix(cmd, "EHLO") || strings.HasPrefix(cmd, "HELO"):
|
||||
_, _ = io.WriteString(conn, "250-lanqin.test\r\n250 OK\r\n")
|
||||
case strings.HasPrefix(cmd, "DATA"):
|
||||
_, _ = io.WriteString(conn, "354 End data with <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 {
|
||||
t *testing.T
|
||||
server *httptest.Server
|
||||
@@ -227,6 +294,120 @@ func TestUserCanSelectMultipleMailboxes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatchAllStoresUnregisteredMailForAdminOnly(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ts := httptest.NewServer(a.Router())
|
||||
defer ts.Close()
|
||||
admin := &testClient{t: t, server: ts}
|
||||
|
||||
var login map[string]any
|
||||
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("login code=%d body=%v", code, login)
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"to": []string{"ghost@lanqin.local"},
|
||||
"subject": "should be rejected by default",
|
||||
"text": "default disabled",
|
||||
}
|
||||
var sent MailMessage
|
||||
if code := admin.do("POST", "/api/mail/send", payload, &sent); code != http.StatusCreated {
|
||||
t.Fatalf("send disabled catch-all code=%d", code)
|
||||
}
|
||||
var list struct {
|
||||
Items []MailMessage `json:"items"`
|
||||
}
|
||||
if code := admin.do("GET", "/api/admin/messages?mailboxId=unregistered&q=should%20be%20rejected", nil, &list); code != http.StatusOK || len(list.Items) != 0 {
|
||||
t.Fatalf("disabled catch-all should not store unregistered mail: code=%d items=%+v", code, list.Items)
|
||||
}
|
||||
|
||||
var settings SystemSettings
|
||||
if code := admin.do("GET", "/api/admin/settings", nil, &settings); code != http.StatusOK {
|
||||
t.Fatalf("get settings code=%d", code)
|
||||
}
|
||||
update := map[string]any{
|
||||
"publicHostname": settings.PublicHostname,
|
||||
"publicBaseUrl": settings.PublicBaseURL,
|
||||
"smtpHost": settings.SMTPHost,
|
||||
"smtpPort": settings.SMTPPort,
|
||||
"smtpUsername": settings.SMTPUsername,
|
||||
"smtpPassword": "",
|
||||
"smtpRequireTls": settings.SMTPRequireTLS,
|
||||
"maildirRoot": settings.MaildirRoot,
|
||||
"maildirScanSeconds": settings.MaildirScanSeconds,
|
||||
"sessionTtlHours": settings.SessionTTLHours,
|
||||
"allowInsecureHttp": settings.AllowInsecureHTTP,
|
||||
"openRegistration": settings.OpenRegistration,
|
||||
"twoFactorEnabled": settings.TwoFactorEnabled,
|
||||
"turnstileEnabled": settings.TurnstileEnabled,
|
||||
"turnstileSiteKey": settings.TurnstileSiteKey,
|
||||
"turnstileSecretKey": "",
|
||||
"catchAllEnabled": true,
|
||||
"mailAutoRefresh": settings.MailAutoRefresh,
|
||||
"mailRefreshSeconds": settings.MailRefreshSeconds,
|
||||
}
|
||||
if code := admin.do("POST", "/api/admin/settings", update, &settings); code != http.StatusOK || !settings.CatchAllEnabled {
|
||||
t.Fatalf("enable catch-all code=%d settings=%+v", code, settings)
|
||||
}
|
||||
|
||||
payload = map[string]any{
|
||||
"to": []string{"ghost@lanqin.local"},
|
||||
"subject": "stored for admin only",
|
||||
"text": "unregistered mailbox content",
|
||||
}
|
||||
if code := admin.do("POST", "/api/mail/send", payload, &sent); code != http.StatusCreated {
|
||||
t.Fatalf("send enabled catch-all code=%d", code)
|
||||
}
|
||||
if code := admin.do("GET", "/api/admin/messages?mailboxId=unregistered&q=stored%20for%20admin", nil, &list); code != http.StatusOK || len(list.Items) != 1 {
|
||||
t.Fatalf("enabled catch-all admin list code=%d items=%+v", code, list.Items)
|
||||
}
|
||||
if got := list.Items[0].RecipientAddr; got != "ghost@lanqin.local" {
|
||||
t.Fatalf("recipientAddress=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSMTPTestEndpoint(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
host, port, received := startFakeSMTP(t)
|
||||
a.cfg.SMTPHost = host
|
||||
a.cfg.SMTPPort = port
|
||||
ts := httptest.NewServer(a.Router())
|
||||
defer ts.Close()
|
||||
admin := &testClient{t: t, server: ts}
|
||||
|
||||
var login map[string]any
|
||||
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("login code=%d body=%v", code, login)
|
||||
}
|
||||
|
||||
var out map[string]any
|
||||
var templates struct {
|
||||
Items []MailTemplate `json:"items"`
|
||||
}
|
||||
if code := admin.do("GET", "/api/admin/mail-templates", nil, &templates); code != http.StatusOK || len(templates.Items) == 0 {
|
||||
t.Fatalf("templates code=%d items=%d", code, len(templates.Items))
|
||||
}
|
||||
var updated MailTemplate
|
||||
if code := admin.do("POST", "/api/admin/mail-templates/smtp_test", map[string]string{
|
||||
"subject": "自定义 SMTP 测试",
|
||||
"bodyText": "hello {{to}} from {{from}}",
|
||||
"bodyHtml": "<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) {
|
||||
a := newTestApp(t)
|
||||
ts := httptest.NewServer(a.Router())
|
||||
@@ -262,6 +443,64 @@ func TestProfileAndPasswordUpdate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserTwoFactorSetupAndLogin(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
a.cfg.TwoFactorEnabled = true
|
||||
ts := httptest.NewServer(a.Router())
|
||||
defer ts.Close()
|
||||
client := &testClient{t: t, server: ts}
|
||||
|
||||
var login map[string]any
|
||||
if code := client.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("login code=%d body=%v", code, login)
|
||||
}
|
||||
|
||||
var setup struct {
|
||||
Secret string `json:"secret"`
|
||||
OtpauthURL string `json:"otpauthUrl"`
|
||||
}
|
||||
if code := client.do("POST", "/api/me/2fa/setup", map[string]string{}, &setup); code != http.StatusOK || setup.Secret == "" || !strings.HasPrefix(setup.OtpauthURL, "otpauth://totp/") {
|
||||
t.Fatalf("setup code=%d setup=%+v", code, setup)
|
||||
}
|
||||
|
||||
var out map[string]any
|
||||
if code := client.do("POST", "/api/me/2fa/enable", map[string]string{"code": "000000"}, &out); code != http.StatusUnauthorized {
|
||||
t.Fatalf("wrong enable code=%d body=%v", code, out)
|
||||
}
|
||||
code, err := generateTOTP(setup.Secret, a.now().UTC())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var enabled struct {
|
||||
User User `json:"user"`
|
||||
}
|
||||
if status := client.do("POST", "/api/me/2fa/enable", map[string]string{"code": code}, &enabled); status != http.StatusOK || !enabled.User.TwoFactorEnabled {
|
||||
t.Fatalf("enable status=%d user=%+v", status, enabled.User)
|
||||
}
|
||||
|
||||
fresh := &testClient{t: t, server: ts}
|
||||
var challenge struct {
|
||||
TwoFactorRequired bool `json:"twoFactorRequired"`
|
||||
ChallengeToken string `json:"challengeToken"`
|
||||
}
|
||||
if status := fresh.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &challenge); status != http.StatusOK || !challenge.TwoFactorRequired || challenge.ChallengeToken == "" || fresh.cookie != nil {
|
||||
t.Fatalf("challenge status=%d challenge=%+v cookie=%v", status, challenge, fresh.cookie)
|
||||
}
|
||||
if status := fresh.do("POST", "/api/auth/login", map[string]string{"challengeToken": challenge.ChallengeToken, "twoFactorCode": "000000"}, &out); status != http.StatusUnauthorized {
|
||||
t.Fatalf("wrong challenge status=%d body=%v", status, out)
|
||||
}
|
||||
code, err = generateTOTP(setup.Secret, a.now().UTC())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if status := fresh.do("POST", "/api/auth/login", map[string]string{"challengeToken": challenge.ChallengeToken, "twoFactorCode": code}, &login); status != http.StatusOK || fresh.cookie == nil {
|
||||
t.Fatalf("2fa login status=%d body=%v cookie=%v", status, login, fresh.cookie)
|
||||
}
|
||||
if status := fresh.do("POST", "/api/me/2fa/disable", map[string]string{"code": code}, &enabled); status != http.StatusOK || enabled.User.TwoFactorEnabled {
|
||||
t.Fatalf("disable status=%d user=%+v", status, enabled.User)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSRecords(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
d, err := a.domainByID(context.Background(), mustDefaultDomainID(t, a))
|
||||
|
||||
@@ -25,6 +25,14 @@ type Config struct {
|
||||
MaildirRoot string
|
||||
MaildirScanSeconds int
|
||||
AllowInsecureHTTP bool
|
||||
OpenRegistration bool
|
||||
TwoFactorEnabled bool
|
||||
TurnstileEnabled bool
|
||||
TurnstileSiteKey string
|
||||
TurnstileSecretKey string
|
||||
CatchAllEnabled bool
|
||||
MailAutoRefresh bool
|
||||
MailRefreshSeconds int
|
||||
}
|
||||
|
||||
func LoadConfig() Config {
|
||||
@@ -47,6 +55,14 @@ func LoadConfig() Config {
|
||||
MaildirRoot: getenv("LANQIN_MAILDIR_ROOT", ""),
|
||||
MaildirScanSeconds: getenvInt("LANQIN_MAILDIR_SCAN_SECONDS", 30),
|
||||
AllowInsecureHTTP: getenvBool("LANQIN_ALLOW_INSECURE_HTTP", true),
|
||||
OpenRegistration: getenvBool("LANQIN_OPEN_REGISTRATION", false),
|
||||
TwoFactorEnabled: getenvBool("LANQIN_TWO_FACTOR_ENABLED", false),
|
||||
TurnstileEnabled: getenvBool("LANQIN_TURNSTILE_ENABLED", false),
|
||||
TurnstileSiteKey: getenv("LANQIN_TURNSTILE_SITE_KEY", ""),
|
||||
TurnstileSecretKey: getenv("LANQIN_TURNSTILE_SECRET_KEY", ""),
|
||||
CatchAllEnabled: getenvBool("LANQIN_CATCH_ALL_ENABLED", false),
|
||||
MailAutoRefresh: getenvBool("LANQIN_MAIL_AUTO_REFRESH", true),
|
||||
MailRefreshSeconds: getenvInt("LANQIN_MAIL_REFRESH_SECONDS", 30),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,23 +23,24 @@ type AttachmentInput struct {
|
||||
}
|
||||
|
||||
type storedMessage struct {
|
||||
MailboxID string
|
||||
FolderID string
|
||||
MessageUID string
|
||||
MessageID string
|
||||
Subject string
|
||||
From string
|
||||
To []string
|
||||
CC []string
|
||||
BCC []string
|
||||
SentAt time.Time
|
||||
ReceivedAt time.Time
|
||||
Snippet string
|
||||
BodyText string
|
||||
BodyHTML string
|
||||
IsRead bool
|
||||
IsStarred bool
|
||||
RawPath string
|
||||
MailboxID string
|
||||
FolderID string
|
||||
RecipientAddr string
|
||||
MessageUID string
|
||||
MessageID string
|
||||
Subject string
|
||||
From string
|
||||
To []string
|
||||
CC []string
|
||||
BCC []string
|
||||
SentAt time.Time
|
||||
ReceivedAt time.Time
|
||||
Snippet string
|
||||
BodyText string
|
||||
BodyHTML string
|
||||
IsRead bool
|
||||
IsStarred bool
|
||||
RawPath string
|
||||
}
|
||||
|
||||
func (a *App) handleMyMailboxes(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -225,12 +226,36 @@ func (a *App) handleMailSend(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Development/local-domain delivery: if a recipient exists as a local mailbox, write an Inbox copy.
|
||||
// Development/local-domain delivery: known local recipients go to their Inbox.
|
||||
// When catch-all is enabled, unknown local recipients are stored as unregistered
|
||||
// messages visible only in the admin "全部邮件" view.
|
||||
localRecipients := append(req.To, req.CC...)
|
||||
localRecipients = append(localRecipients, req.BCC...)
|
||||
for _, rcpt := range localRecipients {
|
||||
rcptMailbox, err := a.mailboxByAddress(r.Context(), rcpt)
|
||||
if err != nil {
|
||||
if !a.cfg.CatchAllEnabled || !a.isLocalDomainAddress(r.Context(), rcpt) {
|
||||
continue
|
||||
}
|
||||
copyMsg := base
|
||||
copyMsg.MailboxID = ""
|
||||
copyMsg.FolderID = ""
|
||||
copyMsg.RecipientAddr = normalizeEmail(rcpt)
|
||||
copyMsg.MessageUID = newID("uid")
|
||||
copyMsg.IsRead = false
|
||||
_, _ = a.insertMessage(r.Context(), copyMsg, req.Attachments)
|
||||
continue
|
||||
}
|
||||
if rcptMailbox.Status != "active" {
|
||||
if a.cfg.CatchAllEnabled && a.isLocalDomainAddress(r.Context(), rcpt) {
|
||||
copyMsg := base
|
||||
copyMsg.MailboxID = ""
|
||||
copyMsg.FolderID = ""
|
||||
copyMsg.RecipientAddr = normalizeEmail(rcpt)
|
||||
copyMsg.MessageUID = newID("uid")
|
||||
copyMsg.IsRead = false
|
||||
_, _ = a.insertMessage(r.Context(), copyMsg, req.Attachments)
|
||||
}
|
||||
continue
|
||||
}
|
||||
inboxID, err := a.ensureFolder(r.Context(), rcptMailbox.ID, "Inbox")
|
||||
@@ -251,6 +276,16 @@ func (a *App) handleMailSend(w http.ResponseWriter, r *http.Request) {
|
||||
respondJSON(w, http.StatusCreated, msg)
|
||||
}
|
||||
|
||||
func (a *App) isLocalDomainAddress(ctx context.Context, address string) bool {
|
||||
parts := strings.Split(normalizeEmail(address), "@")
|
||||
if len(parts) != 2 || parts[1] == "" {
|
||||
return false
|
||||
}
|
||||
var count int
|
||||
_ = a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM domains WHERE name=? AND status='active'`, parts[1]).Scan(&count)
|
||||
return count > 0
|
||||
}
|
||||
|
||||
func (a *App) handleMarkRead(w http.ResponseWriter, r *http.Request) {
|
||||
msg, err := a.loadMessageForRequest(r, chi.URLParam(r, "id"), false)
|
||||
if err != nil {
|
||||
@@ -369,6 +404,27 @@ func (a *App) handleAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.Copy(w, f)
|
||||
}
|
||||
|
||||
func (a *App) handleAdminAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
attID := chi.URLParam(r, "id")
|
||||
row := a.db.QueryRowContext(r.Context(), `SELECT filename,content_type,size_bytes,storage_path FROM attachments WHERE id=?`, attID)
|
||||
var filename, contentType, path string
|
||||
var size int64
|
||||
if err := row.Scan(&filename, &contentType, &size, &path); err != nil {
|
||||
respondError(w, http.StatusNotFound, "attachment not found")
|
||||
return
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "attachment file missing")
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="`+strings.ReplaceAll(filename, `"`, "")+`"`)
|
||||
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
|
||||
_, _ = io.Copy(w, f)
|
||||
}
|
||||
|
||||
func (a *App) handleEvents(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
@@ -440,8 +496,8 @@ func (a *App) loadMessageForRequest(r *http.Request, id string, includeBody bool
|
||||
}
|
||||
|
||||
func (a *App) messageByID(ctx context.Context, id string, includeBody bool) (*MailMessage, error) {
|
||||
row := a.db.QueryRowContext(ctx, `SELECT m.id,m.mailbox_id,m.folder_id,f.name,m.message_uid,m.message_id,m.subject,m.from_addr,m.to_addrs,m.cc_addrs,m.bcc_addrs,m.sent_at,m.received_at,m.snippet,m.body_text,m.body_html,m.is_read,m.is_starred,m.has_attachments,m.size_bytes
|
||||
FROM messages m JOIN folders f ON f.id=m.folder_id WHERE m.id=?`, id)
|
||||
row := a.db.QueryRowContext(ctx, `SELECT m.id,COALESCE(m.mailbox_id,''),COALESCE(m.recipient_addr,''),COALESCE(m.folder_id,''),COALESCE(f.name,'Unregistered'),m.message_uid,m.message_id,m.subject,m.from_addr,m.to_addrs,m.cc_addrs,m.bcc_addrs,m.sent_at,m.received_at,m.snippet,m.body_text,m.body_html,m.is_read,m.is_starred,m.has_attachments,m.size_bytes
|
||||
FROM messages m LEFT JOIN folders f ON f.id=m.folder_id WHERE m.id=?`, id)
|
||||
msg, err := scanMessageFull(row, includeBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -466,8 +522,16 @@ func (a *App) insertMessage(ctx context.Context, msg storedMessage, attachments
|
||||
size += int64(len(decoded))
|
||||
}
|
||||
}
|
||||
_, err := a.db.ExecContext(ctx, `INSERT INTO messages(id,mailbox_id,folder_id,message_uid,message_id,subject,from_addr,to_addrs,cc_addrs,bcc_addrs,sent_at,received_at,snippet,body_text,body_html,is_read,is_starred,has_attachments,size_bytes,raw_path,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, id, msg.MailboxID, msg.FolderID, msg.MessageUID, msg.MessageID, msg.Subject, msg.From, jsonEncode(msg.To), jsonEncode(msg.CC), jsonEncode(msg.BCC), msg.SentAt.Format(time.RFC3339Nano), msg.ReceivedAt.Format(time.RFC3339Nano), msg.Snippet, msg.BodyText, msg.BodyHTML, boolInt(msg.IsRead), boolInt(msg.IsStarred), boolInt(hasAttachments), size, msg.RawPath, now, now)
|
||||
var mailboxID, folderID any
|
||||
if strings.TrimSpace(msg.MailboxID) != "" {
|
||||
mailboxID = msg.MailboxID
|
||||
}
|
||||
if strings.TrimSpace(msg.FolderID) != "" {
|
||||
folderID = msg.FolderID
|
||||
}
|
||||
recipientAddr := normalizeEmail(msg.RecipientAddr)
|
||||
_, err := a.db.ExecContext(ctx, `INSERT INTO messages(id,mailbox_id,folder_id,recipient_addr,message_uid,message_id,subject,from_addr,to_addrs,cc_addrs,bcc_addrs,sent_at,received_at,snippet,body_text,body_html,is_read,is_starred,has_attachments,size_bytes,raw_path,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, id, mailboxID, folderID, recipientAddr, msg.MessageUID, msg.MessageID, msg.Subject, msg.From, jsonEncode(msg.To), jsonEncode(msg.CC), jsonEncode(msg.BCC), msg.SentAt.Format(time.RFC3339Nano), msg.ReceivedAt.Format(time.RFC3339Nano), msg.Snippet, msg.BodyText, msg.BodyHTML, boolInt(msg.IsRead), boolInt(msg.IsStarred), boolInt(hasAttachments), size, msg.RawPath, now, now)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -541,6 +605,20 @@ func (a *App) deleteMessageFiles(ctx context.Context, messageID string) {
|
||||
|
||||
type messageSummaryScanner interface{ Scan(dest ...any) error }
|
||||
|
||||
func scanAdminMessageSummary(row messageSummaryScanner) (MailMessage, error) {
|
||||
var msg MailMessage
|
||||
var toJSON, ccJSON, bccJSON, sent, received string
|
||||
var read, starred, hasAtt int
|
||||
err := row.Scan(&msg.ID, &msg.MailboxID, &msg.MailboxAddress, &msg.OwnerEmail, &msg.RecipientAddr, &msg.FolderID, &msg.Folder, &msg.MessageUID, &msg.MessageID, &msg.Subject, &msg.From, &toJSON, &ccJSON, &bccJSON, &sent, &received, &msg.Snippet, &read, &starred, &hasAtt, &msg.SizeBytes)
|
||||
if err != nil {
|
||||
return msg, err
|
||||
}
|
||||
msg.To, msg.CC, msg.BCC = jsonDecodeSlice(toJSON), jsonDecodeSlice(ccJSON), jsonDecodeSlice(bccJSON)
|
||||
msg.SentAt, msg.ReceivedAt = parseTime(sent), parseTime(received)
|
||||
msg.IsRead, msg.IsStarred, msg.HasAttachments = intBool(read), intBool(starred), intBool(hasAtt)
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
func scanMessageSummary(row messageSummaryScanner, folder string) (MailMessage, error) {
|
||||
var msg MailMessage
|
||||
var toJSON, ccJSON, bccJSON, sent, received string
|
||||
@@ -561,7 +639,7 @@ func scanMessageFull(row messageSummaryScanner, includeBody bool) (MailMessage,
|
||||
var toJSON, ccJSON, bccJSON, sent, received string
|
||||
var read, starred, hasAtt int
|
||||
var bodyText, bodyHTML string
|
||||
err := row.Scan(&msg.ID, &msg.MailboxID, &msg.FolderID, &msg.Folder, &msg.MessageUID, &msg.MessageID, &msg.Subject, &msg.From, &toJSON, &ccJSON, &bccJSON, &sent, &received, &msg.Snippet, &bodyText, &bodyHTML, &read, &starred, &hasAtt, &msg.SizeBytes)
|
||||
err := row.Scan(&msg.ID, &msg.MailboxID, &msg.RecipientAddr, &msg.FolderID, &msg.Folder, &msg.MessageUID, &msg.MessageID, &msg.Subject, &msg.From, &toJSON, &ccJSON, &bccJSON, &sent, &received, &msg.Snippet, &bodyText, &bodyHTML, &read, &starred, &hasAtt, &msg.SizeBytes)
|
||||
if err != nil {
|
||||
return msg, err
|
||||
}
|
||||
|
||||
@@ -20,10 +20,12 @@ import (
|
||||
)
|
||||
|
||||
type maildirMailbox struct {
|
||||
ID string
|
||||
Address string
|
||||
LocalPart string
|
||||
Domain string
|
||||
ID string
|
||||
Address string
|
||||
LocalPart string
|
||||
Domain string
|
||||
Unregistered bool
|
||||
RecipientDomain string
|
||||
}
|
||||
|
||||
type maildirFolder struct {
|
||||
@@ -80,6 +82,14 @@ func (a *App) syncMaildirOnce(ctx context.Context) (int, error) {
|
||||
}
|
||||
imported := 0
|
||||
for _, mb := range mailboxes {
|
||||
if mb.Unregistered {
|
||||
count, err := a.syncUnregisteredMaildir(ctx, mb)
|
||||
if err != nil {
|
||||
return imported, err
|
||||
}
|
||||
imported += count
|
||||
continue
|
||||
}
|
||||
folders, err := a.maildirFolders(ctx, mb.ID)
|
||||
if err != nil {
|
||||
return imported, err
|
||||
@@ -135,7 +145,109 @@ func (a *App) maildirMailboxes(ctx context.Context) ([]maildirMailbox, error) {
|
||||
}
|
||||
out = append(out, mb)
|
||||
}
|
||||
return out, rows.Err()
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if a.cfg.CatchAllEnabled {
|
||||
domainRows, err := a.db.QueryContext(ctx, `SELECT name FROM domains WHERE status='active' ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer domainRows.Close()
|
||||
for domainRows.Next() {
|
||||
var domain string
|
||||
if err := domainRows.Scan(&domain); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, maildirMailbox{
|
||||
Address: "__unregistered__@" + domain,
|
||||
LocalPart: "__unregistered__",
|
||||
Domain: domain,
|
||||
Unregistered: true,
|
||||
RecipientDomain: domain,
|
||||
})
|
||||
}
|
||||
if err := domainRows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *App) syncUnregisteredMaildir(ctx context.Context, mb maildirMailbox) (int, error) {
|
||||
base := filepath.Join(strings.TrimSpace(a.cfg.MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
|
||||
imported := 0
|
||||
for _, sub := range []string{"new", "cur"} {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return imported, ctx.Err()
|
||||
default:
|
||||
}
|
||||
dir := filepath.Join(base, sub)
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
return imported, err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(dir, entry.Name())
|
||||
ok, err := a.syncUnregisteredMaildirFile(ctx, mb, path)
|
||||
if err != nil {
|
||||
a.log.Warn("unregistered maildir file import failed", "path", path, "error", err)
|
||||
continue
|
||||
}
|
||||
if ok {
|
||||
imported++
|
||||
}
|
||||
}
|
||||
}
|
||||
return imported, nil
|
||||
}
|
||||
|
||||
func (a *App) syncUnregisteredMaildirFile(ctx context.Context, mb maildirMailbox, path string) (bool, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
msg, attachments, err := a.parseMaildirMessage(raw, mb.Address)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
recipient := unregisteredRecipientFromMessage(msg, mb.RecipientDomain)
|
||||
if recipient == "" {
|
||||
recipient = mb.Address
|
||||
}
|
||||
msg.MailboxID = ""
|
||||
msg.FolderID = ""
|
||||
msg.RecipientAddr = recipient
|
||||
msg.RawPath = path
|
||||
if msg.MessageUID == "" {
|
||||
msg.MessageUID = newID("uid")
|
||||
}
|
||||
if msg.MessageID == "" {
|
||||
msg.MessageID = fmt.Sprintf("<%s@lanqin.local>", newID("msg"))
|
||||
}
|
||||
if msg.ReceivedAt.IsZero() {
|
||||
msg.ReceivedAt = a.now().UTC()
|
||||
}
|
||||
if msg.SentAt.IsZero() {
|
||||
msg.SentAt = msg.ReceivedAt
|
||||
}
|
||||
if msg.Snippet == "" {
|
||||
msg.Snippet = snippetFrom(msg.BodyText, msg.BodyHTML)
|
||||
}
|
||||
if exists, err := a.unregisteredMaildirMessageExists(ctx, path, msg.MessageID, msg.RecipientAddr); err != nil {
|
||||
return false, err
|
||||
} else if exists {
|
||||
return false, nil
|
||||
}
|
||||
_, err = a.insertMessage(ctx, msg, attachments)
|
||||
return err == nil, err
|
||||
}
|
||||
|
||||
func (a *App) maildirFolders(ctx context.Context, mailboxID string) ([]maildirFolder, error) {
|
||||
@@ -212,6 +324,26 @@ func (a *App) maildirMessageExists(ctx context.Context, mailboxID, folderID, raw
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (a *App) unregisteredMaildirMessageExists(ctx context.Context, rawPath, messageID, recipient string) (bool, error) {
|
||||
var count int
|
||||
err := a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM messages WHERE mailbox_id IS NULL AND (raw_path=? OR (recipient_addr=? AND message_id=? AND message_id <> ''))`, rawPath, recipient, messageID).Scan(&count)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func unregisteredRecipientFromMessage(msg storedMessage, domain string) string {
|
||||
domain = normalizeDomain(domain)
|
||||
for _, address := range append(append([]string{}, msg.To...), msg.CC...) {
|
||||
address = normalizeEmail(address)
|
||||
if strings.HasSuffix(address, "@"+domain) {
|
||||
return address
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (a *App) parseMaildirMessage(raw []byte, fallbackTo string) (storedMessage, []AttachmentInput, error) {
|
||||
m, err := netmail.ReadMessage(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
|
||||
@@ -122,20 +122,24 @@ func writeBase64(w io.Writer, data []byte) {
|
||||
}
|
||||
|
||||
func (a *App) sendSMTP(from string, recipients []string, mimeBytes []byte) error {
|
||||
addr := net.JoinHostPort(a.cfg.SMTPHost, a.cfg.SMTPPort)
|
||||
return sendSMTPWithConfig(a.cfg, from, recipients, mimeBytes)
|
||||
}
|
||||
|
||||
func sendSMTPWithConfig(cfg Config, from string, recipients []string, mimeBytes []byte) error {
|
||||
addr := net.JoinHostPort(cfg.SMTPHost, cfg.SMTPPort)
|
||||
var auth smtp.Auth
|
||||
if a.cfg.SMTPUsername != "" {
|
||||
auth = smtp.PlainAuth("", a.cfg.SMTPUsername, a.cfg.SMTPPassword, a.cfg.SMTPHost)
|
||||
if cfg.SMTPUsername != "" {
|
||||
auth = smtp.PlainAuth("", cfg.SMTPUsername, cfg.SMTPPassword, cfg.SMTPHost)
|
||||
}
|
||||
if !a.cfg.SMTPRequireTLS {
|
||||
if !cfg.SMTPRequireTLS {
|
||||
return smtp.SendMail(addr, auth, from, recipients, mimeBytes)
|
||||
}
|
||||
conn, err := tls.Dial("tcp", addr, &tls.Config{ServerName: a.cfg.SMTPHost, MinVersion: tls.VersionTLS12})
|
||||
conn, err := tls.Dial("tcp", addr, &tls.Config{ServerName: cfg.SMTPHost, MinVersion: tls.VersionTLS12})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
client, err := smtp.NewClient(conn, a.cfg.SMTPHost)
|
||||
client, err := smtp.NewClient(conn, cfg.SMTPHost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -30,11 +30,15 @@ func (a *App) Router() http.Handler {
|
||||
})
|
||||
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
r.Get("/public/settings", a.handlePublicSettings)
|
||||
r.Post("/auth/login", a.handleLogin)
|
||||
r.Post("/auth/logout", a.handleLogout)
|
||||
r.With(a.requireAuth).Get("/me", a.handleMe)
|
||||
r.With(a.requireAuth).Post("/me/profile", a.handleUpdateProfile)
|
||||
r.With(a.requireAuth).Post("/me/password", a.handleChangePassword)
|
||||
r.With(a.requireAuth).Post("/me/2fa/setup", a.handleTwoFactorSetup)
|
||||
r.With(a.requireAuth).Post("/me/2fa/enable", a.handleTwoFactorEnable)
|
||||
r.With(a.requireAuth).Post("/me/2fa/disable", a.handleTwoFactorDisable)
|
||||
r.With(a.requireAuth).Get("/me/contacts", a.handleListContacts)
|
||||
r.With(a.requireAuth).Post("/me/contacts", a.handleCreateContact)
|
||||
r.With(a.requireAuth).Delete("/me/contacts/{id}", a.handleDeleteContact)
|
||||
@@ -65,12 +69,33 @@ func (a *App) Router() http.Handler {
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(a.requireAuth)
|
||||
r.Use(a.requireAdmin)
|
||||
r.Get("/admin/overview", a.handleAdminOverview)
|
||||
r.Get("/admin/users", a.handleListUsers)
|
||||
r.Post("/admin/users", a.handleCreateUser)
|
||||
r.Post("/admin/users/{id}", a.handleUpdateUser)
|
||||
r.Post("/admin/users/{id}/password", a.handleResetUserPassword)
|
||||
r.Delete("/admin/users/{id}", a.handleDeleteUser)
|
||||
r.Get("/admin/domains", a.handleListDomains)
|
||||
r.Post("/admin/domains", a.handleCreateDomain)
|
||||
r.Post("/admin/domains/{id}", a.handleUpdateDomain)
|
||||
r.Delete("/admin/domains/{id}", a.handleDeleteDomain)
|
||||
r.Get("/admin/mailboxes", a.handleListMailboxes)
|
||||
r.Post("/admin/mailboxes", a.handleCreateMailbox)
|
||||
r.Post("/admin/mailboxes/{id}", a.handleUpdateMailbox)
|
||||
r.Delete("/admin/mailboxes/{id}", a.handleDeleteMailbox)
|
||||
r.Get("/admin/aliases", a.handleListAliases)
|
||||
r.Post("/admin/aliases", a.handleCreateAlias)
|
||||
r.Post("/admin/aliases/{id}", a.handleUpdateAlias)
|
||||
r.Delete("/admin/aliases/{id}", a.handleDeleteAlias)
|
||||
r.Get("/admin/messages", a.handleAdminMessages)
|
||||
r.Get("/admin/messages/{id}", a.handleAdminMessage)
|
||||
r.Get("/admin/attachments/{id}", a.handleAdminAttachment)
|
||||
r.Get("/admin/settings", a.handleGetSystemSettings)
|
||||
r.Post("/admin/settings", a.handleUpdateSystemSettings)
|
||||
r.Post("/admin/settings/test-smtp", a.handleTestSMTP)
|
||||
r.Get("/admin/mail-templates", a.handleListMailTemplates)
|
||||
r.Post("/admin/mail-templates/{key}", a.handleUpdateMailTemplate)
|
||||
r.Post("/admin/mail-templates/{key}/reset", a.handleResetMailTemplate)
|
||||
r.Get("/admin/domains/{id}/dns-records", a.handleDNSRecords)
|
||||
r.Post("/admin/domains/{id}/check-dns", a.handleDNSCheck)
|
||||
})
|
||||
@@ -99,13 +124,44 @@ func (a *App) corsMiddleware(next http.Handler) http.Handler {
|
||||
|
||||
func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
TurnstileToken string `json:"turnstileToken"`
|
||||
ChallengeToken string `json:"challengeToken"`
|
||||
TwoFactorCode string `json:"twoFactorCode"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.ChallengeToken) != "" {
|
||||
challenge, err := a.loginChallengeByToken(r.Context(), req.ChallengeToken)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusUnauthorized, "invalid verification challenge")
|
||||
return
|
||||
}
|
||||
user, secret, err := a.loadUserAuthByID(r.Context(), challenge.UserID)
|
||||
if err != nil || user.Disabled || !user.TwoFactorEnabled || strings.TrimSpace(secret) == "" {
|
||||
a.deleteLoginChallenge(r.Context(), challenge.ID)
|
||||
respondError(w, http.StatusUnauthorized, "invalid verification challenge")
|
||||
return
|
||||
}
|
||||
if !verifyTOTP(secret, req.TwoFactorCode, a.now().UTC()) {
|
||||
respondError(w, http.StatusUnauthorized, "invalid verification code")
|
||||
return
|
||||
}
|
||||
a.deleteLoginChallenge(r.Context(), challenge.ID)
|
||||
if err := a.issueSession(w, r, user.ID); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to create session")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"user": user})
|
||||
return
|
||||
}
|
||||
if err := a.verifyTurnstile(r.Context(), req.TurnstileToken, r.RemoteAddr); err != nil {
|
||||
respondError(w, http.StatusUnauthorized, "human verification failed")
|
||||
return
|
||||
}
|
||||
email := normalizeEmail(req.Email)
|
||||
user, passwordHash, err := a.userByEmail(r.Context(), email)
|
||||
if err != nil || user.Disabled {
|
||||
@@ -116,25 +172,19 @@ func (a *App) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusUnauthorized, "invalid email or password")
|
||||
return
|
||||
}
|
||||
token := randomToken()
|
||||
sessionID := newID("ses")
|
||||
expires := a.now().UTC().Add(time.Duration(a.cfg.SessionTTLHours) * time.Hour)
|
||||
_, err = a.db.ExecContext(r.Context(), `INSERT INTO sessions(id,user_id,token_hash,expires_at,created_at) VALUES(?,?,?,?,?)`,
|
||||
sessionID, user.ID, hashToken(token), expires.Format(time.RFC3339Nano), a.now().UTC().Format(time.RFC3339Nano))
|
||||
if err != nil {
|
||||
if a.cfg.TwoFactorEnabled && user.TwoFactorEnabled {
|
||||
challengeToken, err := a.createLoginChallenge(r.Context(), user.ID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to create verification challenge")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"twoFactorRequired": true, "challengeToken": challengeToken})
|
||||
return
|
||||
}
|
||||
if err := a.issueSession(w, r, user.ID); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to create session")
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: a.cfg.CookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
Expires: expires,
|
||||
MaxAge: int(time.Until(expires).Seconds()),
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: !a.cfg.AllowInsecureHTTP,
|
||||
})
|
||||
respondJSON(w, http.StatusOK, map[string]any{"user": user})
|
||||
}
|
||||
|
||||
@@ -265,16 +315,17 @@ func (a *App) authenticateRequest(r *http.Request) (*User, error) {
|
||||
if err != nil || cookie.Value == "" {
|
||||
return nil, errors.New("no session")
|
||||
}
|
||||
row := a.db.QueryRowContext(r.Context(), `SELECT u.id,u.email,u.display_name,u.role,u.disabled,u.created_at
|
||||
row := a.db.QueryRowContext(r.Context(), `SELECT u.id,u.email,u.display_name,u.role,u.disabled,u.two_factor_enabled,u.created_at
|
||||
FROM sessions s JOIN users u ON u.id=s.user_id
|
||||
WHERE s.token_hash=? AND s.expires_at > ?`, hashToken(cookie.Value), a.now().UTC().Format(time.RFC3339Nano))
|
||||
var u User
|
||||
var disabled int
|
||||
var disabled, twoFactorEnabled int
|
||||
var created string
|
||||
if err := row.Scan(&u.ID, &u.Email, &u.DisplayName, &u.Role, &disabled, &created); err != nil {
|
||||
if err := row.Scan(&u.ID, &u.Email, &u.DisplayName, &u.Role, &disabled, &twoFactorEnabled, &created); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.Disabled = intBool(disabled)
|
||||
u.TwoFactorEnabled = intBool(twoFactorEnabled)
|
||||
u.CreatedAt = parseTime(created)
|
||||
if u.Disabled {
|
||||
return nil, errors.New("disabled")
|
||||
@@ -283,34 +334,36 @@ func (a *App) authenticateRequest(r *http.Request) (*User, error) {
|
||||
}
|
||||
|
||||
func (a *App) userByEmail(ctx context.Context, email string) (*User, string, error) {
|
||||
row := a.db.QueryRowContext(ctx, `SELECT id,email,display_name,role,password_hash,disabled,created_at FROM users WHERE email=?`, email)
|
||||
row := a.db.QueryRowContext(ctx, `SELECT id,email,display_name,role,password_hash,disabled,two_factor_enabled,created_at FROM users WHERE email=?`, email)
|
||||
var u User
|
||||
var passwordHash string
|
||||
var disabled int
|
||||
var disabled, twoFactorEnabled int
|
||||
var created string
|
||||
if err := row.Scan(&u.ID, &u.Email, &u.DisplayName, &u.Role, &passwordHash, &disabled, &created); err != nil {
|
||||
if err := row.Scan(&u.ID, &u.Email, &u.DisplayName, &u.Role, &passwordHash, &disabled, &twoFactorEnabled, &created); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, "", errNotFound
|
||||
}
|
||||
return nil, "", err
|
||||
}
|
||||
u.Disabled = intBool(disabled)
|
||||
u.TwoFactorEnabled = intBool(twoFactorEnabled)
|
||||
u.CreatedAt = parseTime(created)
|
||||
return &u, passwordHash, nil
|
||||
}
|
||||
|
||||
func (a *App) userByID(ctx context.Context, id string) (*User, error) {
|
||||
row := a.db.QueryRowContext(ctx, `SELECT id,email,display_name,role,disabled,created_at FROM users WHERE id=?`, id)
|
||||
row := a.db.QueryRowContext(ctx, `SELECT id,email,display_name,role,disabled,two_factor_enabled,created_at FROM users WHERE id=?`, id)
|
||||
var u User
|
||||
var disabled int
|
||||
var disabled, twoFactorEnabled int
|
||||
var created string
|
||||
if err := row.Scan(&u.ID, &u.Email, &u.DisplayName, &u.Role, &disabled, &created); err != nil {
|
||||
if err := row.Scan(&u.ID, &u.Email, &u.DisplayName, &u.Role, &disabled, &twoFactorEnabled, &created); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, errNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
u.Disabled = intBool(disabled)
|
||||
u.TwoFactorEnabled = intBool(twoFactorEnabled)
|
||||
u.CreatedAt = parseTime(created)
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Role string `json:"role"`
|
||||
Disabled bool `json:"disabled"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Role string `json:"role"`
|
||||
Disabled bool `json:"disabled"`
|
||||
TwoFactorEnabled bool `json:"twoFactorEnabled"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type AdminUser struct {
|
||||
User
|
||||
MailboxCount int `json:"mailboxCount"`
|
||||
Mailboxes []string `json:"mailboxes"`
|
||||
}
|
||||
|
||||
type Domain struct {
|
||||
@@ -55,6 +62,9 @@ type MailFolder struct {
|
||||
type MailMessage struct {
|
||||
ID string `json:"id"`
|
||||
MailboxID string `json:"mailboxId,omitempty"`
|
||||
MailboxAddress string `json:"mailboxAddress,omitempty"`
|
||||
OwnerEmail string `json:"ownerEmail,omitempty"`
|
||||
RecipientAddr string `json:"recipientAddress,omitempty"`
|
||||
FolderID string `json:"folderId"`
|
||||
Folder string `json:"folder"`
|
||||
MessageUID string `json:"messageUid"`
|
||||
|
||||
Reference in New Issue
Block a user