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 } // ErrEmailRegistered 注册发码时邮箱已被占用,handler 据此返回 400 业务提示 var ErrEmailRegistered = errors.New("该邮箱已被注册,请直接登录") 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}}

` 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 } } if purpose == "register" { // 入口拦截:邮箱已被占用则不发码,省一次邮件资源,也给用户明确提示 var n int64 if e = o.db.Model(&model.User{}).Where("LOWER(email) = ?", email).Count(&n).Error; e != nil { return 0, e } if n > 0 { return 0, ErrEmailRegistered } } 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 }