Files
jiang13-bbs/backend/service/operations_mail.go
freefire 3e55b5d230 feat: 管理端板块管理与旧版数据导入,补充部署运营文档
- 新增管理端板块管理页面与后端接口(admin_board)
- 新增旧版数据导入:legacyimport 服务、导入面板、importusers 命令行工具
- 聊天用户卡片、板块图标等 UI 组件与界面优化
- 补充 about/公告/1Panel 部署等文档
- gitignore 排除 dist/ 构建产物与 .agents/ 本地工具目录
2026-09-24 03:37:44 +08:00

490 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"github.com/freefire/jiang13-bbs/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
stdhtml "html"
"io"
"mime"
"net"
"net/mail"
"net/smtp"
"net/url"
"strconv"
"strings"
"time"
)
type mailPayload struct{ To, Subject, Body string }
func (o *Operations) smtp(ctx context.Context, c MailConfig, p *mailPayload, id string) error {
password, e := o.open(c.Password, "mail:password")
if e != nil {
return e
}
ctx, cancel := context.WithTimeout(ctx, time.Duration(c.Timeout)*time.Second)
defer cancel()
conn, e := safeDial(ctx, "tcp", net.JoinHostPort(c.Host, strconv.Itoa(c.Port)))
if e != nil {
return e
}
defer conn.Close()
deadline, _ := ctx.Deadline()
_ = conn.SetDeadline(deadline)
tlsCfg := &tls.Config{ServerName: c.Host, MinVersion: tls.VersionTLS12, RootCAs: o.smtpRoots}
if c.TLS == "tls" {
t := tls.Client(conn, tlsCfg)
if e = t.HandshakeContext(ctx); e != nil {
return errors.New("TLS 协商或证书校验失败")
}
conn = t
}
client, e := smtp.NewClient(conn, c.Host)
if e != nil {
return errors.New("SMTP 握手失败")
}
defer client.Close()
if c.TLS == "starttls" {
if ok, _ := client.Extension("STARTTLS"); !ok {
return errors.New("服务器未提供必需 STARTTLS")
}
if e = client.StartTLS(tlsCfg); e != nil {
return errors.New("TLS 协商或证书校验失败")
}
}
if e = client.Auth(smtp.PlainAuth("", c.Username, password, c.Host)); e != nil {
return errors.New("SMTP 认证失败")
}
if p == nil {
return nil
}
if !validEmail(p.To) {
return errors.New("测试收件邮箱无效")
}
if e = client.Mail(c.From); e != nil {
return errors.New("服务器拒绝发件人")
}
if e = client.Rcpt(p.To); e != nil {
return errors.New("服务器拒绝收件人")
}
w, e := client.Data()
if e != nil {
return errors.New("提交邮件失败")
}
if c.FromName == "" {
st, e := NewSettingService(o.db).Public()
if e == nil {
c.FromName = st.SiteName
}
}
from := (&mail.Address{Name: c.FromName, Address: c.From}).String()
reply := c.ReplyTo
if reply == "" {
reply = c.From
}
// QQ / 网关常按 7bit 路径转发:HTML 必须用 quoted-printable,避免长行与中文破坏 style。
var msg bytes.Buffer
msg.WriteString("From: " + from + "\r\n")
msg.WriteString("To: " + p.To + "\r\n")
msg.WriteString("Reply-To: " + reply + "\r\n")
msg.WriteString("Subject: " + mime.QEncoding.Encode("utf-8", p.Subject) + "\r\n")
msg.WriteString("Message-ID: <" + id + "@jiang13.local>\r\n")
msg.WriteString("MIME-Version: 1.0\r\n")
msg.WriteString("Content-Type: text/html; charset=UTF-8\r\n")
msg.WriteString("Content-Transfer-Encoding: quoted-printable\r\n\r\n")
if e = writeQuotedPrintableHTML(&msg, p.Body); e != nil {
return errors.New("提交邮件失败")
}
if _, e = io.Copy(w, &msg); e != nil {
return errors.New("提交邮件失败或超时")
}
if e = w.Close(); e != nil {
return errors.New("服务器未确认接受邮件")
}
return nil
}
const defaultMailSubject = "{{site_name}} · {{purpose}}"
const defaultMailBody = `<div class="email-wrap" style="margin:0;padding:32px 16px;background:#f5f7fb;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;line-height:1.6;color:#1e293b;">
<style>
.email-wrap{margin:0;padding:32px 16px;background:#f5f7fb;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;line-height:1.6;color:#1e293b;}
.email-card{max-width:520px;margin:0 auto;background:#fff;border:1px solid #eef2f6;border-radius:24px;box-shadow:0 12px 40px rgba(0,0,0,.06);overflow:hidden;padding:40px 36px 32px;}
.email-logo,.email-wrap img{display:block;margin:0 auto 24px;max-width:180px;height:auto;}
.email-title{margin:0 0 8px;font-size:22px;font-weight:650;color:#0f172a;text-align:center;letter-spacing:-.02em;}
.email-lead{margin:0 0 24px;font-size:15px;color:#475569;text-align:center;}
.email-code-box{margin:0 0 24px;padding:22px 16px;text-align:center;background:#f8fafc;border:1px solid #e2e8f0;border-radius:16px;}
.email-code-label{margin:0 0 8px;font-size:12px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:#475569;}
.email-code{margin:0;font-size:40px;font-weight:700;letter-spacing:.18em;line-height:1.2;color:#0f172a;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;}
.email-hint{margin:0 0 20px;font-size:14px;color:#334155;text-align:center;}
.email-btn-wrap{text-align:center;margin:0 0 18px;}
.email-btn{display:inline-block;padding:14px 32px;border-radius:999px;background:#1e293b;color:#fff !important;text-decoration:none;font-size:15px;font-weight:600;}
.email-link{margin:0 0 24px;font-size:12px;color:#475569;text-align:center;word-break:break-all;}
.email-link a{color:#1d4ed8;}
.email-hr{height:1px;margin:0 0 20px;background:#e2e8f0;border:0;}
.email-foot{margin:0;font-size:13px;line-height:1.7;color:#475569;text-align:center;}
.email-foot strong{color:#334155;font-weight:600;}
@media (max-width:480px){.email-card{padding:28px 20px;border-radius:20px;}.email-code{font-size:32px;letter-spacing:.12em;}.email-btn{display:block;}}
</style>
<div class="email-card" style="max-width:520px;margin:0 auto;background:#ffffff;border:1px solid #eef2f6;border-radius:24px;padding:40px 36px 32px;">
{{logo}}
<h1 class="email-title" style="margin:0 0 8px;font-size:22px;font-weight:650;color:#0f172a;text-align:center;">{{purpose}}</h1>
<p class="email-lead" style="margin:0 0 24px;font-size:15px;color:#475569;text-align:center;">您好,感谢使用 {{site_name}}。请使用下面的验证码完成{{purpose}}。</p>
<div class="email-code-box" style="margin:0 0 24px;padding:22px 16px;text-align:center;background:#f8fafc;border:1px solid #e2e8f0;border-radius:16px;">
<div class="email-code-label" style="margin:0 0 8px;font-size:12px;font-weight:600;letter-spacing:.08em;color:#475569;">您的验证码</div>
<div class="email-code" style="margin:0;font-size:40px;font-weight:700;letter-spacing:.18em;color:#0f172a;font-family:ui-monospace,Menlo,Consolas,monospace;">{{code}}</div>
</div>
<p class="email-hint" style="margin:0 0 20px;font-size:14px;color:#334155;text-align:center;">验证码 15 分钟内有效。您也可以点击下方按钮前往站点继续操作。</p>
<div class="email-btn-wrap" style="text-align:center;margin:0 0 18px;">
<a class="email-btn" href="{{link}}" target="_blank" rel="noopener noreferrer" style="display:inline-block;padding:14px 32px;border-radius:999px;background:#1e293b;color:#ffffff;text-decoration:none;font-size:15px;font-weight:600;">前往完成{{purpose}}</a>
</div>
<p class="email-link" style="margin:0 0 24px;font-size:12px;color:#475569;text-align:center;word-break:break-all;">若按钮无法点击,请复制链接:<br><a href="{{link}}" target="_blank" rel="noopener noreferrer" style="color:#1d4ed8;">{{link}}</a></p>
<div class="email-hr" style="height:1px;margin:0 0 20px;background:#e2e8f0;"></div>
<p class="email-foot" style="margin:0;font-size:13px;line-height:1.7;color:#475569;text-align:center;">本邮件由 <strong style="color:#334155;">{{site_name}}</strong> 系统发出,请勿直接回复。<br>如非本人操作,请忽略本邮件。<br><span style="color:#334155;">{{site_url}}</span></p>
</div>
</div>`
func (c *MailConfig) applyTemplateDefaults() {
if strings.TrimSpace(c.SubjectTemplate) == "" {
c.SubjectTemplate = defaultMailSubject
}
if strings.TrimSpace(c.BodyTemplate) == "" {
c.BodyTemplate = defaultMailBody
}
}
func (o *Operations) MailPreview(code string, kinds ...string) mailPayload {
kind := ""
if len(kinds) > 0 {
kind = kinds[0]
}
c := MailConfig{}
if o.db != nil {
if v, _, e := o.read(o.db, "mail"); e == nil {
c = *v.(*MailConfig)
}
}
return o.renderMail(c, code, kind)
}
func (o *Operations) renderMail(c MailConfig, code, kind string) mailPayload {
c.applyTemplateDefaults()
siteName, logoURL := "", ""
if o.db != nil {
if st, e := NewSettingService(o.db).Public(); e == nil {
siteName, logoURL = st.SiteName, st.LogoLightURL
}
}
purpose, path := "账号验证", "/register"
if kind == "reset" {
purpose, path = "密码找回", "/reset-password"
}
base, dev := "", false
if o.cfg != nil {
base = strings.TrimRight(o.cfg.SiteURL, "/")
dev = o.cfg.DevMode
}
link := ""
if base != "" {
link = base + path
}
absLogo := mailLogoURL(dev, base, logoURL)
body := strings.NewReplacer(
"{{site_name}}", stdhtml.EscapeString(siteName),
"{{purpose}}", stdhtml.EscapeString(purpose),
"{{code}}", stdhtml.EscapeString(code),
"{{link}}", stdhtml.EscapeString(link),
"{{site_url}}", stdhtml.EscapeString(base),
"{{logo_url}}", stdhtml.EscapeString(absLogo),
"{{logo}}", mailLogoImg(absLogo),
).Replace(c.BodyTemplate)
subject := strings.NewReplacer(
"{{site_name}}", mailOneLine(siteName),
"{{purpose}}", purpose,
"{{code}}", mailOneLine(code),
"{{link}}", mailOneLine(link),
"{{site_url}}", mailOneLine(base),
"{{logo_url}}", "",
"{{logo}}", "",
).Replace(c.SubjectTemplate)
return mailPayload{Subject: mailOneLine(subject), Body: prepareMailHTML(body)}
}
func mailOneLine(s string) string {
return strings.NewReplacer("\r", "", "\n", "").Replace(s)
}
func mailLogoURL(dev bool, base, logoURL string) string {
if logoURL == "" || base == "" {
return ""
}
origin, e := url.Parse(base)
ref, re := url.Parse(logoURL)
if e != nil || re != nil {
return ""
}
u := origin.ResolveReference(ref)
if u.Scheme != "https" && !(dev && u.Scheme == "http") {
return ""
}
return u.String()
}
func mailLogoImg(abs string) string {
if abs == "" {
return ""
}
return "<img class=\"email-logo\" width=\"180\" alt=\"站点 Logo\" src=\"" + stdhtml.EscapeString(abs) + "\" style=\"display:block;margin:0 auto 24px;max-width:180px;height:auto;\">"
}
func (o *Operations) TestMail(ctx context.Context, raw json.RawMessage, clear []string, send bool, to string, actor uint) error {
v, _, _, e := o.draft(o.db, "mail", raw, clear)
if e != nil {
return e
}
c := *v.(*MailConfig)
c.Enabled = true
if e = o.validate(o.db, "mail", &c); e != nil {
return e
}
var p *mailPayload
if send {
preview := o.renderMail(c, "测试邮件,无有效验证码", "")
preview.To = to
p = &preview
}
e = o.smtp(ctx, c, p, randomID())
result := "连接、TLS 与认证通过"
if send {
result = "服务器已接受(不代表送达)"
}
if e != nil {
result = e.Error()
}
o.Audit(actor, "mail", "test", result)
return e
}
func (o *Operations) EnqueueMail(tx *gorm.DB, to, kind, code, dedupe string) error {
p := o.MailPreview(code, kind)
p.To = to
b, _ := json.Marshal(p)
encrypted, e := o.seal(string(b), "mail-task")
if e != nil {
return e
}
masked := "***"
parts := strings.Split(to, "@")
if len(parts) == 2 {
masked = "***@" + parts[1]
}
return tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&model.MailTask{Dedupe: dedupe, Kind: kind, Recipient: masked, Payload: encrypted, Status: "queued", NextAt: time.Now()}).Error
}
// A lease is committed before I/O; expired leases are recovered after a restart.
func (o *Operations) ProcessMail(ctx context.Context) error {
v, _, e := o.read(o.db, "mail")
if e != nil {
return e
}
c := *v.(*MailConfig)
if !c.Enabled {
return nil
}
o.db.Model(&model.MailTask{}).Where("status = ? AND attempts >= 3 AND next_at <= ?", "sending", time.Now()).Updates(map[string]any{"status": "failed", "summary": "重试次数已用完,最终接受状态未知", "payload": ""})
var task model.MailTask
e = o.db.Transaction(func(tx *gorm.DB) error {
if e := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"}).Where("status IN ? AND next_at <= ?", []string{"queued", "retry", "sending"}, time.Now()).Order("id").First(&task).Error; e != nil {
return e
}
task.Attempts++
task.Status = "sending"
task.NextAt = time.Now().Add(2 * time.Minute)
return tx.Save(&task).Error
})
if errors.Is(e, gorm.ErrRecordNotFound) {
return nil
}
if e != nil {
return e
}
plaintext, e := o.open(task.Payload, "mail-task")
var p mailPayload
if e == nil {
e = json.Unmarshal([]byte(plaintext), &p)
}
if e == nil {
e = o.smtp(ctx, c, &p, task.Dedupe)
}
status, summary := "accepted", "服务器已接受(不代表送达)"
if e != nil {
status = "retry"
summary = "发送失败,请检查邮件服务"
if task.Attempts >= 3 {
status = "failed"
}
}
updates := map[string]any{"status": status, "summary": summary, "next_at": time.Now().Add(time.Duration(task.Attempts) * time.Minute)}
if status == "accepted" || status == "failed" {
updates["payload"] = ""
}
return o.db.Model(&model.MailTask{}).Where("id = ? AND attempts = ? AND status = ?", task.ID, task.Attempts, "sending").Updates(updates).Error
}
func (o *Operations) Run(ctx context.Context) {
tick := time.NewTicker(2 * time.Second)
defer tick.Stop()
n := 0
for {
select {
case <-ctx.Done():
return
case <-tick.C:
_ = o.ProcessMail(ctx)
n++
if n%300 == 0 {
o.db.Where("expires_at < ?", time.Now()).Delete(&model.ActionCounter{})
o.db.Where("expires_at < ?", time.Now().Add(-24*time.Hour)).Delete(&model.EmailChallenge{})
}
}
}
}
func (o *Operations) MailRows() ([]model.MailTask, error) {
var a []model.MailTask
e := o.db.Order("id desc").Limit(100).Find(&a).Error
return a, e
}
func (o *Operations) SendCode(email, purpose, ip string) (int, error) {
email = strings.ToLower(strings.TrimSpace(email))
if !validEmail(email) {
return 0, errors.New("邮箱格式无效")
}
cfg, e := o.Security()
if e != nil {
return 0, e
}
if purpose != "register" && purpose != "reset" {
return 0, errors.New("用途无效")
}
if (purpose == "register" && (!cfg.VerifyEmail || !cfg.AllowRegister)) || (purpose == "reset" && !cfg.PasswordReset) {
return 0, errors.New("该账号邮件功能未开启")
}
for _, q := range []struct {
k string
n, s int
}{{"email:ip:" + ip, 20, 3600}, {"email:resend:" + purpose + ":" + email, 1, cfg.ResendInterval}, {"email:hour:" + email, cfg.EmailHourly, 3600}} {
wait, e := o.Quota(q.k, q.n, q.s)
if e != nil || wait > 0 {
return wait, e
}
}
if purpose == "reset" {
var n int64
if e = o.db.Model(&model.User{}).Where("LOWER(email) = ?", email).Count(&n).Error; e != nil {
return 0, e
}
if n != 1 {
return 0, nil
}
}
code := randomCode()
hash := counterKey(email + ":" + purpose + ":" + code)
e = o.db.Transaction(func(tx *gorm.DB) error {
if e := tx.Create(&model.EmailChallenge{Hash: hash, Email: email, Purpose: purpose, ExpiresAt: time.Now().Add(15 * time.Minute)}).Error; e != nil {
return e
}
return o.EnqueueMail(tx, email, purpose, code, hash)
})
return 0, e
}
const (
codeVerifyMaxAttempts = 5
codeVerifyWindow = 15 * 60 // 秒,与验证码有效期一致
)
// CodeThrottleError 表示验证码校验失败次数超限,携带需等待的秒数。
type CodeThrottleError struct{ Wait int }
func (e *CodeThrottleError) Error() string {
return fmt.Sprintf("验证码错误次数过多,请 %d 秒后重试", e.Wait)
}
func (o *Operations) ConsumeCode(email, purpose, code string) error {
email = strings.ToLower(strings.TrimSpace(email))
failKey := "verifyfail:" + purpose + ":" + email
// 失败次数限流:窗口内错误次数超限则拒绝,防止 6 位数字码被暴力枚举。
if wait, e := o.verifyFailWait(failKey); e != nil {
return e
} else if wait > 0 {
return &CodeThrottleError{Wait: wait}
}
r := o.db.Model(&model.EmailChallenge{}).Where("hash = ? AND used = false AND expires_at > ?", counterKey(email+":"+purpose+":"+code), time.Now()).Update("used", true)
if r.Error != nil {
return r.Error
}
if r.RowsAffected != 1 {
if _, e := o.Quota(failKey, codeVerifyMaxAttempts, codeVerifyWindow); e != nil {
return e
}
return fmt.Errorf("验证码无效或已过期")
}
// 校验成功,清除失败计数。
o.db.Delete(&model.ActionCounter{}, "key = ?", counterKey(failKey))
return nil
}
// verifyFailWait 读取当前失败计数,若已达上限返回需等待的秒数,不修改计数。
func (o *Operations) verifyFailWait(key string) (int, error) {
var c model.ActionCounter
e := o.db.First(&c, "key = ?", counterKey(key)).Error
if e != nil {
if errors.Is(e, gorm.ErrRecordNotFound) {
return 0, nil
}
return 0, e
}
if time.Now().Before(c.ExpiresAt) && c.Count >= codeVerifyMaxAttempts {
return int(time.Until(c.ExpiresAt).Seconds()) + 1, nil
}
return 0, nil
}
// Probe merged drafts so partial API updates cannot bypass activation validation.
func (o *Operations) ProbeBeforeSave(ctx context.Context, name string, raw json.RawMessage, clear []string) error {
v, _, _, e := o.draft(o.db, name, raw, clear)
if e != nil {
return e
}
if e = o.validate(o.db, name, v); e != nil {
return e
}
switch c := v.(type) {
case *StorageConfig:
return o.TestStorage(ctx, raw, clear)
case *MailConfig:
if c.Enabled {
return o.smtp(ctx, *c, nil, randomID())
}
case *SecurityConfig:
old, e := o.Security()
if e != nil {
return e
}
if (c.VerifyEmail && !old.VerifyEmail) || (c.PasswordReset && !old.PasswordReset) {
mail, _, e := o.read(o.db, "mail")
if e != nil {
return e
}
return o.smtp(ctx, *mail.(*MailConfig), nil, randomID())
}
}
return nil
}