2026-06-15 00:37:43 +08:00
|
|
|
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: "欢迎邮件",
|
2026-08-02 15:07:16 +08:00
|
|
|
Subject: "欢迎使用 NewSzxcn 邮箱",
|
2026-06-15 00:37:43 +08:00
|
|
|
BodyText: "你的自建邮箱 Webmail 已经初始化完成。\n\n请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。",
|
|
|
|
|
BodyHTML: "<p>你的自建邮箱 Webmail 已经初始化完成。</p><p>请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。</p>",
|
|
|
|
|
UpdatedAt: now,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
Key: smtpTestTemplateKey,
|
|
|
|
|
Name: "SMTP 测试",
|
2026-08-02 15:07:16 +08:00
|
|
|
Subject: "NewSzxcn 邮箱 SMTP 测试",
|
2026-06-15 00:37:43 +08:00
|
|
|
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),
|
|
|
|
|
}
|
|
|
|
|
}
|