- 新增管理端板块管理页面与后端接口(admin_board) - 新增旧版数据导入:legacyimport 服务、导入面板、importusers 命令行工具 - 聊天用户卡片、板块图标等 UI 组件与界面优化 - 补充 about/公告/1Panel 部署等文档 - gitignore 排除 dist/ 构建产物与 .agents/ 本地工具目录
490 lines
17 KiB
Go
490 lines
17 KiB
Go
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
|
||
}
|