Files
NewSzxcn-Email/apps/api/internal/app/template_handlers.go
T
LanQin 8a042ae3dc feat(admin): 扩展后台管理与登录安全能力。
- 新增用户、域名、邮箱、别名、邮件、系统设置与模板的管理接口和页面。
- 支持双因素认证、Turnstile、人机验证与管理员 SMTP 测试。
- 增加无人收件/未注册邮件归档、Maildir 同步和数据库迁移支持。
- 更新前端导航、个人中心 2FA 配置以及相关部署示例。
2026-06-15 00:38:51 +08:00

211 lines
6.5 KiB
Go

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),
}
}