feat(admin): 扩展后台管理与登录安全能力。
- 新增用户、域名、邮箱、别名、邮件、系统设置与模板的管理接口和页面。 - 支持双因素认证、Turnstile、人机验证与管理员 SMTP 测试。 - 增加无人收件/未注册邮件归档、Maildir 同步和数据库迁移支持。 - 更新前端导航、个人中心 2FA 配置以及相关部署示例。
This commit is contained in:
@@ -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),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user