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: "欢迎使用 NewSzxcn 邮箱", BodyText: "你的自建邮箱 Webmail 已经初始化完成。\n\n请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。", BodyHTML: "

你的自建邮箱 Webmail 已经初始化完成。

请尽快修改默认管理员密码,并配置 MX/SPF/DKIM/DMARC。

", UpdatedAt: now, }, { Key: smtpTestTemplateKey, Name: "SMTP 测试", Subject: "NewSzxcn 邮箱 SMTP 测试", BodyText: "这是一封 SMTP 测试邮件。\n\n发件人:{{from}}\n收件人:{{to}}\n时间:{{time}}\n主机:{{publicHostname}}", BodyHTML: "

这是一封 SMTP 测试邮件。

发件人:{{from}}
收件人:{{to}}
时间:{{time}}
主机:{{publicHostname}}

", 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 = "

" + htmlEscape(bodyText) + "

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