Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 17 additions & 7 deletions app/pkg/env/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,22 @@ func Version() string {
return fmt.Sprintf("%s-%s", v, commithash)
}

type SMTPConfig struct {
Host string `env:"EMAIL_SMTP_HOST"`
Port string `env:"EMAIL_SMTP_PORT"`
Username string `env:"EMAIL_SMTP_USERNAME"`
Password string `env:"EMAIL_SMTP_PASSWORD"`
AuthMechanism string `env:"EMAIL_SMTP_AUTH_MECHANISM,default=AGNOSTIC"`
EnableStartTLS bool `env:"EMAIL_SMTP_ENABLE_STARTTLS,default=true"`

// Specific settings for Auth Mechanism "XOAUTH2"
// Username will be used for XOAUTH2 as well
ClientId string `env:"EMAIL_SMTP_OAUTH_CLIENT_ID"`
ClientSecret string `env:"EMAIL_SMTP_OAUTH_CLIENT_SECRET"`
TokenUrl string `env:"EMAIL_SMTP_OAUTH_TOKEN_URL"`
Scopes string `env:"EMAIL_SMTP_OAUTH_SCOPES"` // comma-separated
Comment on lines +47 to +50
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would you mind changing the suffix to be upper case, e.g ClientID and TokenURL - for consistency, ta

}

type config struct {
Environment string `env:"GO_ENV,default=production"`
SignUpDisabled bool `env:"SIGNUP_DISABLED,default=false"`
Expand Down Expand Up @@ -118,13 +134,7 @@ type config struct {
Domain string `env:"EMAIL_MAILGUN_DOMAIN"`
Region string `env:"EMAIL_MAILGUN_REGION,default=US"` // possible values: US or EU
}
SMTP struct {
Host string `env:"EMAIL_SMTP_HOST"`
Port string `env:"EMAIL_SMTP_PORT"`
Username string `env:"EMAIL_SMTP_USERNAME"`
Password string `env:"EMAIL_SMTP_PASSWORD"`
EnableStartTLS bool `env:"EMAIL_SMTP_ENABLE_STARTTLS,default=true"`
}
SMTP SMTPConfig
}
BlobStorage struct {
Type string `env:"BLOB_STORAGE,default=sql"` // possible values: sql, fs or s3
Expand Down
42 changes: 42 additions & 0 deletions app/services/email/smtp/auth_xoauth2.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package smtp

import (
"fmt"
gosmtp "net/smtp"

"github.com/getfider/fider/app/pkg/errors"
)

type xoauth2Auth struct {
user string
token string
host string
}

func XOAuth2Auth(user, token, host string) gosmtp.Auth {
return &xoauth2Auth{
user: user,
token: token,
host: host,
}
}

func (a *xoauth2Auth) Start(server *gosmtp.ServerInfo) (proto string, toServer []byte, err error) {
if server.Name != a.host {
return "", nil, errors.New("smtp: wrong host name")
}

if !server.TLS {
return "", nil, errors.New("smtp: XOAUTH2 requires TLS")
}
Comment on lines +29 to +31
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

XOAUTH2 requires TLS, but EMAIL_SMTP_ENABLE_STARTTLS can be disabled.

Would be nice to verify this all in env.go, in the reload method:

case "smtp":
      mustBeSet("EMAIL_SMTP_HOST")
      mustBeSet("EMAIL_SMTP_PORT")

      // Validate auth mechanism if explicitly set
      authMech := strings.ToUpper(strings.TrimSpace(Config.Email.SMTP.AuthMechanism))
      switch authMech {
      case "", "AGNOSTIC":
          // Username/password are optional - supports unauthenticated SMTP
      case "XOAUTH2":
          // If explicitly choosing XOAUTH2, OAuth credentials are required
          mustBeSet("EMAIL_SMTP_OAUTH_CLIENT_ID")
          mustBeSet("EMAIL_SMTP_OAUTH_CLIENT_SECRET")
          mustBeSet("EMAIL_SMTP_OAUTH_TOKEN_URL")
          mustBeSet("EMAIL_SMTP_USERNAME") // Used as the OAuth user
          if !Config.Email.SMTP.EnableStartTLS {
              panic("XOAUTH2 requires STARTTLS to be enabled (set EMAIL_SMTP_ENABLE_STARTTLS=true)")
          }
      default:
          // Fail fast on typos like "XOATH2" or "OAUTH2"
          panic(fmt.Sprintf("invalid EMAIL_SMTP_AUTH_MECHANISM '%s' (valid options: AGNOSTIC, XOAUTH2)", authMech))
      }
  }


resp := fmt.Sprintf("user=%s\x01auth=Bearer %s\x01\x01", a.user, a.token)
return "XOAUTH2", []byte(resp), nil
}

