Feat: email smtp support

pull/116/head
zijiren233 11 months ago
parent 04a411d629
commit 6f961c5583

@ -3,8 +3,11 @@ module github.com/synctv-org/synctv
go 1.21
require (
github.com/Boostport/mjml-go v0.14.6
github.com/caarlos0/env/v9 v9.0.0
github.com/cavaliergopher/grab/v3 v3.0.1
github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43
github.com/emersion/go-smtp v0.21.0
github.com/gin-contrib/cors v1.7.1
github.com/gin-gonic/gin v1.9.1
github.com/glebarez/sqlite v1.11.0
@ -24,6 +27,7 @@ require (
github.com/json-iterator/go v1.1.12
github.com/maruel/natural v1.1.1
github.com/mitchellh/go-homedir v1.1.0
github.com/mojocn/base64Captcha v1.3.6
github.com/natefinch/lumberjack v2.0.0+incompatible
github.com/quic-go/quic-go v0.42.0
github.com/sirupsen/logrus v1.9.3
@ -32,7 +36,7 @@ require (
github.com/synctv-org/vendors v0.3.3-0.20240329144101-a35c08863ac6
github.com/ulule/limiter/v3 v3.11.2
github.com/zencoder/go-dash/v3 v3.0.3
github.com/zijiren233/gencontainer v0.0.0-20240214185550-64325761736f
github.com/zijiren233/gencontainer v0.0.0-20240331174346-b5e420773df7
github.com/zijiren233/go-colorable v0.0.0-20230930131441-997304c961cb
github.com/zijiren233/livelib v0.3.1
github.com/zijiren233/stream v0.5.2
@ -54,6 +58,7 @@ require (
cloud.google.com/go/compute/metadata v0.2.3 // indirect
filippo.io/edwards25519 v1.1.0 // indirect
github.com/BurntSushi/toml v1.3.2 // indirect
github.com/andybalholm/brotli v1.1.0 // indirect
github.com/armon/go-metrics v0.4.1 // indirect
github.com/bytedance/sonic v1.11.3 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
@ -71,6 +76,7 @@ require (
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/pprof v0.0.0-20240327155427-868f304927ed // indirect
@ -106,6 +112,7 @@ require (
github.com/pkg/errors v0.9.1 // indirect
github.com/quic-go/qpack v0.4.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/tetratelabs/wazero v1.6.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
go.etcd.io/etcd/api/v3 v3.5.12 // indirect
@ -114,6 +121,7 @@ require (
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.0 // indirect
golang.org/x/arch v0.7.0 // indirect
golang.org/x/image v0.13.0 // indirect
golang.org/x/mod v0.16.0 // indirect
golang.org/x/net v0.22.0 // indirect
golang.org/x/sync v0.6.0 // indirect

@ -4,6 +4,8 @@ cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGB
cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/Boostport/mjml-go v0.14.6 h1:pzDN2KPqy9smNzYesHzsIFflA3eyZf24jNYSuBHbtB8=
github.com/Boostport/mjml-go v0.14.6/go.mod h1:dP8/GHUYxLGi1S+GCkhAB0ANcRx6rR8+Pc91JjtKLLU=
github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
@ -11,6 +13,8 @@ github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuy
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJA=
@ -54,6 +58,11 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43 h1:hH4PQfOndHDlpzYfLAAfl63E8Le6F2+EL/cdhlkyRJY=
github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-smtp v0.21.0 h1:ZDZmX9aFUuPlD1lpoT0nC/nozZuIkSCyQIyxdijjCy0=
github.com/emersion/go-smtp v0.21.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
github.com/envoyproxy/go-control-plane v0.12.0 h1:4X+VP1GHd1Mhj6IB5mMeGbLCleqxjletLK6K0rbxyZI=
github.com/envoyproxy/go-control-plane v0.12.0/go.mod h1:ZBTaoJ23lqITozF0M6G4/IragXCQKCnYbmlmtHvwRG0=
github.com/envoyproxy/protoc-gen-validate v1.0.4 h1:gVPz/FMfvh57HdSJQyvBtF00j8JU4zdyUgIUNhlgg0A=
@ -112,6 +121,8 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@ -271,6 +282,8 @@ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lN
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mojocn/base64Captcha v1.3.6 h1:gZEKu1nsKpttuIAQgWHO+4Mhhls8cAKyiV2Ew03H+Tw=
github.com/mojocn/base64Captcha v1.3.6/go.mod h1:i5CtHvm+oMbj1UzEPXaA8IH/xHFZ3DGY3Wh3dBpZ28E=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/natefinch/lumberjack v2.0.0+incompatible h1:4QJd3OLAMgj7ph+yZTuX13Ld4UpgHp07nNdFX7mqFfM=
github.com/natefinch/lumberjack v2.0.0+incompatible/go.mod h1:Wi9p2TTF5DG5oU+6YfsmYQpsTIOm0B1VNzQg9Mw6nPk=
@ -345,6 +358,8 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/synctv-org/vendors v0.3.3-0.20240329144101-a35c08863ac6 h1:EdhhBGmjrL/9pTnp+oGT+lPD8hBYE0NH70HeopPAsNE=
github.com/synctv-org/vendors v0.3.3-0.20240329144101-a35c08863ac6/go.mod h1:VF4uTsi7KBpBXaSV0ycAs2fo7KS08eF9hIB/ugvzIbY=
github.com/tetratelabs/wazero v1.6.0 h1:z0H1iikCdP8t+q341xqepY4EWvHEw8Es7tlqiVzlP3g=
github.com/tetratelabs/wazero v1.6.0/go.mod h1:0U0G41+ochRKoPKCJlh0jMg1CHkyfK8kDqiirMmKY8A=
github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
@ -357,8 +372,8 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zencoder/go-dash/v3 v3.0.3 h1:xqwGJ2fJCSArwONGx6sY26Z1lxQ7zTURoxdRjCpuodM=
github.com/zencoder/go-dash/v3 v3.0.3/go.mod h1:30R5bKy1aUYY45yesjtZ9l8trNc2TwNqbS17WVQmCzk=
github.com/zijiren233/gencontainer v0.0.0-20240214185550-64325761736f h1:2zrspSbXHBnJmWhhkr1kphJ0Ql2yciAkIzYDu3e8cn0=
github.com/zijiren233/gencontainer v0.0.0-20240214185550-64325761736f/go.mod h1:V5oL7PrZxgisuLCblFWd89Jg99O8vM1n58llcxZ2hDY=
github.com/zijiren233/gencontainer v0.0.0-20240331174346-b5e420773df7 h1:ymsEhM4NrTiZx/nyJb5CQRVOmdgZm6L6hHcRUErXYVQ=
github.com/zijiren233/gencontainer v0.0.0-20240331174346-b5e420773df7/go.mod h1:V5oL7PrZxgisuLCblFWd89Jg99O8vM1n58llcxZ2hDY=
github.com/zijiren233/go-colorable v0.0.0-20230930131441-997304c961cb h1:0DyOxf/TbbGodHhOVHNoPk+7v/YBJACs22gKpKlatWw=
github.com/zijiren233/go-colorable v0.0.0-20230930131441-997304c961cb/go.mod h1:6TCzjDiQ8+5gWZiwsC3pnA5M0vUy2jV2Y7ciHJh729g=
github.com/zijiren233/livelib v0.3.1 h1:vNGQFeVyk1qrXTO/lqyRs0oC6cLzMD6yo2Jdym3XNpI=
@ -397,6 +412,8 @@ golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 h1:aAcj0Da7eBAtrTp03QXWvm88pSyOt+UgdZw2BFZ+lEw=
golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8/go.mod h1:CQ1k9gNrJ50XIzaKCRR2hssIjF07kZFEiieALBM/ARQ=
golang.org/x/image v0.13.0 h1:3cge/F/QTkNLauhf2QoE9zp+7sr+ZcL4HnoZmdwg9sg=
golang.org/x/image v0.13.0/go.mod h1:6mmbMOeV28HuMTgA6OSRkdXKYw/t5W9Uwn2Yv1r3Yxk=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=

@ -0,0 +1,13 @@
package captcha
import (
"github.com/mojocn/base64Captcha"
)
var (
Captcha *base64Captcha.Captcha
)
func init() {
Captcha = base64Captcha.NewCaptcha(base64Captcha.DefaultDriverDigit, base64Captcha.DefaultMemStore)
}

@ -57,6 +57,18 @@ func WithRegisteredByProvider(b bool) CreateUserConfig {
}
}
func WithEmail(email string) CreateUserConfig {
return func(u *model.User) {
u.Email = email
}
}
func WithRegisteredByEmail(b bool) CreateUserConfig {
return func(u *model.User) {
u.RegisteredByEmail = b
}
}
func CreateUserWithHashedPassword(username string, hashedPassword []byte, conf ...CreateUserConfig) (*model.User, error) {
if username == "" {
return nil, errors.New("username cannot be empty")
@ -128,8 +140,10 @@ func CreateOrLoadUserWithHashedPassword(username string, hashedPassword []byte,
// 只有当provider和puid没有找到对应的user时才会创建
func CreateOrLoadUserWithProvider(username, password string, p provider.OAuth2Provider, puid string, conf ...CreateUserConfig) (*model.User, error) {
if puid == "" {
return nil, errors.New("provider user id cannot be empty")
}
var user model.User
if err := db.Where("id = (?)", db.Table("user_providers").Where("provider = ? AND provider_user_id = ?", p, puid).Select("user_id")).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return CreateUser(username, password, append(conf, WithSetProvider(p, puid), WithRegisteredByProvider(true))...)
@ -141,12 +155,40 @@ func CreateOrLoadUserWithProvider(username, password string, p provider.OAuth2Pr
}
}
func CreateOrLoadUserWithEmail(username, password, email string, conf ...CreateUserConfig) (*model.User, error) {
if email == "" {
return nil, errors.New("email cannot be empty")
}
var user model.User
if err := db.Where("email = ?", email).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return CreateUser(username, password, append(conf, WithEmail(email), WithRegisteredByEmail(true))...)
} else {
return nil, err
}
}
return &user, nil
}
func CreateUserWithEmail(username, password, email string, conf ...CreateUserConfig) (*model.User, error) {
if email == "" {
return nil, errors.New("email cannot be empty")
}
return CreateUser(username, password, append(conf, WithEmail(email), WithRegisteredByEmail(true))...)
}
func GetUserByProvider(p provider.OAuth2Provider, puid string) (*model.User, error) {
var user model.User
err := db.Where("id = (?)", db.Table("user_providers").Where("provider = ? AND provider_user_id = ?", p, puid).Select("user_id")).First(&user).Error
return &user, HandleNotFound(err, "user")
}
func GetUserByEmail(email string) (*model.User, error) {
var user model.User
err := db.Where("email = ?", email).First(&user).Error
return &user, HandleNotFound(err, "user")
}
func GetProviderUserID(p provider.OAuth2Provider, puid string) (string, error) {
var userProvider model.UserProvider
err := db.Where("provider = ? AND provider_user_id = ?", p, puid).Select("user_id").First(&userProvider).Error
@ -368,3 +410,24 @@ func SetUserHashedPassword(id string, hashedPassword []byte) error {
err := db.Model(&model.User{}).Where("id = ?", id).Update("hashed_password", hashedPassword).Error
return HandleNotFound(err, "user")
}
func BindEmail(id string, email string) error {
err := db.Model(&model.User{}).Where("id = ?", id).Update("email", email).Error
return HandleNotFound(err, "user")
}
func UnbindEmail(uid string) error {
return Transactional(func(tx *gorm.DB) error {
user := model.User{}
if err := tx.Where("id = ?", uid).First(&user).Error; err != nil {
return HandleNotFound(err, "user")
}
if user.Email == "" {
return errors.New("user has no email")
}
if user.RegisteredByEmail {
return errors.New("user must have one email")
}
return tx.Model(&model.User{}).Where("id = ?", uid).Update("email", "").Error
})
}

@ -0,0 +1,378 @@
package email
import (
"bytes"
"context"
"errors"
"fmt"
"net/url"
"text/template"
"time"
"github.com/Boostport/mjml-go"
log "github.com/sirupsen/logrus"
email_template "github.com/synctv-org/synctv/internal/email/template"
"github.com/synctv-org/synctv/internal/model"
"github.com/synctv-org/synctv/internal/settings"
"github.com/synctv-org/synctv/utils"
"github.com/zijiren233/gencontainer/synccache"
"github.com/zijiren233/stream"
)
var (
ErrEmailNotEnabled = errors.New("email is not enabled")
emailCaptcha *synccache.SyncCache[string, string] = synccache.NewSyncCache[string, string](time.Minute * 5)
)
var (
EnableEmail = settings.NewBoolSetting(
"enable_email",
false,
model.SettingGroupEmail,
settings.WithAfterSetBool(func(bs settings.BoolSetting, b bool) {
if !b {
closeSmtpPool()
}
}),
)
DisableUserSignup = settings.NewBoolSetting(
"email_disable_user_signup",
false,
model.SettingGroupEmail,
)
SignupNeedReview = settings.NewBoolSetting(
"email_signup_need_review",
false,
model.SettingGroupEmail,
)
RetrievePasswordUrlPath = settings.NewStringSetting(
"email_retrieve_password_url_path",
"/web/retrievePassword",
model.SettingGroupEmail,
)
EmailSignupWhiteListEnable = settings.NewBoolSetting(
"email_signup_white_list_enable",
false,
model.SettingGroupEmail,
)
EmailSignupWhiteList = settings.NewStringSetting(
"email_signup_white_list",
`gmail.com,qq.com,163.com,yahoo.com,sina.com,126.com,outlook.com,yeah.net,foxmail.com`,
model.SettingGroupEmail,
)
)
var (
testTemplate *template.Template
captchaTemplate *template.Template
retrievePasswordTemplate *template.Template
)
func init() {
body, err := mjml.ToHTML(
context.Background(),
stream.BytesToString(email_template.TestMjml),
mjml.WithMinify(true),
)
if err != nil {
log.Fatalf("mjml test template error: %v", err)
}
t, err := template.New("").Parse(body)
if err != nil {
log.Fatalf("parse test template error: %v", err)
}
testTemplate = t
body, err = mjml.ToHTML(
context.Background(),
stream.BytesToString(email_template.CaptchaMjml),
mjml.WithMinify(true),
)
if err != nil {
log.Fatalf("mjml captcha template error: %v", err)
}
t, err = template.New("").Parse(body)
if err != nil {
log.Fatalf("parse captcha template error: %v", err)
}
captchaTemplate = t
body, err = mjml.ToHTML(
context.Background(),
stream.BytesToString(email_template.RetrievePasswordMjml),
mjml.WithMinify(true),
)
if err != nil {
log.Fatalf("mjml retrieve password template error: %v", err)
}
t, err = template.New("").Parse(body)
if err != nil {
log.Fatalf("parse retrieve password template error: %v", err)
}
retrievePasswordTemplate = t
}
type testPayload struct {
Username string
Year int
}
type captchaPayload struct {
Captcha string
Year int
}
type retrievePasswordPayload struct {
Host string
Url string
Year int
}
func SendBindCaptchaEmail(userID, userEmail string) error {
if !EnableEmail.Get() {
return ErrEmailNotEnabled
}
if userID == "" {
return errors.New("user id is empty")
}
if userEmail == "" {
return errors.New("email is empty")
}
pool, err := getSmtpPool()
if err != nil {
return err
}
entry, loaded := emailCaptcha.LoadOrStore(
fmt.Sprintf("bind:%s:%s", userID, userEmail),
utils.RandString(6),
time.Minute*5,
)
if loaded {
entry.SetExpiration(time.Now().Add(time.Minute * 5))
}
out := bytes.NewBuffer(nil)
err = captchaTemplate.Execute(out, captchaPayload{
Captcha: entry.Value(),
Year: time.Now().Year(),
})
if err != nil {
return err
}
return pool.SendEmail(
[]string{userEmail},
"SyncTV Verification Code",
out.String(),
)
}
func VerifyBindCaptchaEmail(userID, userEmail, captcha string) (bool, error) {
if !EnableEmail.Get() {
return false, ErrEmailNotEnabled
}
if userID == "" {
return false, errors.New("user id is empty")
}
if userEmail == "" {
return false, errors.New("email is empty")
}
if captcha == "" {
return false, errors.New("captcha is empty")
}
key := fmt.Sprintf("bind:%s:%s", userID, userEmail)
if emailCaptcha.CompareValueAndDelete(
key,
captcha,
) {
return true, nil
}
return false, nil
}
func SendTestEmail(username, email string) error {
if !EnableEmail.Get() {
return ErrEmailNotEnabled
}
if email == "" {
return errors.New("email is empty")
}
pool, err := getSmtpPool()
if err != nil {
return err
}
out := bytes.NewBuffer(nil)
err = testTemplate.Execute(out, testPayload{
Username: username,
Year: time.Now().Year(),
})
if err != nil {
return err
}
return pool.SendEmail(
[]string{email},
"SyncTV Test Email",
out.String(),
)
}
func SendSignupCaptchaEmail(email string) error {
if !EnableEmail.Get() {
return ErrEmailNotEnabled
}
if email == "" {
return errors.New("email is empty")
}
pool, err := getSmtpPool()
if err != nil {
return err
}
entry, loaded := emailCaptcha.LoadOrStore(
fmt.Sprintf("signup:%s", email),
utils.RandString(6),
time.Minute*5,
)
if loaded {
entry.SetExpiration(time.Now().Add(time.Minute * 5))
}
out := bytes.NewBuffer(nil)
err = captchaTemplate.Execute(out, captchaPayload{
Captcha: entry.Value(),
Year: time.Now().Year(),
})
if err != nil {
return err
}
return pool.SendEmail(
[]string{email},
"SyncTV Signup Verification Code",
out.String(),
)
}
func VerifySignupCaptchaEmail(email, captcha string) (bool, error) {
if !EnableEmail.Get() {
return false, ErrEmailNotEnabled
}
if email == "" {
return false, errors.New("email is empty")
}
if captcha == "" {
return false, errors.New("captcha is empty")
}
if emailCaptcha.CompareValueAndDelete(
fmt.Sprintf("signup:%s", email),
captcha,
) {
return true, nil
}
return false, nil
}
func SendRetrievePasswordCaptchaEmail(userID, email, host string) error {
if !EnableEmail.Get() {
return ErrEmailNotEnabled
}
if userID == "" {
return errors.New("user id is empty")
}
if email == "" {
return errors.New("email is empty")
}
u, err := url.Parse(host)
if err != nil {
return err
}
u.Path = RetrievePasswordUrlPath.Get()
pool, err := getSmtpPool()
if err != nil {
return err
}
entry, loaded := emailCaptcha.LoadOrStore(
fmt.Sprintf("retrieve_password:%s:%s", userID, email),
utils.RandString(6),
time.Minute*5,
)
if loaded {
entry.SetExpiration(time.Now().Add(time.Minute * 5))
}
q := u.Query()
q.Set("userID", userID)
q.Set("captcha", entry.Value())
q.Set("email", email)
u.RawQuery = q.Encode()
out := bytes.NewBuffer(nil)
err = retrievePasswordTemplate.Execute(out, retrievePasswordPayload{
Host: host,
Url: u.String(),
Year: time.Now().Year(),
})
if err != nil {
return err
}
return pool.SendEmail(
[]string{email},
"SyncTV Retrieve Password Verification Code",
out.String(),
)
}
func VerifyRetrievePasswordCaptchaEmail(userID, email, captcha string) (bool, error) {
if !EnableEmail.Get() {
return false, ErrEmailNotEnabled
}
if userID == "" {
return false, errors.New("user id is empty")
}
if email == "" {
return false, errors.New("email is empty")
}
if captcha == "" {
return false, errors.New("captcha is empty")
}
if emailCaptcha.CompareValueAndDelete(
fmt.Sprintf("retrieve_password:%s:%s", userID, email),
captcha,
) {
return true, nil
}
return false, nil
}

@ -0,0 +1,168 @@
package email
import (
"fmt"
"strings"
"sync"
"github.com/synctv-org/synctv/internal/model"
"github.com/synctv-org/synctv/internal/settings"
"github.com/synctv-org/synctv/utils/smtp"
)
var (
smtpPool *smtp.SmtpPool
configChanged bool
lock sync.Mutex
)
var (
smtpHost = settings.NewStringSetting(
"smtp_host",
"",
model.SettingGroupEmail,
settings.WithAfterSetString(func(ss settings.StringSetting, s string) {
lock.Lock()
defer lock.Unlock()
configChanged = true
}),
)
smtpPort = settings.NewInt64Setting(
"smtp_port",
587,
model.SettingGroupEmail,
settings.WithValidatorInt64(func(i int64) error {
if i <= 0 {
return fmt.Errorf("smtp port must be greater than 0")
}
if i > 65535 {
return fmt.Errorf("smtp port must be less than 65535")
}
return nil
}),
settings.WithAfterSetInt64(func(ss settings.Int64Setting, i int64) {
lock.Lock()
defer lock.Unlock()
configChanged = true
}),
)
smtpProtocol = settings.NewStringSetting(
"smtp_protocol",
"TLS",
model.SettingGroupEmail,
settings.WithValidatorString(func(s string) error {
s = strings.ToLower(s)
switch s {
case "tcp", "tls", "ssl", "":
return nil
default:
return fmt.Errorf("smtp protocol must be tcp, tls or ssl")
}
}),
settings.WithAfterSetString(func(ss settings.StringSetting, s string) {
lock.Lock()
defer lock.Unlock()
configChanged = true
}),
)
smtpUsername = settings.NewStringSetting(
"smtp_username",
"",
model.SettingGroupEmail,
settings.WithAfterSetString(func(ss settings.StringSetting, s string) {
lock.Lock()
defer lock.Unlock()
configChanged = true
}),
)
smtpPassword = settings.NewStringSetting(
"smtp_password",
"",
model.SettingGroupEmail,
settings.WithAfterSetString(func(ss settings.StringSetting, s string) {
lock.Lock()
defer lock.Unlock()
configChanged = true
}),
)
smtpFrom = settings.NewStringSetting(
"smtp_from",
"",
model.SettingGroupEmail,
settings.WithAfterSetString(func(ss settings.StringSetting, s string) {
lock.Lock()
defer lock.Unlock()
if smtpPool != nil {
smtpPool.SetFrom(s)
}
}),
)
smtpPoolSize = settings.NewInt64Setting(
"smtp_pool_size",
10,
model.SettingGroupEmail,
settings.WithValidatorInt64(func(i int64) error {
if i <= 0 {
return fmt.Errorf("smtp pool size must be greater than 0")
}
if i > 100 {
return fmt.Errorf("smtp pool size must be less than 100")
}
return nil
}),
settings.WithAfterSetInt64(func(ss settings.Int64Setting, i int64) {
lock.Lock()
defer lock.Unlock()
configChanged = true
}),
)
)
func newSmtpConfig() *smtp.SmtpConfig {
return &smtp.SmtpConfig{
Host: smtpHost.Get(),
Port: uint32(smtpPort.Get()),
Protocol: smtpProtocol.Get(),
Username: smtpUsername.Get(),
Password: smtpPassword.Get(),
From: smtpFrom.Get(),
}
}
func newSmtpPool() (*smtp.SmtpPool, error) {
return smtp.NewSmtpPool(newSmtpConfig(), int(smtpPoolSize.Get()))
}
func getSmtpPool() (*smtp.SmtpPool, error) {
lock.Lock()
defer lock.Unlock()
if configChanged {
configChanged = false
if smtpPool != nil {
smtpPool.Close()
smtpPool = nil
}
}
if smtpPool == nil {
pool, err := newSmtpPool()
if err != nil {
return nil, err
}
smtpPool = pool
}
return smtpPool, nil
}
func closeSmtpPool() {
lock.Lock()
defer lock.Unlock()
if smtpPool != nil {
smtpPool.Close()
smtpPool = nil
}
}

@ -0,0 +1,39 @@
<mjml>
<mj-head>
<mj-style>.indent div {
text-indent: 2em;
}
.code div {
text-shadow: 0 0 11px #bdbdff;
}
.footer div {
text-shadow: 0 0 5px #fef0df;
}
iframe {
border:none
}</mj-style>
</mj-head>
<mj-body>
<mj-section>
<mj-column>
<mj-text align="center" font-size="30px">SyncTV</mj-text>
</mj-column>
</mj-section>
<mj-section padding="10px" padding-left="0px" padding-right="0px" background-color="#f3f4f6"
border-radius=".75rem">
<mj-column>
<mj-text font-size="18px" font-weight="600">验证码:</mj-text>
<mj-text css-class="indent">你的验证码为:</mj-text>
<mj-text css-class="code" color="#2563eb" align="center" font-size="40px">{{ .Captcha }}</mj-text>
<mj-text css-class="indent" font-family="MiSans">该验证码有效期为5分钟如果您并没有访问过我们的网站或没有进行上述操作请忽略这封邮件。</mj-text>
</mj-column>
</mj-section>
<mj-section>
<mj-column>
<mj-text css-class="footer" align="center">Copyright {{ .Year }} <a href="https://github.com/synctv-org"
target="_blank" style="text-decoration: none;font-weight: 600;color: #2563eb">SyncTV</a> All
Rights Reserved.</mj-text>
</mj-column>
</mj-section>
</mj-body>
</mjml>

@ -0,0 +1,14 @@
package email_template
import _ "embed"
var (
//go:embed test.mjml
TestMjml []byte
//go:embed captcha.mjml
CaptchaMjml []byte
//go:embed retrieve_password.mjml
RetrievePasswordMjml []byte
)

@ -0,0 +1,39 @@
<mjml>
<mj-head>
<mj-style>.indent div {
text-indent: 2em;
}
.code div {
text-shadow: 0 0 11px #bdbdff;
}
.footer div {
text-shadow: 0 0 5px #fef0df;
}
iframe {
border:none
}</mj-style>
</mj-head>
<mj-body>
<mj-section>
<mj-column>
<mj-text align="center" font-size="30px">SyncTV</mj-text>
</mj-column>
</mj-section>
<mj-section padding="10px" padding-left="0px" padding-right="0px" background-color="#f3f4f6"
border-radius=".75rem">
<mj-column>
<mj-text font-size="18px" font-weight="600">忘记密码:</mj-text>
<mj-text css-class="indent">Hi! 你在 SyncTV 中提交了重置密码的请求,请前往修改:</mj-text>
<mj-button background-color="#2563eb" color="#ffffff" href="{{ .Url }}">前往站点修改</mj-button>
<mj-text css-class="indent" font-family="MiSans">该验证码有效期为5分钟如果您并没有访问过我们的网站或没有进行上述操作请忽略这封邮件。</mj-text>
</mj-column>
</mj-section>
<mj-section>
<mj-column>
<mj-text css-class="footer" align="center">Copyright {{ .Year }} <a href="https://github.com/synctv-org"
target="_blank" style="text-decoration: none;font-weight: 600;color: #2563eb">SyncTV</a> All
Rights Reserved.</mj-text>
</mj-column>
</mj-section>
</mj-body>
</mjml>

@ -0,0 +1,39 @@
<mjml>
<mj-head>
<mj-style>.indent div {
text-indent: 2em;
}
.code div {
text-shadow: 0 0 11px #bdbdff;
}
.footer div {
text-shadow: 0 0 5px #fef0df;
}
iframe {
border:none
}</mj-style>
</mj-head>
<mj-body>
<mj-section>
<mj-column>
<mj-text align="center" font-size="30px">SyncTV</mj-text>
</mj-column>
</mj-section>
<mj-section padding="10px" padding-left="0px" padding-right="0px" background-color="#f3f4f6"
border-radius=".75rem">
<mj-column>
<mj-text font-size="18px" font-weight="600">测试邮件:</mj-text>
<mj-text css-class="indent">Dear {{ .Username }}.</mj-text>
<mj-text css-class="indent">这是一封测试邮件。This is a test email.</mj-text>
</mj-column>
</mj-section>
<mj-section>
<mj-column>
<mj-text css-class="footer" align="center">Copyright {{ .Year }} <a
href="https://github.com/synctv-org/synctv" target="_blank"
style="text-decoration: none;font-weight: 600;color: #2563eb">SyncTV</a> All
Rights Reserved.</mj-text>
</mj-column>
</mj-section>
</mj-body>
</mjml>

@ -21,6 +21,7 @@ const (
SettingGroupDatabase SettingGroup = "database"
SettingGroupServer SettingGroup = "server"
SettingGroupOauth2 SettingGroup = "oauth2"
SettingGroupEmail SettingGroup = "email"
)
type Setting struct {

@ -43,9 +43,11 @@ type User struct {
CreatedAt time.Time
UpdatedAt time.Time
RegisteredByProvider bool `gorm:"not null;default:false"`
RegisteredByEmail bool `gorm:"not null;default:false"`
UserProviders []UserProvider `gorm:"foreignKey:UserID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
Username string `gorm:"not null;uniqueIndex;type:varchar(32)"`
HashedPassword []byte `gorm:"not null"`
Email string `gorm:"type:varchar(128);uniqueIndex:,where:email <> ''"`
Role Role `gorm:"not null;default:2"`
RoomUserRelations []RoomUserRelation `gorm:"foreignKey:UserID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
Rooms []Room `gorm:"foreignKey:CreatorID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`

@ -7,6 +7,7 @@ import (
"github.com/synctv-org/synctv/internal/cache"
"github.com/synctv-org/synctv/internal/db"
"github.com/synctv-org/synctv/internal/email"
"github.com/synctv-org/synctv/internal/model"
"github.com/synctv-org/synctv/internal/provider"
"github.com/synctv-org/synctv/internal/settings"
@ -288,3 +289,52 @@ func (u *User) BindProvider(p provider.OAuth2Provider, pid string) error {
}
return nil
}
func (u *User) SendBindCaptchaEmail(e string) error {
return email.SendBindCaptchaEmail(u.ID, e)
}
func (u *User) VerifyBindCaptchaEmail(e, captcha string) (bool, error) {
return email.VerifyBindCaptchaEmail(u.ID, e, captcha)
}
func (u *User) BindEmail(e string) error {
err := db.BindEmail(u.ID, e)
if err != nil {
return err
}
u.Email = e
return nil
}
func (u *User) UnbindEmail() error {
err := db.UnbindEmail(u.ID)
if err != nil {
return err
}
u.Email = ""
return nil
}
func (u *User) SendTestEmail() error {
if u.Email == "" {
return errors.New("unbound email")
}
return email.SendTestEmail(u.Username, u.Email)
}
func (u *User) SendRetrievePasswordCaptchaEmail(host string) error {
if u.Email == "" {
return errors.New("unbound email")
}
return email.SendRetrievePasswordCaptchaEmail(u.ID, u.Email, host)
}
func (u *User) VerifyRetrievePasswordCaptchaEmail(e, captcha string) (bool, error) {
if u.Email != e {
return false, errors.New("email has changed, please resend the captcha email")
}
return email.VerifyRetrievePasswordCaptchaEmail(u.ID, e, captcha)
}

@ -43,6 +43,15 @@ func LoadOrInitUserByID(id string) (*UserEntry, error) {
return LoadOrInitUser(user)
}
func LoadOrInitUserByEmail(email string) (*UserEntry, error) {
u, err := db.GetUserByEmail(email)
if err != nil {
return nil, err
}
return LoadOrInitUser(u)
}
func LoadUserByUsername(username string) (*UserEntry, error) {
u, err := db.GetUserByUsername(username)
if err != nil {
@ -77,9 +86,6 @@ func CreateUser(username string, password string, conf ...db.CreateUserConfig) (
}
func CreateOrLoadUserWithProvider(username, password string, p provider.OAuth2Provider, pid string, conf ...db.CreateUserConfig) (*UserEntry, error) {
if username == "" {
return nil, errors.New("username cannot be empty")
}
u, err := db.CreateOrLoadUserWithProvider(username, password, p, pid, conf...)
if err != nil {
return nil, err
@ -88,6 +94,24 @@ func CreateOrLoadUserWithProvider(username, password string, p provider.OAuth2Pr
return LoadOrInitUser(u)
}
func CreateOrLoadUserWithEmail(username, password, email string, conf ...db.CreateUserConfig) (*UserEntry, error) {
u, err := db.CreateOrLoadUserWithEmail(username, password, email, conf...)
if err != nil {
return nil, err
}
return LoadOrInitUser(u)
}
func CreateUserWithEmail(username, password, email string, conf ...db.CreateUserConfig) (*UserEntry, error) {
u, err := db.CreateUserWithEmail(username, password, email, conf...)
if err != nil {
return nil, err
}
return LoadOrInitUser(u)
}
func GetUserByProvider(p provider.OAuth2Provider, pid string) (*UserEntry, error) {
u, err := db.GetUserByProvider(p, pid)
if err != nil {

@ -27,6 +27,7 @@ type Bool struct {
defaultValue bool
value uint32
beforeInit, beforeSet func(BoolSetting, bool) (bool, error)
afterInit, afterSet func(BoolSetting, bool)
}
type BoolSettingOption func(*Bool)
@ -49,6 +50,18 @@ func WithBeforeSetBool(beforeSet func(BoolSetting, bool) (bool, error)) BoolSett
}
}
func WithAfterInitBool(afterInit func(BoolSetting, bool)) BoolSettingOption {
return func(s *Bool) {
s.SetAfterInit(afterInit)
}
}
func WithAfterSetBool(afterSet func(BoolSetting, bool)) BoolSettingOption {
return func(s *Bool) {
s.SetAfterSet(afterSet)
}
}
func newBool(name string, value bool, group model.SettingGroup, options ...BoolSettingOption) *Bool {
b := &Bool{
setting: setting{
@ -77,6 +90,14 @@ func (b *Bool) SetBeforeSet(beforeSet func(BoolSetting, bool) (bool, error)) {
b.beforeSet = beforeSet
}
func (b *Bool) SetAfterInit(afterInit func(BoolSetting, bool)) {
b.afterInit = afterInit
}
func (b *Bool) SetAfterSet(afterSet func(BoolSetting, bool)) {
b.afterSet = afterSet
}
func (b *Bool) set(value bool) {
if value {
atomic.StoreUint32(&b.value, 1)
@ -103,6 +124,11 @@ func (b *Bool) Init(value string) error {
}
b.set(v)
if b.afterInit != nil {
b.afterInit(b, v)
}
return nil
}
@ -149,6 +175,11 @@ func (b *Bool) SetString(value string) error {
}
b.set(v)
if b.afterSet != nil {
b.afterSet(b, v)
}
return nil
}
@ -166,6 +197,11 @@ func (b *Bool) Set(v bool) (err error) {
}
b.set(v)
if b.afterSet != nil {
b.afterSet(b, v)
}
return
}

@ -29,6 +29,7 @@ type Float64 struct {
value uint64
validator func(float64) error
beforeInit, beforeSet func(Float64Setting, float64) (float64, error)
afterInit, afterSet func(Float64Setting, float64)
}
type Float64SettingOption func(*Float64)
@ -57,6 +58,18 @@ func WithBeforeSetFloat64(beforeSet func(Float64Setting, float64) (float64, erro
}
}
func WithAfterInitFloat64(afterInit func(Float64Setting, float64)) Float64SettingOption {
return func(s *Float64) {
s.SetAfterInit(afterInit)
}
}
func WithAfterSetFloat64(afterSet func(Float64Setting, float64)) Float64SettingOption {
return func(s *Float64) {
s.SetAfterSet(afterSet)
}
}
func newFloat64(name string, value float64, group model.SettingGroup, options ...Float64SettingOption) *Float64 {
f := &Float64{
setting: setting{
@ -85,6 +98,14 @@ func (f *Float64) SetBeforeSet(beforeSet func(Float64Setting, float64) (float64,
f.beforeSet = beforeSet
}
func (f *Float64) SetAfterInit(afterInit func(Float64Setting, float64)) {
f.afterInit = afterInit
}
func (f *Float64) SetAfterSet(afterSet func(Float64Setting, float64)) {
f.afterSet = afterSet
}
func (f *Float64) Parse(value string) (float64, error) {
v, err := strconv.ParseFloat(value, 64)
if err != nil {
@ -114,6 +135,11 @@ func (f *Float64) Init(value string) error {
}
f.set(v)
if f.afterInit != nil {
f.afterInit(f, v)
}
return nil
}
@ -152,6 +178,11 @@ func (f *Float64) SetString(value string) error {
}
f.set(v)
if f.afterSet != nil {
f.afterSet(f, v)
}
return nil
}
@ -180,6 +211,11 @@ func (f *Float64) Set(v float64) (err error) {
}
f.set(v)
if f.afterSet != nil {
f.afterSet(f, v)
}
return
}

@ -28,6 +28,7 @@ type Int64 struct {
value int64
validator func(int64) error
beforeInit, beforeSet func(Int64Setting, int64) (int64, error)
afterInit, afterSet func(Int64Setting, int64)
}
type Int64SettingOption func(*Int64)
@ -56,6 +57,18 @@ func WithBeforeSetInt64(beforeSet func(Int64Setting, int64) (int64, error)) Int6
}
}
func WithAfterInitInt64(afterInit func(Int64Setting, int64)) Int64SettingOption {
return func(s *Int64) {
s.SetAfterInit(afterInit)
}
}
func WithAfterSetInt64(afterSet func(Int64Setting, int64)) Int64SettingOption {
return func(s *Int64) {
s.SetAfterSet(afterSet)
}
}
func newInt64(name string, value int64, group model.SettingGroup, options ...Int64SettingOption) *Int64 {
i := &Int64{
setting: setting{
@ -84,6 +97,14 @@ func (i *Int64) SetBeforeSet(beforeSet func(Int64Setting, int64) (int64, error))
i.beforeSet = beforeSet
}
func (i *Int64) SetAfterInit(afterInit func(Int64Setting, int64)) {
i.afterInit = afterInit
}
func (i *Int64) SetAfterSet(afterSet func(Int64Setting, int64)) {
i.afterSet = afterSet
}
func (i *Int64) Parse(value string) (int64, error) {
v, err := strconv.ParseInt(value, 10, 64)
if err != nil {
@ -113,6 +134,11 @@ func (i *Int64) Init(value string) error {
}
i.set(v)
if i.afterInit != nil {
i.afterInit(i, v)
}
return nil
}
@ -151,6 +177,11 @@ func (i *Int64) SetString(value string) error {
}
i.set(v)
if i.afterSet != nil {
i.afterSet(i, v)
}
return nil
}
@ -179,6 +210,11 @@ func (i *Int64) Set(v int64) (err error) {
}
i.set(v)
if i.afterSet != nil {
i.afterSet(i, v)
}
return
}

@ -31,6 +31,16 @@ func SetValue(name string, value any) error {
if !ok {
return fmt.Errorf("setting %s not found", name)
}
switch s.Type() {
case model.SettingTypeBool:
return s.(BoolSetting).Set(json.Wrap(value).ToBool())
case model.SettingTypeInt64:
return s.(Int64Setting).Set(json.Wrap(value).ToInt64())
case model.SettingTypeFloat64:
return s.(Float64Setting).Set(json.Wrap(value).ToFloat64())
case model.SettingTypeString:
return s.(StringSetting).Set(json.Wrap(value).ToString())
}
return s.SetString(json.Wrap(value).ToString())
}

@ -28,6 +28,7 @@ type String struct {
value string
validator func(string) error
beforeInit, beforeSet func(StringSetting, string) (string, error)
afterInit, afterSet func(StringSetting, string)
}
type StringSettingOption func(*String)
@ -56,6 +57,18 @@ func WithBeforeSetString(beforeSet func(StringSetting, string) (string, error))
}
}
func WithAfterInitString(afterInit func(StringSetting, string)) StringSettingOption {
return func(s *String) {
s.SetAfterInit(afterInit)
}
}
func WithAfterSetString(afterSet func(StringSetting, string)) StringSettingOption {
return func(s *String) {
s.SetAfterSet(afterSet)
}
}
func newString(name string, value string, group model.SettingGroup, options ...StringSettingOption) *String {
s := &String{
setting: setting{
@ -84,6 +97,14 @@ func (s *String) SetBeforeSet(beforeSet func(StringSetting, string) (string, err
s.beforeSet = beforeSet
}
func (s *String) SetAfterInit(afterInit func(StringSetting, string)) {
s.afterInit = afterInit
}
func (s *String) SetAfterSet(afterSet func(StringSetting, string)) {
s.afterSet = afterSet
}
func (s *String) Parse(value string) (string, error) {
if s.validator != nil {
return value, s.validator(value)
@ -109,6 +130,11 @@ func (s *String) Init(value string) error {
}
s.set(v)
if s.afterInit != nil {
s.afterInit(s, v)
}
return nil
}
@ -147,6 +173,11 @@ func (s *String) SetString(value string) error {
}
s.set(v)
if s.afterSet != nil {
s.afterSet(s, v)
}
return nil
}
@ -177,6 +208,11 @@ func (s *String) Set(v string) (err error) {
}
s.set(v)
if s.afterSet != nil {
s.afterSet(s, v)
}
return
}

@ -2,6 +2,8 @@ package handlers
import (
"github.com/gin-gonic/gin"
"github.com/synctv-org/synctv/internal/model"
"github.com/synctv-org/synctv/internal/settings"
"github.com/synctv-org/synctv/server/handlers/vendors"
"github.com/synctv-org/synctv/server/handlers/vendors/vendorAlist"
"github.com/synctv-org/synctv/server/handlers/vendors/vendorBilibili"
@ -10,6 +12,14 @@ import (
"github.com/synctv-org/synctv/utils"
)
var (
HOST = settings.NewStringSetting(
"host",
"",
model.SettingGroupServer,
)
)
func Init(e *gin.Engine) {
api := e.Group("/api")
@ -200,6 +210,18 @@ func initMovie(movie *gin.RouterGroup, needAuthMovie *gin.RouterGroup) {
func initUser(user *gin.RouterGroup, needAuthUser *gin.RouterGroup) {
user.POST("/login", LoginUser)
user.GET("/signup/email/captcha", GetUserSignupEmailStep1Captcha)
user.POST("/signup/email/captcha", SendUserSignupEmailCaptcha)
user.POST("/signup/email", UserSignupEmail)
user.GET("/retrieve/email/captcha", GetUserRetrievePasswordEmailStep1Captcha)
user.POST("/retrieve/email/captcha", SendUserRetrievePasswordEmailCaptcha)
user.POST("/retrieve/email", UserRetrievePasswordEmail)
needAuthUser.POST("/logout", LogoutUser)
needAuthUser.GET("/me", Me)
@ -211,6 +233,16 @@ func initUser(user *gin.RouterGroup, needAuthUser *gin.RouterGroup) {
needAuthUser.POST("/password", SetUserPassword)
needAuthUser.GET("/providers", UserBindProviders)
needAuthUser.GET("/bind/email/captcha", GetUserBindEmailStep1Captcha)
needAuthUser.POST("/bind/email/captcha", SendUserBindEmailCaptcha)
needAuthUser.POST("/bind/email", UserBindEmail)
needAuthUser.POST("/unbind/email", UserUnbindEmail)
needAuthUser.POST("/bind/email/test", UserSendTestEmail)
}
func initVendor(vendor *gin.RouterGroup) {

@ -321,6 +321,9 @@ func NewPublishKey(ctx *gin.Context) {
}
host := settings.CustomPublishHost.Get()
if host == "" {
host = HOST.Get()
}
if host == "" {
host = ctx.Request.Host
}

@ -1,10 +1,23 @@
package handlers
import (
"strings"
"github.com/gin-gonic/gin"
"github.com/synctv-org/synctv/internal/email"
"github.com/synctv-org/synctv/server/model"
)
type publicSettings struct {
EmailWhitelistEnabled bool `json:"emailWhitelistEnabled"`
EmailWhitelist []string `json:"emailWhitelist,omitempty"`
}
func Settings(ctx *gin.Context) {
ctx.JSON(200, model.NewApiDataResp(gin.H{}))
ctx.JSON(200, model.NewApiDataResp(
&publicSettings{
EmailWhitelistEnabled: email.EmailSignupWhiteListEnable.Get(),
EmailWhitelist: strings.Split(email.EmailSignupWhiteList.Get(), ","),
},
))
}

@ -1,18 +1,25 @@
package handlers
import (
"math/rand"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
"github.com/synctv-org/synctv/internal/captcha"
"github.com/synctv-org/synctv/internal/db"
"github.com/synctv-org/synctv/internal/email"
dbModel "github.com/synctv-org/synctv/internal/model"
"github.com/synctv-org/synctv/internal/op"
"github.com/synctv-org/synctv/internal/provider"
"github.com/synctv-org/synctv/internal/provider/providers"
"github.com/synctv-org/synctv/internal/settings"
"github.com/synctv-org/synctv/server/middlewares"
"github.com/synctv-org/synctv/server/model"
"github.com/synctv-org/synctv/utils"
"golang.org/x/exp/slices"
"gorm.io/gorm"
)
@ -24,6 +31,7 @@ func Me(ctx *gin.Context) {
Username: user.Username,
Role: user.Role,
CreatedAt: user.CreatedAt.UnixMilli(),
Email: user.Email,
}))
}
@ -236,3 +244,336 @@ func UserBindProviders(ctx *gin.Context) {
ctx.JSON(http.StatusOK, resp)
}
func GetUserBindEmailStep1Captcha(ctx *gin.Context) {
// user := ctx.MustGet("user").(*op.UserEntry).Value()
log := ctx.MustGet("log").(*logrus.Entry)
id, data, _, err := captcha.Captcha.Generate()
if err != nil {
log.Errorf("failed to generate captcha: %v", err)
ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewApiErrorResp(err))
return
}
ctx.JSON(http.StatusOK, model.NewApiDataResp(&model.GetUserBindEmailStep1CaptchaResp{
CaptchaID: id,
CaptchaBase64: data,
}))
}
func SendUserBindEmailCaptcha(ctx *gin.Context) {
user := ctx.MustGet("user").(*op.UserEntry).Value()
log := ctx.MustGet("log").(*logrus.Entry)
req := model.UserSendBindEmailCaptchaReq{}
if err := model.Decode(ctx, &req); err != nil {
log.Errorf("failed to decode request: %v", err)
ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorResp(err))
return
}
if !captcha.Captcha.Verify(
req.CaptchaID,
req.Answer,
true,
) {
log.Errorf("captcha verify failed")
ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorStringResp("captcha verify failed"))
return
}
if err := user.SendBindCaptchaEmail(req.Email); err != nil {
log.Errorf("failed to send email captcha: %v", err)
ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewApiErrorResp(err))
return
}
ctx.Status(http.StatusNoContent)
}
func UserBindEmail(ctx *gin.Context) {
user := ctx.MustGet("user").(*op.UserEntry).Value()
log := ctx.MustGet("log").(*logrus.Entry)
req := model.UserBindEmailReq{}
if err := model.Decode(ctx, &req); err != nil {
log.Errorf("failed to decode request: %v", err)
ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorResp(err))
return
}
if ok, err := user.VerifyBindCaptchaEmail(req.Email, req.Captcha); err != nil || !ok {
log.Errorf("email captcha verify failed")
ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorStringResp("email captcha verify failed"))
return
}
err := user.BindEmail(req.Email)
if err != nil {
log.Errorf("failed to bind email: %v", err)
ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewApiErrorResp(err))
return
}
ctx.Status(http.StatusNoContent)
}
func UserUnbindEmail(ctx *gin.Context) {
user := ctx.MustGet("user").(*op.UserEntry).Value()
log := ctx.MustGet("log").(*logrus.Entry)
err := user.UnbindEmail()
if err != nil {
log.Errorf("failed to unbind email: %v", err)
ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewApiErrorResp(err))
return
}
ctx.Status(http.StatusNoContent)
}
func UserSendTestEmail(ctx *gin.Context) {
user := ctx.MustGet("user").(*op.UserEntry).Value()
log := ctx.MustGet("log").(*logrus.Entry)
if err := user.SendTestEmail(); err != nil {
log.Errorf("failed to send test email: %v", err)
ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewApiErrorResp(err))
return
}
ctx.Status(http.StatusNoContent)
}
func GetUserSignupEmailStep1Captcha(ctx *gin.Context) {
log := ctx.MustGet("log").(*logrus.Entry)
id, data, _, err := captcha.Captcha.Generate()
if err != nil {
log.Errorf("failed to generate captcha: %v", err)
ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewApiErrorResp(err))
return
}
ctx.JSON(http.StatusOK, model.NewApiDataResp(&model.GetUserBindEmailStep1CaptchaResp{
CaptchaID: id,
CaptchaBase64: data,
}))
}
func SendUserSignupEmailCaptcha(ctx *gin.Context) {
log := ctx.MustGet("log").(*logrus.Entry)
if settings.DisableUserSignup.Get() || email.DisableUserSignup.Get() {
ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorStringResp("user signup disabled"))
return
}
req := model.SendUserSignupEmailCaptchaReq{}
if err := model.Decode(ctx, &req); err != nil {
log.Errorf("failed to decode request: %v", err)
ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorResp(err))
return
}
if !captcha.Captcha.Verify(
req.CaptchaID,
req.Answer,
true,
) {
log.Errorf("captcha verify failed")
ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorStringResp("captcha verify failed"))
return
}
if email.EmailSignupWhiteListEnable.Get() {
_, after, found := strings.Cut(req.Email, "@")
if !found {
log.Errorf("email format error")
ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorStringResp("email format error"))
return
}
if !slices.Contains(
strings.Split(email.EmailSignupWhiteList.Get(), ","),
after,
) {
log.Errorf("email(%s) sub(%s) not in white list", req.Email, after)
ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorStringResp("email not in white list"))
return
}
}
_, err := op.LoadOrInitUserByEmail(req.Email)
if err == nil {
log.Errorf("email already exists")
ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorStringResp("email already exists"))
return
}
if err := email.SendSignupCaptchaEmail(req.Email); err != nil {
log.Errorf("failed to send email captcha: %v", err)
ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewApiErrorResp(err))
return
}
ctx.Status(http.StatusNoContent)
}
func UserSignupEmail(ctx *gin.Context) {
log := ctx.MustGet("log").(*logrus.Entry)
if settings.DisableUserSignup.Get() || email.DisableUserSignup.Get() {
log.Errorf("user signup disabled")
ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorStringResp("user signup disabled"))
return
}
req := model.UserSignupEmailReq{}
if err := model.Decode(ctx, &req); err != nil {
log.Errorf("failed to decode request: %v", err)
ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorResp(err))
return
}
ok, err := email.VerifySignupCaptchaEmail(req.Email, req.Captcha)
if err != nil {
log.Errorf("failed to verify email captcha: %v", err)
ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewApiErrorResp(err))
return
}
if !ok {
log.Errorf("email captcha verify failed")
ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorStringResp("email captcha verify failed"))
return
}
var user *op.UserEntry
if settings.SignupNeedReview.Get() || email.SignupNeedReview.Get() {
user, err = op.CreateUserWithEmail(req.Email, utils.RandString(16), req.Email, db.WithRole(dbModel.RolePending))
} else {
user, err = op.CreateUserWithEmail(req.Email, utils.RandString(16), req.Email)
}
if err != nil {
log.Errorf("failed to create user: %v", err)
ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorResp(err))
return
}
token, err := middlewares.NewAuthUserToken(user.Value())
if err != nil {
log.Errorf("failed to generate token: %v", err)
ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorResp(err))
return
}
ctx.JSON(http.StatusOK, model.NewApiDataResp(gin.H{
"token": token,
}))
}
func GetUserRetrievePasswordEmailStep1Captcha(ctx *gin.Context) {
log := ctx.MustGet("log").(*logrus.Entry)
id, data, _, err := captcha.Captcha.Generate()
if err != nil {
log.Errorf("failed to generate captcha: %v", err)
ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewApiErrorResp(err))
return
}
ctx.JSON(http.StatusOK, model.NewApiDataResp(&model.GetUserBindEmailStep1CaptchaResp{
CaptchaID: id,
CaptchaBase64: data,
}))
}
func SendUserRetrievePasswordEmailCaptcha(ctx *gin.Context) {
log := ctx.MustGet("log").(*logrus.Entry)
req := model.SendUserRetrievePasswordEmailCaptchaReq{}
if err := model.Decode(ctx, &req); err != nil {
log.Errorf("failed to decode request: %v", err)
ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorResp(err))
return
}
if !captcha.Captcha.Verify(
req.CaptchaID,
req.Answer,
true,
) {
log.Errorf("captcha verify failed")
ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorStringResp("captcha verify failed"))
return
}
user, err := op.LoadOrInitUserByEmail(req.Email)
if err != nil {
log.Errorf("failed to load or init user by email: %v", err)
time.Sleep(time.Duration(rand.Intn(1500)) + time.Second*3)
ctx.Status(http.StatusNoContent)
return
}
host := HOST.Get()
if host == "" {
host = ctx.Request.Host
}
if host == "" {
log.Error("failed to get host on send retrieve password email")
ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewApiErrorStringResp("failed to get host"))
return
}
if err := user.Value().SendRetrievePasswordCaptchaEmail(host); err != nil {
log.Errorf("failed to send email captcha: %v", err)
ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewApiErrorResp(err))
return
}
ctx.Status(http.StatusNoContent)
}
func UserRetrievePasswordEmail(ctx *gin.Context) {
log := ctx.MustGet("log").(*logrus.Entry)
req := model.UserRetrievePasswordEmailReq{}
if err := model.Decode(ctx, &req); err != nil {
log.Errorf("failed to decode request: %v", err)
ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorResp(err))
return
}
userE, err := op.LoadOrInitUserByID(req.UserID)
if err != nil {
log.Errorf("failed to get user by email: %v", err)
ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorResp(err))
return
}
user := userE.Value()
if ok, err := user.VerifyRetrievePasswordCaptchaEmail(req.Email, req.Captcha); err != nil || !ok {
log.Errorf("email captcha verify failed")
ctx.AbortWithStatusJSON(http.StatusBadRequest, model.NewApiErrorStringResp("email captcha verify failed"))
return
}
err = user.SetPassword(req.Password)
if err != nil {
log.Errorf("failed to set password: %v", err)
ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewApiErrorResp(err))
return
}
token, err := middlewares.NewAuthUserToken(user)
if err != nil {
log.Errorf("failed to generate token: %v", err)
ctx.AbortWithStatusJSON(http.StatusInternalServerError, model.NewApiErrorResp(err))
return
}
ctx.JSON(http.StatusOK, model.NewApiDataResp(gin.H{
"token": token,
}))
}

