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 = `
{{logo}}
{{purpose}}
您好,感谢使用 {{site_name}}。请使用下面的验证码完成{{purpose}}。
验证码 15 分钟内有效。您也可以点击下方按钮前往站点继续操作。
若按钮无法点击,请复制链接:
{{link}}
`
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 "
"
}
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 := randomID()
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
}
func (o *Operations) ConsumeCode(email, purpose, code string) error {
r := o.db.Model(&model.EmailChallenge{}).Where("hash = ? AND used = false AND expires_at > ?", counterKey(strings.ToLower(strings.TrimSpace(email))+":"+purpose+":"+code), time.Now()).Update("used", true)
if r.Error != nil {
return r.Error
}
if r.RowsAffected != 1 {
return fmt.Errorf("验证码无效或已过期")
}
return 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
}