From 470680917079d98db87379f8096b5a4c42ed0072 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ferdinand=20M=C3=BCtsch?= Date: Sat, 10 Apr 2021 00:07:13 +0200 Subject: [PATCH] feat: smtp mail provider implementation --- README.md | 4 ++ config.default.yml | 17 ++++++- config/config.go | 21 +++++++- go.mod | 2 + go.sum | 7 +++ models/mail.go | 45 +++++++++++++++++ models/mail_address.go | 57 +++++++++++++++++++++ models/user.go | 10 +--- repositories/user.go | 1 + routes/login.go | 9 +++- services/mail/mail.go | 56 +++++++++++++++++---- services/mail/mailwhale.go | 55 ++++---------------- services/mail/noop.go | 13 +++++ services/mail/smtp.go | 100 +++++++++++++++++++++++++++++++++++++ services/mail/utils.go | 1 + 15 files changed, 332 insertions(+), 66 deletions(-) create mode 100644 models/mail.go create mode 100644 models/mail_address.go create mode 100644 services/mail/noop.go create mode 100644 services/mail/smtp.go create mode 100644 services/mail/utils.go diff --git a/README.md b/README.md index cfab4a4..1d93dba 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,10 @@ You can specify configuration options either via a config file (default: `config | `db.charset` | `WAKAPI_DB_CHARSET` | `utf8mb4` | Database connection charset (for MySQL only) | | `db.max_conn` | `WAKAPI_DB_MAX_CONNECTIONS` | `2` | Maximum number of database connections | | `db.ssl` | `WAKAPI_DB_SSL` | `false` | Whether to use TLS encryption for database connection (Postgres and CockroachDB only) | +| `mail.enabled` | `WAKAPI_MAIL_ENABLED` | `true` | Whether to allow Wakapi to send e-mail (e.g. for password resets) | +| `mail.provider` | `WAKAPI_MAIL_PROVIDER` | `smtp` | Implementation to use for sending mails (one of [`smtp`, `mailwhale`]) | +| `mail.smtp.*` | `WAKAPI_MAIL_SMTP_*` | `-` | Various options to configure SMTP. See [default config](config.default.yaml) for details | +| `mail.mailwhale.*` | `WAKAPI_MAIL_MAILWHALE_*` | `-` | Various options to configure [MailWhale](https://mailwhale.dev) sending service. See [default config](config.default.yaml) for details | | `sentry.dsn` | `WAKAPI_SENTRY_DSN` | – | DSN for to integrate [Sentry](https://sentry.io) for error logging and tracing (leave empty to disable) | | `sentry.enable_tracing` | `WAKAPI_SENTRY_TRACING` | `false` | Whether to enable Sentry request tracing | | `sentry.sample_rate` | `WAKAPI_SENTRY_SAMPLE_RATE` | `0.75` | Probability of tracing a request in Sentry | diff --git a/config.default.yml b/config.default.yml index 178f121..b7b359c 100644 --- a/config.default.yml +++ b/config.default.yml @@ -39,4 +39,19 @@ sentry: dsn: # leave blank to disable sentry integration enable_tracing: true # whether to use performance monitoring sample_rate: 0.75 # probability of tracing a request - sample_rate_heartbeats: 0.1 # probability of tracing a heartbeat request \ No newline at end of file + sample_rate_heartbeats: 0.1 # probability of tracing a heartbeat request + +mail: + enabled: true # whether to enable mails (used for password resets, reports, etc.) + provider: smtp # method for sending mails, currently one of ['smtp', 'mailwhale'] + smtp: # smtp settings when sending mails via smtp + host: + port: + username: + password: + tls: + sender: Wakapi + mailwhale: # mailwhale.dev settings when using mailwhale as sending service + url: + client_id: + client_secret: diff --git a/config/config.go b/config/config.go index f387ff7..db362c6 100644 --- a/config/config.go +++ b/config/config.go @@ -50,10 +50,12 @@ const ( ) const ( + MailProviderSmtp = "smtp" MailProviderMailWhale = "mailwhale" ) var emailProviders = []string{ + MailProviderSmtp, MailProviderMailWhale, } @@ -97,7 +99,7 @@ type serverConfig struct { ListenIpV4 string `yaml:"listen_ipv4" default:"127.0.0.1" env:"WAKAPI_LISTEN_IPV4"` ListenIpV6 string `yaml:"listen_ipv6" default:"::1" env:"WAKAPI_LISTEN_IPV6"` BasePath string `yaml:"base_path" default:"/" env:"WAKAPI_BASE_PATH"` - PublicUrl string `yaml:"public_url" default:"https://wakapi.dev/" env:"WAKAPI_PUBLIC_URL"` + PublicUrl string `yaml:"public_url" default:"http://localhost:3000" env:"WAKAPI_PUBLIC_URL"` TlsCertPath string `yaml:"tls_cert_path" default:"" env:"WAKAPI_TLS_CERT_PATH"` TlsKeyPath string `yaml:"tls_key_path" default:"" env:"WAKAPI_TLS_KEY_PATH"` } @@ -110,8 +112,10 @@ type sentryConfig struct { } type mailConfig struct { - Provider string `env:"WAKAPI_MAIL_PROVIDER"` + Enabled bool `env:"WAKAPI_MAIL_ENABLED" default:"true"` + Provider string `env:"WAKAPI_MAIL_PROVIDER" default:"smtp"` MailWhale *MailwhaleMailConfig `yaml:"mailwhale"` + Smtp *SMTPMailConfig `yaml:"smtp"` } type MailwhaleMailConfig struct { @@ -120,6 +124,15 @@ type MailwhaleMailConfig struct { ClientSecret string `yaml:"client_secret" env:"WAKAPI_MAIL_MAILWHALE_CLIENT_SECRET"` } +type SMTPMailConfig struct { + Host string `env:"WAKAPI_MAIL_SMTP_HOST"` + Port uint `env:"WAKAPI_MAIL_SMTP_PORT"` + Username string `env:"WAKAPI_MAIL_SMTP_USER"` + Password string `env:"WAKAPI_MAIL_SMTP_PASS"` + TLS bool `env:"WAKAPI_MAIL_SMTP_TLS"` + Sender string `env:"WAKAPI_MAIL_SMTP_SENDER"` +} + type Config struct { Env string `default:"dev" env:"ENVIRONMENT"` Version string `yaml:"-"` @@ -263,6 +276,10 @@ func (c *serverConfig) GetPublicUrl() string { return strings.TrimSuffix(c.PublicUrl, "/") } +func (c *SMTPMailConfig) ConnStr() string { + return fmt.Sprintf("%s:%d", c.Host, c.Port) +} + func IsDev(env string) bool { return env == "dev" || env == "development" } diff --git a/go.mod b/go.mod index dd1708e..be47220 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,8 @@ go 1.13 require ( github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 + github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 + github.com/emersion/go-smtp v0.15.0 github.com/emvi/logbuch v1.1.1 github.com/getsentry/sentry-go v0.10.0 github.com/go-co-op/gocron v0.3.3 diff --git a/go.sum b/go.sum index 21de677..f26efb4 100644 --- a/go.sum +++ b/go.sum @@ -77,6 +77,10 @@ github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1 github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M= github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM= +github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ= +github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= +github.com/emersion/go-smtp v0.15.0 h1:3+hMGMGrqP/lqd7qoxZc1hTU8LY8gHV9RFGWlqSDmP8= +github.com/emersion/go-smtp v0.15.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ= github.com/emvi/logbuch v1.1.1 h1:poBGNbHy/nB95oNoqLKAaJoBrcKxTO0W9DhMijKEkkU= github.com/emvi/logbuch v1.1.1/go.mod h1:J2Wgbr3BuSc1JO+D2MBVh6q3WPVSK5GzktwWz8pvkKw= github.com/envoyproxy/go-control-plane v0.6.9/go.mod h1:SBwIajubJHhxtWwsL9s8ss4safvEdbitLhGGK48rN6g= @@ -99,6 +103,7 @@ github.com/gin-gonic/gin v1.4.0/go.mod h1:OW2EZn3DO8Ln9oIKOvM++LBO+5UPHJJDH72/q/ github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98= github.com/go-co-op/gocron v0.3.3 h1:QnarcMZWWKrEP25uCbtDiLsnnGw+PhCjL3wNITdWJOs= github.com/go-co-op/gocron v0.3.3/go.mod h1:Y9PWlYqDChf2Nbgg7kfS+ZsXHDTZbMZYPEQ0MILqH+M= +github.com/go-errors/errors v1.0.1 h1:LUHzmkK3GUKUrL/1gfBUxAHzcev3apQlezX/+O7ma6w= github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= @@ -166,6 +171,7 @@ github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -408,6 +414,7 @@ github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/9 github.com/performancecopilot/speed v3.0.0+incompatible/go.mod h1:/CLtqpZ5gBg1M9iaPbIdPPGyKcA8hKdoy6hAWba7Yac= github.com/pierrec/lz4 v1.0.2-0.20190131084431-473cd7ce01a1/go.mod h1:3/3N9NVKO0jef7pBehbT1qWhCMrIgbYNnFAZCqQ5LRc= github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= +github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= diff --git a/models/mail.go b/models/mail.go new file mode 100644 index 0000000..f0e229c --- /dev/null +++ b/models/mail.go @@ -0,0 +1,45 @@ +package models + +import ( + "fmt" + "strings" +) + +type Mail struct { + From MailAddress + To MailAddresses + Subject string + Body string + Type string +} + +func (m *Mail) WithText(text string) *Mail { + m.Body = text + m.Type = "text/plain; charset=UTF-8" + return m +} + +func (m *Mail) WithHTML(html string) *Mail { + m.Body = html + m.Type = "text/html; charset=UTF-8" + return m +} + +func (m *Mail) String() string { + return fmt.Sprintf("To: %s\r\n"+ + "From: %s\r\n"+ + "Subject: %s\r\n"+ + "Content-Type: %s\r\n"+ + "\r\n"+ + "%s\r\n", + strings.Join(m.To.RawStrings(), ", "), + m.From.String(), + m.Subject, + m.Type, + m.Body, + ) +} + +func (m *Mail) Reader() *strings.Reader { + return strings.NewReader(m.String()) +} diff --git a/models/mail_address.go b/models/mail_address.go new file mode 100644 index 0000000..726a680 --- /dev/null +++ b/models/mail_address.go @@ -0,0 +1,57 @@ +package models + +import "regexp" + +const ( + MailPattern = "[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+" + EmailAddrPattern = ".*\\s<(" + MailPattern + ")>|(" + MailPattern + ")" +) + +var ( + mailRegex *regexp.Regexp + emailAddrRegex *regexp.Regexp +) + +func init() { + mailRegex = regexp.MustCompile(MailPattern) + emailAddrRegex = regexp.MustCompile(EmailAddrPattern) +} + +type MailAddress string + +type MailAddresses []MailAddress + +func (m MailAddress) String() string { + return string(m) +} + +func (m MailAddress) Raw() string { + match := emailAddrRegex.FindStringSubmatch(string(m)) + if len(match) == 3 { + if match[2] != "" { + return match[2] + } + return match[1] + } + return "" +} + +func (m MailAddress) Valid() bool { + return emailAddrRegex.Match([]byte(m)) +} + +func (m MailAddresses) Strings() []string { + out := make([]string, len(m)) + for i, s := range m { + out[i] = s.String() + } + return out +} + +func (m MailAddresses) RawStrings() []string { + out := make([]string, len(m)) + for i, s := range m { + out[i] = s.Raw() + } + return out +} diff --git a/models/user.go b/models/user.go index 71bdd8c..031b0f1 100644 --- a/models/user.go +++ b/models/user.go @@ -2,14 +2,6 @@ package models import "regexp" -const ( - MailPattern = "[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+" -) - -var ( - mailRegex *regexp.Regexp -) - func init() { mailRegex = regexp.MustCompile(MailPattern) } @@ -17,7 +9,7 @@ func init() { type User struct { ID string `json:"id" gorm:"primary_key"` ApiKey string `json:"api_key" gorm:"unique"` - Email string `json:"email"` + Email string `json:"email" gorm:"uniqueIndex:idx_user_email"` Password string `json:"-"` CreatedAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"` LastLoggedInAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"` diff --git a/repositories/user.go b/repositories/user.go index 58e7aef..f8ab48d 100644 --- a/repositories/user.go +++ b/repositories/user.go @@ -141,6 +141,7 @@ func (r *UserRepository) Update(user *models.User) (*models.User, error) { "share_machines": user.ShareMachines, "wakatime_api_key": user.WakatimeApiKey, "has_data": user.HasData, + "reset_token": user.ResetToken, } result := r.db.Model(user).Updates(updateMap) diff --git a/routes/login.go b/routes/login.go index a0357ed..166f606 100644 --- a/routes/login.go +++ b/routes/login.go @@ -234,6 +234,7 @@ func (h *LoginHandler) PostSetPassword(w http.ResponseWriter, r *http.Request) { } user.Password = setRequest.Password + user.ResetToken = "" if hash, err := utils.HashBcrypt(user.Password, h.config.Security.PasswordSalt); err != nil { w.WriteHeader(http.StatusInternalServerError) templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("failed to set new password")) @@ -256,6 +257,12 @@ func (h *LoginHandler) PostResetPassword(w http.ResponseWriter, r *http.Request) loadTemplates() } + if !h.config.Mail.Enabled { + w.WriteHeader(http.StatusNotImplemented) + templates[conf.ResetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("mailing is disabled on this server")) + return + } + var resetRequest models.ResetPasswordRequest if err := r.ParseForm(); err != nil { w.WriteHeader(http.StatusBadRequest) @@ -277,7 +284,7 @@ func (h *LoginHandler) PostResetPassword(w http.ResponseWriter, r *http.Request) go func(user *models.User) { link := fmt.Sprintf("%s/set-password?token=%s", h.config.Server.GetPublicUrl(), user.ResetToken) if err := h.mailSrvc.SendPasswordResetMail(user, link); err != nil { - logbuch.Error("%v", err) + logbuch.Error("failed to send password reset mail to %s – %v", user.ID, err) } else { logbuch.Info("sent password reset mail to %s", user.ID) } diff --git a/services/mail/mail.go b/services/mail/mail.go index a40d1a6..5aeb07c 100644 --- a/services/mail/mail.go +++ b/services/mail/mail.go @@ -1,23 +1,61 @@ package mail import ( - "github.com/emvi/logbuch" + "bytes" + "fmt" + "github.com/markbates/pkger" conf "github.com/muety/wakapi/config" - "github.com/muety/wakapi/models" "github.com/muety/wakapi/services" + "io/ioutil" + "text/template" ) +const ( + tplPath = "/views/mail" + tplNamePasswordReset = "reset_password" + subjectPasswordReset = "Wakapi – Password Reset" +) + +type passwordResetLinkTplData struct { + ResetLink string +} + +// Factory func NewMailService() services.IMailService { config := conf.Get() - if config.Mail.Provider == conf.MailProviderMailWhale { - return NewMailWhaleService(config.Mail.MailWhale) + if config.Mail.Enabled { + if config.Mail.Provider == conf.MailProviderMailWhale { + return NewMailWhaleService(config.Mail.MailWhale) + } else if config.Mail.Provider == conf.MailProviderSmtp { + return NewSMTPMailService(config.Mail.Smtp) + } } return &NoopMailService{} } -type NoopMailService struct{} - -func (n NoopMailService) SendPasswordResetMail(recipient *models.User, resetLink string) error { - logbuch.Info("noop mail service doing nothing instead of sending password reset mail to %s", recipient.ID) - return nil +func getPasswordResetTemplate(data passwordResetLinkTplData) (*bytes.Buffer, error) { + tpl, err := loadTemplate(tplNamePasswordReset) + if err != nil { + return nil, err + } + var rendered bytes.Buffer + if err := tpl.Execute(&rendered, data); err != nil { + return nil, err + } + return &rendered, nil +} + +func loadTemplate(tplName string) (*template.Template, error) { + tplFile, err := pkger.Open(fmt.Sprintf("%s/%s.tpl.html", tplPath, tplName)) + if err != nil { + return nil, err + } + defer tplFile.Close() + + tplData, err := ioutil.ReadAll(tplFile) + if err != nil { + return nil, err + } + + return template.New(tplName).Parse(string(tplData)) } diff --git a/services/mail/mailwhale.go b/services/mail/mailwhale.go index 85ddc90..bae7986 100644 --- a/services/mail/mailwhale.go +++ b/services/mail/mailwhale.go @@ -5,21 +5,13 @@ import ( "encoding/json" "errors" "fmt" - "github.com/markbates/pkger" conf "github.com/muety/wakapi/config" "github.com/muety/wakapi/models" - "io/ioutil" "net/http" - "text/template" "time" ) -const ( - tplPath = "/views/mail" - tplNamePasswordReset = "reset_password" -) - -type MailWhaleService struct { +type MailWhaleMailService struct { config *conf.MailwhaleMailConfig httpClient *http.Client } @@ -33,8 +25,8 @@ type MailWhaleSendRequest struct { TemplateVars map[string]string `json:"template_vars"` } -func NewMailWhaleService(config *conf.MailwhaleMailConfig) *MailWhaleService { - return &MailWhaleService{ +func NewMailWhaleService(config *conf.MailwhaleMailConfig) *MailWhaleMailService { + return &MailWhaleMailService{ config: config, httpClient: &http.Client{ Timeout: 10 * time.Second, @@ -42,25 +34,15 @@ func NewMailWhaleService(config *conf.MailwhaleMailConfig) *MailWhaleService { } } -func (m *MailWhaleService) SendPasswordResetMail(recipient *models.User, resetLink string) error { - tpl, err := m.loadTemplate(tplNamePasswordReset) +func (s *MailWhaleMailService) SendPasswordResetMail(recipient *models.User, resetLink string) error { + template, err := getPasswordResetTemplate(passwordResetLinkTplData{ResetLink: resetLink}) if err != nil { return err } - - type data struct { - ResetLink string - } - - var rendered bytes.Buffer - if err := tpl.Execute(&rendered, data{ResetLink: resetLink}); err != nil { - return err - } - - return m.send(recipient.Email, "Wakapi – Password Reset", rendered.String(), true) + return s.send(recipient.Email, subjectPasswordReset, template.String(), true) } -func (m *MailWhaleService) send(to, subject, body string, isHtml bool) error { +func (s *MailWhaleMailService) send(to, subject, body string, isHtml bool) error { if to == "" { return errors.New("no recipient mail address set, cannot send password reset link") } @@ -76,36 +58,21 @@ func (m *MailWhaleService) send(to, subject, body string, isHtml bool) error { } payload, _ := json.Marshal(sendRequest) - req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/api/mail", m.config.Url), bytes.NewBuffer(payload)) + req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/api/mail", s.config.Url), bytes.NewBuffer(payload)) if err != nil { return err } - req.SetBasicAuth(m.config.ClientId, m.config.ClientSecret) + req.SetBasicAuth(s.config.ClientId, s.config.ClientSecret) req.Header.Set("Content-Type", "application/json") - res, err := m.httpClient.Do(req) + res, err := s.httpClient.Do(req) if err != nil { return err } if res.StatusCode >= 400 { - return errors.New(fmt.Sprintf("failed to send password reset mail to %v, got status %d from mailwhale", to, res.StatusCode)) + return errors.New(fmt.Sprintf("got status %d from mailwhale", res.StatusCode)) } return nil } - -func (m *MailWhaleService) loadTemplate(tplName string) (*template.Template, error) { - tplFile, err := pkger.Open(fmt.Sprintf("%s/%s.tpl.html", tplPath, tplName)) - if err != nil { - return nil, err - } - defer tplFile.Close() - - tplData, err := ioutil.ReadAll(tplFile) - if err != nil { - return nil, err - } - - return template.New(tplName).Parse(string(tplData)) -} diff --git a/services/mail/noop.go b/services/mail/noop.go new file mode 100644 index 0000000..a1ef569 --- /dev/null +++ b/services/mail/noop.go @@ -0,0 +1,13 @@ +package mail + +import ( + "github.com/emvi/logbuch" + "github.com/muety/wakapi/models" +) + +type NoopMailService struct{} + +func (n *NoopMailService) SendPasswordResetMail(recipient *models.User, resetLink string) error { + logbuch.Info("noop mail service doing nothing instead of sending password reset mail to %s", recipient.ID) + return nil +} diff --git a/services/mail/smtp.go b/services/mail/smtp.go new file mode 100644 index 0000000..cf15b3e --- /dev/null +++ b/services/mail/smtp.go @@ -0,0 +1,100 @@ +package mail + +import ( + "errors" + "github.com/emersion/go-sasl" + "github.com/emersion/go-smtp" + conf "github.com/muety/wakapi/config" + "github.com/muety/wakapi/models" + "io" +) + +type SMTPMailService struct { + config *conf.SMTPMailConfig + auth sasl.Client +} + +func NewSMTPMailService(config *conf.SMTPMailConfig) *SMTPMailService { + return &SMTPMailService{ + config: config, + auth: sasl.NewPlainClient( + "", + config.Username, + config.Password, + ), + } +} + +func (s *SMTPMailService) SendPasswordResetMail(recipient *models.User, resetLink string) error { + template, err := getPasswordResetTemplate(passwordResetLinkTplData{ResetLink: resetLink}) + if err != nil { + return err + } + + mail := &models.Mail{ + From: models.MailAddress(s.config.Sender), + To: models.MailAddresses([]models.MailAddress{models.MailAddress(recipient.Email)}), + Subject: subjectPasswordReset, + } + mail.WithHTML(template.String()) + + return s.send( + s.config.ConnStr(), + s.config.TLS, + s.auth, + mail.From.Raw(), + mail.To.RawStrings(), + mail.Reader(), + ) +} + +func (s *SMTPMailService) send(addr string, tls bool, a sasl.Client, from string, to []string, r io.Reader) error { + dial := smtp.Dial + if tls { + dial = func(addr string) (*smtp.Client, error) { + return smtp.DialTLS(addr, nil) + } + } + + c, err := dial(addr) + if err != nil { + return err + } + + defer c.Close() + + if ok, _ := c.Extension("STARTTLS"); ok { + if err = c.StartTLS(nil); err != nil { + return err + } + } + if a != nil { + if ok, _ := c.Extension("AUTH"); !ok { + return errors.New("smtp: server doesn't support AUTH") + } + if err = c.Auth(a); err != nil { + return err + } + } + if err = c.Mail(from, nil); err != nil { + return err + } + for _, addr := range to { + if err = c.Rcpt(addr); err != nil { + return err + } + } + w, err := c.Data() + if err != nil { + return err + } + _, err = io.Copy(w, r) + if err != nil { + return err + } + err = w.Close() + if err != nil { + return err + } + return c.Quit() +} diff --git a/services/mail/utils.go b/services/mail/utils.go new file mode 100644 index 0000000..9ca19e8 --- /dev/null +++ b/services/mail/utils.go @@ -0,0 +1 @@ +package mail