func (a *xoauth2Auth) Next(fromServer []byte, more bool) ([]byte, error) {
if more {
return nil, errors.New("smtp: unexpected server challenge for XOAUTH2")
}
return nil, nil
}
62 changes: 62 additions & 0 deletions app/services/email/smtp/oauth2_token.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package smtp

import (
"context"
"strings"

"golang.org/x/oauth2"
"golang.org/x/oauth2/clientcredentials"

"github.com/getfider/fider/app/pkg/errors"
)

func splitCommaScopes(raw string) []string {
if strings.TrimSpace(raw) == "" {
return nil
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}

func getClientCredentialsToken(ctx context.Context, tokenURL, clientID, clientSecret string, scopes []string) (string, error) {
if tokenURL == "" {
return "", errors.New("smtp: oauth token url is required")
}
if clientID == "" || clientSecret == "" {
return "", errors.New("smtp: oauth client id/secret are required")
}

key := tokenSourceKey(tokenURL, clientID, scopes)

tokenSourceMu.Lock()
tokenSource, ok := tokenSourceByKey[key]
if !ok {
conf := clientcredentials.Config{
ClientID: clientID,
ClientSecret: clientSecret,
TokenURL: tokenURL,
Scopes: scopes,
}

base := conf.TokenSource(ctx)
tokenSource = oauth2.ReuseTokenSource(nil, base)
tokenSourceByKey[key] = tokenSource
}
tokenSourceMu.Unlock()

tok, err := tokenSource.Token()
if err != nil {
return "", errors.Wrap(err, "smtp: failed to fetch oauth token")
}
if tok == nil || tok.AccessToken == "" {
return "", errors.New("smtp: oauth returned empty access token")
}
return tok.AccessToken, nil
}
30 changes: 30 additions & 0 deletions app/services/email/smtp/oauth2_tokensource.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package smtp

import (
"crypto/sha256"
"encoding/hex"
"sort"
"strings"
"sync"

"golang.org/x/oauth2"
)

var (
tokenSourceMu sync.Mutex
tokenSourceByKey = map[string]oauth2.TokenSource{}
)

func tokenSourceKey(tokenURL, clientID string, scopes []string) string {
normalized := make([]string, 0, len(scopes))
for _, scope := range scopes {
scope = strings.TrimSpace(scope)
if scope != "" {
normalized = append(normalized, scope)
}
}
sort.Strings(normalized)

sum := sha256.Sum256([]byte(tokenURL + "|" + clientID + "|" + strings.Join(normalized, ",")))
return hex.EncodeToString(sum[:])
}
35 changes: 30 additions & 5 deletions app/services/email/smtp/smtp.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
gosmtp "net/smtp"
"net/url"
"strconv"
"strings"
"time"

"github.com/getfider/fider/app/models/cmd"
Expand Down Expand Up @@ -99,7 +100,11 @@ func sendMail(ctx context.Context, c *cmd.SendMail) {

smtpConfig := env.Config.Email.SMTP
servername := fmt.Sprintf("%s:%s", smtpConfig.Host, smtpConfig.Port)
auth := authenticate(smtpConfig.Username, smtpConfig.Password, smtpConfig.Host)
auth, err := authenticate(ctx, smtpConfig)
if err != nil {
panic(errors.Wrap(err, "failed to build smtp auth"))
}

err = Send(localname, servername, smtpConfig.EnableStartTLS, auth, email.NoReply, []string{to.Address}, b.Bytes())
if err != nil {
panic(errors.Wrap(err, "failed to send email with template %s", c.TemplateName))
Expand Down Expand Up @@ -169,11 +174,31 @@ func generateMessageID(localName string) string {
return messageID
}

func authenticate(username string, password string, host string) gosmtp.Auth {
if username == "" && password == "" {
return nil
func authenticate(ctx context.Context, cfg env.SMTPConfig) (gosmtp.Auth, error) {
mech := strings.ToLower(strings.TrimSpace(cfg.AuthMechanism))
switch mech {
case "", "agnostic":
if cfg.Username == "" && cfg.Password == "" {
return nil, nil
}
return AgnosticAuth("", cfg.Username, cfg.Password, cfg.Host), nil

case "xoauth2":
if cfg.Username == "" {
return nil, errors.New("smtp: username is required for XOAUTH2")
}

scopes := splitCommaScopes(cfg.Scopes)
token, err := getClientCredentialsToken(ctx, cfg.TokenUrl, cfg.ClientId, cfg.ClientSecret, scopes)
if err != nil {
return nil, err
}

return XOAuth2Auth(cfg.Username, token, cfg.Host), nil

default:
return nil, errors.New("smtp: unsupported auth mechanism")
}
return AgnosticAuth("", username, password, host)
}

type builder struct {
Expand Down
Loading