@ -1,9 +1,17 @@
package model
import (
"regexp"
"time"
)
var (
alnumReg = regexp.MustCompile(`^[[:alnum:]]+$`)
alnumPrintReg = regexp.MustCompile(`^[[:print:][:alnum:]]+$`)
alnumPrintHanReg = regexp.MustCompile(`^[[:print:][:alnum:]\p{Han}]+$`)
emailReg = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
)
type ApiResp struct {
Time int64 `json:"time"`
Error string `json:"error,omitempty"`

@ -3,7 +3,6 @@ package model
import (
"errors"
"fmt"
"regexp"
json "github.com/json-iterator/go"
@ -19,17 +18,6 @@ var (
ErrPasswordTooLong = errors.New("password too long")
ErrPasswordHasInvalidChar = errors.New("password has invalid char")
ErrEmptyUserId = errors.New("empty user id")
ErrEmptyUsername = errors.New("empty username")
ErrUsernameTooLong = errors.New("username too long")
ErrUsernameHasInvalidChar = errors.New("username has invalid char")
)
var (
alnumReg = regexp.MustCompile(`^[[:alnum:]]+$`)
alnumPrintReg = regexp.MustCompile(`^[[:print:][:alnum:]]+$`)
alnumPrintHanReg = regexp.MustCompile(`^[[:print:][:alnum:]\p{Han}]+$`)
)
type FormatEmptyPasswordError string

@ -9,6 +9,13 @@ import (
"github.com/synctv-org/synctv/internal/provider"
)
var (
ErrEmptyUserId = errors.New("empty user id")
ErrEmptyUsername = errors.New("empty username")
ErrUsernameTooLong = errors.New("username too long")
ErrUsernameHasInvalidChar = errors.New("username has invalid char")
)
type SetUserPasswordReq struct {
Password string `json:"password"`
}
@ -61,6 +68,7 @@ type UserInfoResp struct {
Username string `json:"username"`
Role dbModel.Role `json:"role"`
CreatedAt int64 `json:"createdAt"`
Email string `json:"email"`
}
type SetUsernameReq struct {
@ -101,3 +109,90 @@ type UserBindProviderResp map[provider.OAuth2Provider]struct {
ProviderUserID string `json:"providerUserID"`
CreatedAt int64 `json:"createdAt"`
}
type GetUserBindEmailStep1CaptchaResp struct {
CaptchaID string `json:"captchaID"`
CaptchaBase64 string `json:"captchaBase64"`
}
type UserSendBindEmailCaptchaReq struct {
Email string `json:"email"`
CaptchaID string `json:"captchaID"`
Answer string `json:"answer"`
}
func (u *UserSendBindEmailCaptchaReq) Decode(ctx *gin.Context) error {
return json.NewDecoder(ctx.Request.Body).Decode(u)
}
var (
ErrEmailTooLong = errors.New("email is too long")
ErrInvalidEmail = errors.New("invalid email")
)
func (u *UserSendBindEmailCaptchaReq) Validate() error {
if u.Email == "" {
return errors.New("email is empty")
} else if len(u.Email) > 128 {
return ErrEmailTooLong
} else if !emailReg.MatchString(u.Email) {
return ErrInvalidEmail
}
if u.CaptchaID == "" {
return errors.New("captcha id is empty")
}
if u.Answer == "" {
return errors.New("answer is empty")
}
return nil
}
type UserBindEmailReq struct {
Email string `json:"email"`
Captcha string `json:"captcha"`
}
func (u *UserBindEmailReq) Decode(ctx *gin.Context) error {
return json.NewDecoder(ctx.Request.Body).Decode(u)
}
func (u *UserBindEmailReq) Validate() error {
if u.Email == "" {
return errors.New("email is empty")
} else if len(u.Email) > 128 {
return ErrEmailTooLong
} else if !emailReg.MatchString(u.Email) {
return ErrInvalidEmail
}
if u.Captcha == "" {
return errors.New("captcha is empty")
}
return nil
}
type SendUserSignupEmailCaptchaReq = UserSendBindEmailCaptchaReq
type UserSignupEmailReq = UserBindEmailReq
type SendUserRetrievePasswordEmailCaptchaReq = UserSendBindEmailCaptchaReq
type UserRetrievePasswordEmailReq struct {
UserID string `json:"userID"`
Email string `json:"email"`
Captcha string `json:"captcha"`
Password string `json:"password"`
}
func (u *UserRetrievePasswordEmailReq) Decode(ctx *gin.Context) error {
return json.NewDecoder(ctx.Request.Body).Decode(u)
}
func (u *UserRetrievePasswordEmailReq) Validate() error {
if u.UserID == "" {
return errors.New("userID is empty")
}
if u.Captcha == "" {
return errors.New("captcha is empty")
}
return nil
}

@ -0,0 +1,35 @@
package smtp
import (
"encoding/base64"
"fmt"
"strings"
smtp "github.com/emersion/go-smtp"
"github.com/zijiren233/stream"
)
func FormatMail(from string, to []string, subject string, body any) string {
return fmt.Sprintf(
"From: %s\r\nTo: %s\r\nSubject: =?UTF-8?B?%s?=\r\nContent-Type: text/html; charset=UTF-8\r\n\r\n%v",
from,
strings.Join(to, ","),
base64.StdEncoding.EncodeToString(stream.StringToBytes(subject)),
body,
)
}
func SendEmail(cli *smtp.Client, from string, to []string, subject, body string) error {
return cli.SendMail(
from,
to,
strings.NewReader(
FormatMail(
from,
to,
subject,
body,
),
),
)
}

@ -0,0 +1,176 @@
package smtp
import (
"fmt"
"runtime"
"strings"
"sync"
"github.com/emersion/go-sasl"
smtp "github.com/emersion/go-smtp"
)
type SmtpConfig struct {
Host string
Port uint32
Protocol string
Username string
Password string
From string
}
func validateSmtpConfig(c *SmtpConfig) error {
if c == nil {
return fmt.Errorf("smtp config is nil")
}
if c.Host == "" {
return fmt.Errorf("smtp host is empty")
}
if c.Port == 0 {
return fmt.Errorf("smtp port is empty")
}
if c.Username == "" {
return fmt.Errorf("smtp username is empty")
}
if c.Password == "" {
return fmt.Errorf("smtp password is empty")
}
if c.From == "" {
return fmt.Errorf("smtp from is empty")
}
return nil
}
func newSmtpClient(c *SmtpConfig) (*smtp.Client, error) {
var (
cli *smtp.Client
err error
)
switch strings.ToUpper(c.Protocol) {
case "TLS", "SSL":
cli, err = smtp.DialStartTLS(fmt.Sprintf("%s:%d", c.Host, c.Port), nil)
default:
cli, err = smtp.Dial(fmt.Sprintf("%s:%d", c.Host, c.Port))
}
if err != nil {
return nil, fmt.Errorf("dial smtp server failed: %w", err)
}
err = cli.Auth(sasl.NewLoginClient(c.Username, c.Password))
if err != nil {
cli.Close()
return nil, fmt.Errorf("auth failed: %w", err)
}
return cli, nil
}
var ErrSmtpPoolClosed = fmt.Errorf("smtp pool is closed")
type SmtpPool struct {
mu sync.Mutex
clients []*smtp.Client
c *SmtpConfig
max int
active int
closed bool
}
func NewSmtpPool(c *SmtpConfig, max int) (*SmtpPool, error) {
err := validateSmtpConfig(c)
if err != nil {
return nil, err
}
return &SmtpPool{
clients: make([]*smtp.Client, 0, max),
c: c,
max: max,
}, nil
}
func (p *SmtpPool) Get() (*smtp.Client, error) {
p.mu.Lock()
if p.closed {
p.mu.Unlock()
return nil, ErrSmtpPoolClosed
}
if len(p.clients) > 0 {
cli := p.clients[len(p.clients)-1]
p.clients = p.clients[:len(p.clients)-1]
p.mu.Unlock()
if cli.Noop() != nil {
cli.Close()
return p.Get()
}
p.mu.Lock()
p.active++
p.mu.Unlock()
return cli, nil
}
if p.active >= p.max {
p.mu.Unlock()
runtime.Gosched()
return p.Get()
}
cli, err := newSmtpClient(p.c)
if err != nil {
p.mu.Unlock()
return nil, err
}
p.active++
p.mu.Unlock()
return cli, nil
}
func (p *SmtpPool) Put(cli *smtp.Client) {
if cli == nil {
return
}
noopErr := cli.Noop()
p.mu.Lock()
defer p.mu.Unlock()
p.active--
if p.closed || noopErr != nil {
cli.Close()
return
}
p.clients = append(p.clients, cli)
}
func (p *SmtpPool) Close() {
p.mu.Lock()
defer p.mu.Unlock()
p.closed = true
for _, cli := range p.clients {
cli.Close()
}
p.clients = nil
}
func (p *SmtpPool) SendEmail(to []string, subject, body string) error {
cli, err := p.Get()
if err != nil {
return err
}
defer p.Put(cli)
return SendEmail(cli, p.c.From, to, subject, body)
}
func (p *SmtpPool) SetFrom(from string) {
p.mu.Lock()
defer p.mu.Unlock()
p.c.From = from
}
Loading…
Cancel
Save