diff --git a/config.default.yaml b/config.default.yaml index b6ff5d2..0388cc1 100644 --- a/config.default.yaml +++ b/config.default.yaml @@ -27,6 +27,12 @@ email: security: insecure_skip_verify: from: + auth: + oauth2: + tenant_id: + client_id: + client_secret: + scope: secrets: turnstile_secret: example client_secret_key: aes_32_byte_string diff --git a/config/types.go b/config/types.go index 04ae563..978caaf 100644 --- a/config/types.go +++ b/config/types.go @@ -40,14 +40,23 @@ type search struct { ApiKey string `yaml:"api_key"` } +type _email_oauth2 struct { + Tenantid string `yaml:"tenant_id"` + ClientId string `yaml:"client_id"` + ClientSecret string `yaml:"client_secret"` + Scope string `yaml:"scope"` +} + type email struct { - Host string `yaml:"host"` - Port string `yaml:"port"` - Username string `yaml:"username"` - Password string `yaml:"password"` - Security string `yaml:"security"` - InsecureSkipVerify bool `yaml:"insecure_skip_verify"` - From string `yaml:"from"` + Host string `yaml:"host"` + Port string `yaml:"port"` + Username string `yaml:"username"` + Password string `yaml:"password"` + Security string `yaml:"security"` + InsecureSkipVerify bool `yaml:"insecure_skip_verify"` + From string `yaml:"from"` + Auth string `yaml:"auth"` + Oauth2 _email_oauth2 `yaml:"oauth2"` } type secrets struct { diff --git a/go.mod b/go.mod index fabbeea..9394772 100644 --- a/go.mod +++ b/go.mod @@ -74,6 +74,7 @@ require ( go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/arch v0.23.0 // indirect golang.org/x/net v0.48.0 // indirect + golang.org/x/oauth2 v0.34.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.39.0 // indirect golang.org/x/text v0.32.0 // indirect diff --git a/go.sum b/go.sum index 48bc8ba..7be128f 100644 --- a/go.sum +++ b/go.sum @@ -292,6 +292,8 @@ golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/pkgs/email/email.go b/pkgs/email/email.go index 010c004..924a321 100644 --- a/pkgs/email/email.go +++ b/pkgs/email/email.go @@ -1,18 +1,67 @@ package email import ( + "bytes" + "context" "crypto/tls" "errors" + "fmt" + "net" + "net/smtp" "strings" + "sync" "time" "github.com/spf13/viper" + "golang.org/x/oauth2" + "golang.org/x/oauth2/clientcredentials" gomail "gopkg.in/gomail.v2" ) type Client struct { + // basic smtp dialer *gomail.Dialer - from string + + // shared + from string + host string + port int + username string + + security string + insecure bool + + // auth mode + authMode string + + // oauth2 + oauth *oauthTokenProvider +} + +type oauthTokenProvider struct { + cfg clientcredentials.Config + + mu sync.Mutex + token *oauth2.Token + fetchErr error +} + +func (p *oauthTokenProvider) getToken(ctx context.Context) (string, error) { + p.mu.Lock() + defer p.mu.Unlock() + + if p.token != nil && p.token.Valid() && time.Until(p.token.Expiry) > 60*time.Second { + return p.token.AccessToken, nil + } + + tok, err := p.cfg.Token(ctx) + if err != nil { + p.fetchErr = err + return "", err + } + p.token = tok + p.fetchErr = nil + return tok.AccessToken, nil } func NewSMTPClient() (*Client, error) { @@ -25,46 +74,240 @@ func NewSMTPClient() (*Client, error) { security := strings.ToLower(viper.GetString("email.security")) insecure := viper.GetBool("email.insecure_skip_verify") - if host == "" || port == 0 || user == "" || pass == "" { + authMode := strings.ToLower(viper.GetString("email.auth")) + if authMode == "" { + authMode = "basic" + } + + if host == "" || port == 0 || user == "" { return nil, errors.New("SMTP config not set") } - dialer := gomail.NewDialer(host, port, user, pass) - - dialer.TLSConfig = &tls.Config{ - ServerName: host, - InsecureSkipVerify: insecure, + c := &Client{ + from: from, + host: host, + port: port, + username: user, + security: security, + insecure: insecure, + authMode: authMode, } - switch security { - case "ssl": - dialer.SSL = true - case "starttls": - dialer.SSL = false - case "plain", "": - dialer.SSL = false - dialer.TLSConfig = nil + switch authMode { + case "basic": + if pass == "" { + return nil, errors.New("SMTP basic auth requires email.password") + } + + dialer := gomail.NewDialer(host, port, user, pass) + dialer.TLSConfig = &tls.Config{ + ServerName: host, + InsecureSkipVerify: insecure, + } + + switch security { + case "ssl": + dialer.SSL = true + case "starttls": + dialer.SSL = false + case "plain", "": + dialer.SSL = false + dialer.TLSConfig = nil + default: + return nil, errors.New("unknown smtp security mode: " + security) + } + + c.dialer = dialer + return c, nil + + case "oauth2": + if security == "" { + security = "starttls" + c.security = "starttls" + } + if security == "plain" { + return nil, errors.New("oauth2 requires TLS (starttls or ssl); plain is not allowed") + } + + tenantID := viper.GetString("email.oauth2.tenant_id") + clientID := viper.GetString("email.oauth2.client_id") + clientSecret := viper.GetString("email.oauth2.client_secret") + scope := viper.GetString("email.oauth2.scope") + if scope == "" { + // Microsoft Learn: client credentials for SMTP uses https://outlook.office365.com/.default :contentReference[oaicite:3]{index=3} + scope = "https://outlook.office365.com/.default" + } + + if tenantID == "" || clientID == "" || clientSecret == "" { + return nil, errors.New("oauth2 requires email.oauth2.tenant_id/client_id/client_secret") + } + + c.oauth = &oauthTokenProvider{ + cfg: clientcredentials.Config{ + ClientID: clientID, + ClientSecret: clientSecret, + TokenURL: fmt.Sprintf("https://login.microsoftonline.com/%s/oauth2/v2.0/token", tenantID), + Scopes: []string{scope}, + }, + } + return c, nil + default: - return nil, errors.New("unknown smtp security mode: " + security) + return nil, errors.New("unknown email.auth: " + authMode) } - - return &Client{ - dialer: dialer, - from: from, - }, nil } func (c *Client) Send(to, subject, html string) (string, error) { m := gomail.NewMessage() - m.SetHeader("From", c.from) m.SetHeader("To", to) m.SetHeader("Subject", subject) m.SetBody("text/html", html) - if err := c.dialer.DialAndSend(m); err != nil { - return "", err + switch c.authMode { + case "basic": + if c.dialer == nil { + return "", errors.New("basic dialer not initialized") + } + if err := c.dialer.DialAndSend(m); err != nil { + return "", err + } + return time.Now().Format(time.RFC3339Nano), nil + + case "oauth2": + if err := c.sendWithXOAUTH2(m, to); err != nil { + return "", err + } + return time.Now().Format(time.RFC3339Nano), nil + + default: + return "", errors.New("unsupported auth mode: " + c.authMode) + } +} + +// XOAUTH2 auth for net/smtp +type xoauth2Auth struct { + username string + token string +} + +func (a *xoauth2Auth) Start(server *smtp.ServerInfo) (string, []byte, error) { + if !server.TLS { + return "", nil, errors.New("refusing to authenticate over insecure connection") } - return time.Now().Format(time.RFC3339Nano), nil + // Microsoft Learn XOAUTH2 Format: user=\x01auth=Bearer \x01\x01 :contentReference[oaicite:4]{index=4} + resp := fmt.Sprintf("user=%s\x01auth=Bearer %s\x01\x01", a.username, a.token) + return "XOAUTH2", []byte(resp), nil +} + +func (a *xoauth2Auth) Next(fromServer []byte, more bool) ([]byte, error) { + if more { + return nil, errors.New("unexpected server challenge during XOAUTH2 auth") + } + return nil, nil +} + +func (c *Client) sendWithXOAUTH2(m *gomail.Message, rcpt string) error { + if c.oauth == nil { + return errors.New("oauth2 provider not initialized") + } + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + token, err := c.oauth.getToken(ctx) + if err != nil { + return fmt.Errorf("oauth2 token error: %w", err) + } + + // write gomail.Message to RFC822 + var buf bytes.Buffer + if _, err := m.WriteTo(&buf); err != nil { + return err + } + msg := buf.Bytes() + + addr := fmt.Sprintf("%s:%d", c.host, c.port) + tlsCfg := &tls.Config{ + ServerName: c.host, + InsecureSkipVerify: c.insecure, + } + + var ( + conn net.Conn + cl *smtp.Client + ) + + switch c.security { + case "ssl": + conn, err = tls.Dial("tcp", addr, tlsCfg) + if err != nil { + return err + } + cl, err = smtp.NewClient(conn, c.host) + if err != nil { + _ = conn.Close() + return err + } + + case "starttls", "": + conn, err = net.Dial("tcp", addr) + if err != nil { + return err + } + cl, err = smtp.NewClient(conn, c.host) + if err != nil { + _ = conn.Close() + return err + } + + // Upgrade with STARTTLS + if ok, _ := cl.Extension("STARTTLS"); ok { + if err := cl.StartTLS(tlsCfg); err != nil { + _ = cl.Close() + return err + } + } else { + _ = cl.Close() + return errors.New("server does not support STARTTLS") + } + + default: + return errors.New("unknown smtp security mode: " + c.security) + } + + defer func() { _ = cl.Quit() }() + + // AUTH XOAUTH2 + if err := cl.Auth(&xoauth2Auth{username: c.username, token: token}); err != nil { + return err + } + + // MAIL FROM / RCPT TO / DATA + if err := cl.Mail(extractAddress(c.from)); err != nil { + return err + } + if err := cl.Rcpt(rcpt); err != nil { + return err + } + + w, err := cl.Data() + if err != nil { + return err + } + if _, err := w.Write(msg); err != nil { + _ = w.Close() + return err + } + return w.Close() +} + +func extractAddress(from string) string { + if i := strings.LastIndex(from, "<"); i >= 0 { + if j := strings.LastIndex(from, ">"); j > i { + return strings.TrimSpace(from[i+1 : j]) + } + } + return strings.TrimSpace(from) }