mirror of https://github.com/synctv-org/synctv
Feat: email smtp support
parent
04a411d629
commit
6f961c5583
@ -0,0 +1,13 @@
|
|||||||
|
package captcha
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/mojocn/base64Captcha"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
Captcha *base64Captcha.Captcha
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Captcha = base64Captcha.NewCaptcha(base64Captcha.DefaultDriverDigit, base64Captcha.DefaultMemStore)
|
||||||
|
}
|
@ -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,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">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>
|
@ -1,10 +1,23 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/synctv-org/synctv/internal/email"
|
||||||
"github.com/synctv-org/synctv/server/model"
|
"github.com/synctv-org/synctv/server/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type publicSettings struct {
|
||||||
|
EmailWhitelistEnabled bool `json:"emailWhitelistEnabled"`
|
||||||
|
EmailWhitelist []string `json:"emailWhitelist,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
func Settings(ctx *gin.Context) {
|
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(), ","),
|
||||||
|
},
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
@ -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…
Reference in New Issue