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
|
||||
|
||||
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(), ","),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
@ -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