diff --git a/README.md b/README.md index 7235b3d..9e3d797 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,7 @@ You can specify configuration options either via a config file (default: `config | `db.ssl` | `WAKAPI_DB_SSL` | `false` | Whether to use TLS encryption for database connection (Postgres and CockroachDB only) | | `db.automgirate_fail_silently` | `WAKAPI_DB_AUTOMIGRATE_FAIL_SILENTLY` | `false` | Whether to ignore schema auto-migration failures when starting up | | `mail.enabled` | `WAKAPI_MAIL_ENABLED` | `true` | Whether to allow Wakapi to send e-mail (e.g. for password resets) | +| `mail.sender` | `WAKAPI_MAIL_SENDER` | `noreply@wakapi.dev` | Default sender address for outgoing mails (ignored for MailWhale) | | `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 | diff --git a/config.default.yml b/config.default.yml index f321bb5..b588e08 100644 --- a/config.default.yml +++ b/config.default.yml @@ -44,16 +44,20 @@ sentry: 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 + enabled: true # whether to enable mails (used for password resets, reports, etc.) + provider: smtp # method for sending mails, currently one of ['smtp', 'mailwhale'] + sender: Wakapi # ignored for mailwhale + + # smtp settings when sending mails via smtp + smtp: + host: + port: + username: + password: + tls: + + # mailwhale.dev settings when using mailwhale as sending service + mailwhale: url: client_id: client_secret: diff --git a/config/config.go b/config/config.go index 1c3d0de..747a409 100644 --- a/config/config.go +++ b/config/config.go @@ -116,6 +116,7 @@ type mailConfig struct { Provider string `env:"WAKAPI_MAIL_PROVIDER" default:"smtp"` MailWhale MailwhaleMailConfig `yaml:"mailwhale"` Smtp SMTPMailConfig `yaml:"smtp"` + Sender string `env:"WAKAPI_MAIL_SENDER" yaml:"sender"` } type MailwhaleMailConfig struct { @@ -130,7 +131,6 @@ type SMTPMailConfig struct { 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 { diff --git a/models/mail.go b/models/mail.go index f0e229c..052002b 100644 --- a/models/mail.go +++ b/models/mail.go @@ -5,6 +5,9 @@ import ( "strings" ) +const HtmlType = "text/html; charset=UTF-8" +const PlainType = "text/html; charset=UTF-8" + type Mail struct { From MailAddress To MailAddresses @@ -15,13 +18,13 @@ type Mail struct { func (m *Mail) WithText(text string) *Mail { m.Body = text - m.Type = "text/plain; charset=UTF-8" + m.Type = PlainType return m } func (m *Mail) WithHTML(html string) *Mail { m.Body = html - m.Type = "text/html; charset=UTF-8" + m.Type = HtmlType return m } diff --git a/services/mail/mail.go b/services/mail/mail.go index 5e33123..b5b19bd 100644 --- a/services/mail/mail.go +++ b/services/mail/mail.go @@ -5,11 +5,12 @@ import ( "fmt" "github.com/muety/wakapi/models" "github.com/muety/wakapi/routes" + "github.com/muety/wakapi/services" "html/template" "io/ioutil" + "time" conf "github.com/muety/wakapi/config" - "github.com/muety/wakapi/services" "github.com/muety/wakapi/views" ) @@ -22,31 +23,76 @@ const ( subjectReport = "Wakapi – Your Latest Report" ) -type PasswordResetTplData struct { - ResetLink string +type SendingService interface { + Send(*models.Mail) error } -type ImportNotificationTplData struct { - PublicUrl string - Duration string - NumHeartbeats int +type MailService struct { + config *conf.Config + sendingService SendingService } -type ReportTplData struct { - Report *models.Report -} - -// Factory func NewMailService() services.IMailService { config := conf.Get() + + var sendingService SendingService + sendingService = &NoopSendingService{} + if config.Mail.Enabled { if config.Mail.Provider == conf.MailProviderMailWhale { - return NewMailWhaleService(config.Mail.MailWhale, config.Server.PublicUrl) + sendingService = NewMailWhaleSendingService(config.Mail.MailWhale) } else if config.Mail.Provider == conf.MailProviderSmtp { - return NewSMTPMailService(config.Mail.Smtp, config.Server.PublicUrl) + sendingService = NewSMTPSendingService(config.Mail.Smtp) } } - return &NoopMailService{} + + return &MailService{sendingService: sendingService, config: config} +} + +func (m *MailService) SendPasswordReset(recipient *models.User, resetLink string) error { + tpl, err := getPasswordResetTemplate(PasswordResetTplData{ResetLink: resetLink}) + if err != nil { + return err + } + mail := &models.Mail{ + From: models.MailAddress(m.config.Mail.Sender), + To: models.MailAddresses([]models.MailAddress{models.MailAddress(recipient.Email)}), + Subject: subjectPasswordReset, + } + mail.WithHTML(tpl.String()) + return m.sendingService.Send(mail) +} + +func (m *MailService) SendImportNotification(recipient *models.User, duration time.Duration, numHeartbeats int) error { + tpl, err := getImportNotificationTemplate(ImportNotificationTplData{ + PublicUrl: m.config.Server.PublicUrl, + Duration: fmt.Sprintf("%.0f seconds", duration.Seconds()), + NumHeartbeats: numHeartbeats, + }) + if err != nil { + return err + } + mail := &models.Mail{ + From: models.MailAddress(m.config.Mail.Sender), + To: models.MailAddresses([]models.MailAddress{models.MailAddress(recipient.Email)}), + Subject: subjectImportNotification, + } + mail.WithHTML(tpl.String()) + return m.sendingService.Send(mail) +} + +func (m *MailService) SendReport(recipient *models.User, report *models.Report) error { + tpl, err := getReportTemplate(ReportTplData{report}) + if err != nil { + return err + } + mail := &models.Mail{ + From: models.MailAddress(m.config.Mail.Sender), + To: models.MailAddresses([]models.MailAddress{models.MailAddress(recipient.Email)}), + Subject: subjectReport, + } + mail.WithHTML(tpl.String()) + return m.sendingService.Send(mail) } func getPasswordResetTemplate(data PasswordResetTplData) (*bytes.Buffer, error) { diff --git a/services/mail/mailwhale.go b/services/mail/mailwhale.go index bbfc645..2fee253 100644 --- a/services/mail/mailwhale.go +++ b/services/mail/mailwhale.go @@ -11,8 +11,7 @@ import ( "time" ) -type MailWhaleMailService struct { - publicUrl string +type MailWhaleSendingService struct { config conf.MailwhaleMailConfig httpClient *http.Client } @@ -26,57 +25,28 @@ type MailWhaleSendRequest struct { TemplateVars map[string]string `json:"template_vars"` } -func NewMailWhaleService(config conf.MailwhaleMailConfig, publicUrl string) *MailWhaleMailService { - return &MailWhaleMailService{ - publicUrl: publicUrl, - config: config, +func NewMailWhaleSendingService(config conf.MailwhaleMailConfig) *MailWhaleSendingService { + return &MailWhaleSendingService{ + config: config, httpClient: &http.Client{ Timeout: 10 * time.Second, }, } } -func (s *MailWhaleMailService) SendPasswordReset(recipient *models.User, resetLink string) error { - template, err := getPasswordResetTemplate(PasswordResetTplData{ResetLink: resetLink}) - if err != nil { - return err - } - return s.send(recipient.Email, subjectPasswordReset, template.String(), true) -} - -func (s *MailWhaleMailService) SendImportNotification(recipient *models.User, duration time.Duration, numHeartbeats int) error { - template, err := getImportNotificationTemplate(ImportNotificationTplData{ - PublicUrl: s.publicUrl, - Duration: fmt.Sprintf("%.0f seconds", duration.Seconds()), - NumHeartbeats: numHeartbeats, - }) - if err != nil { - return err - } - return s.send(recipient.Email, subjectImportNotification, template.String(), true) -} - -func (s *MailWhaleMailService) SendReport(recipient *models.User, report *models.Report) error { - template, err := getReportTemplate(ReportTplData{report}) - if err != nil { - return err - } - return s.send(recipient.Email, subjectReport, template.String(), true) -} - -func (s *MailWhaleMailService) send(to, subject, body string, isHtml bool) error { - if to == "" { +func (s *MailWhaleSendingService) Send(mail *models.Mail) error { + if len(mail.To) == 0 { return errors.New("not sending mail as recipient mail address seems to be invalid") } sendRequest := &MailWhaleSendRequest{ - To: []string{to}, - Subject: subject, + To: mail.To.Strings(), + Subject: mail.Subject, } - if isHtml { - sendRequest.Html = body + if mail.Type == models.HtmlType { + sendRequest.Html = mail.Body } else { - sendRequest.Text = body + sendRequest.Text = mail.Body } payload, _ := json.Marshal(sendRequest) diff --git a/services/mail/noop.go b/services/mail/noop.go index e9ab8ba..18a038d 100644 --- a/services/mail/noop.go +++ b/services/mail/noop.go @@ -3,24 +3,11 @@ package mail import ( "github.com/emvi/logbuch" "github.com/muety/wakapi/models" - "time" ) -type NoopMailService struct{} +type NoopSendingService struct{} -const notImplemented = "noop mail service doing nothing instead of sending password reset mail to %s" - -func (n *NoopMailService) SendReport(recipient *models.User, report *models.Report) error { - logbuch.Info(notImplemented, recipient.ID) - return nil -} - -func (n *NoopMailService) SendPasswordReset(recipient *models.User, resetLink string) error { - logbuch.Info(notImplemented, recipient.ID) - return nil -} - -func (n *NoopMailService) SendImportNotification(recipient *models.User, duration time.Duration, numHeartbeats int) error { - logbuch.Info(notImplemented, recipient.ID) +func (n *NoopSendingService) Send(mail *models.Mail) error { + logbuch.Info("noop mail service doing nothing instead of sending password reset mail to [%v]", mail.To.Strings()) return nil } diff --git a/services/mail/smtp.go b/services/mail/smtp.go index ae8a990..3cc69e5 100644 --- a/services/mail/smtp.go +++ b/services/mail/smtp.go @@ -2,29 +2,21 @@ package mail import ( "errors" - "fmt" "github.com/emersion/go-sasl" "github.com/emersion/go-smtp" conf "github.com/muety/wakapi/config" "github.com/muety/wakapi/models" "io" - "time" ) -type SMTPMailService struct { - publicUrl string - config conf.SMTPMailConfig - auth sasl.Client +type SMTPSendingService struct { + config conf.SMTPMailConfig + auth sasl.Client } -func (s *SMTPMailService) SendReport(recipient *models.User, report *models.Report) error { - panic("implement me") // TODO -} - -func NewSMTPMailService(config conf.SMTPMailConfig, publicUrl string) *SMTPMailService { - return &SMTPMailService{ - publicUrl: publicUrl, - config: config, +func NewSMTPSendingService(config conf.SMTPMailConfig) *SMTPSendingService { + return &SMTPSendingService{ + config: config, auth: sasl.NewPlainClient( "", config.Username, @@ -33,51 +25,15 @@ func NewSMTPMailService(config conf.SMTPMailConfig, publicUrl string) *SMTPMailS } } -func (s *SMTPMailService) SendPasswordReset(recipient *models.User, resetLink string) error { - template, err := getPasswordResetTemplate(PasswordResetTplData{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) SendImportNotification(recipient *models.User, duration time.Duration, numHeartbeats int) error { - template, err := getImportNotificationTemplate(ImportNotificationTplData{ - PublicUrl: s.publicUrl, - Duration: fmt.Sprintf("%.0f seconds", duration.Seconds()), - NumHeartbeats: numHeartbeats, - }) - if err != nil { - return err - } - - mail := &models.Mail{ - From: models.MailAddress(s.config.Sender), - To: models.MailAddresses([]models.MailAddress{models.MailAddress(recipient.Email)}), - Subject: subjectImportNotification, - } - 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 { +func (s *SMTPSendingService) Send(mail *models.Mail) error { dial := smtp.Dial - if tls { + if s.config.TLS { dial = func(addr string) (*smtp.Client, error) { return smtp.DialTLS(addr, nil) } } - c, err := dial(addr) + c, err := dial(s.config.ConnStr()) if err != nil { return err } @@ -89,18 +45,18 @@ func (s *SMTPMailService) send(addr string, tls bool, a sasl.Client, from string return err } } - if a != nil { + if s.auth != nil { if ok, _ := c.Extension("AUTH"); !ok { return errors.New("smtp: server doesn't support AUTH") } - if err = c.Auth(a); err != nil { + if err = c.Auth(s.auth); err != nil { return err } } - if err = c.Mail(from, nil); err != nil { + if err = c.Mail(mail.From.Raw(), nil); err != nil { return err } - for _, addr := range to { + for _, addr := range mail.To.RawStrings() { if err = c.Rcpt(addr); err != nil { return err } @@ -109,7 +65,7 @@ func (s *SMTPMailService) send(addr string, tls bool, a sasl.Client, from string if err != nil { return err } - _, err = io.Copy(w, r) + _, err = io.Copy(w, mail.Reader()) if err != nil { return err } diff --git a/services/mail/types.go b/services/mail/types.go new file mode 100644 index 0000000..5b74b82 --- /dev/null +++ b/services/mail/types.go @@ -0,0 +1,17 @@ +package mail + +import "github.com/muety/wakapi/models" + +type PasswordResetTplData struct { + ResetLink string +} + +type ImportNotificationTplData struct { + PublicUrl string + Duration string + NumHeartbeats int +} + +type ReportTplData struct { + Report *models.Report +} diff --git a/views/mail/report.tpl.html b/views/mail/report.tpl.html index e5b71ea..b8c5645 100644 --- a/views/mail/report.tpl.html +++ b/views/mail/report.tpl.html @@ -111,7 +111,7 @@

Your Stats from {{ .Report.From | simpledate }} to {{ .Report.To | simpledate }}

-

You have coded a total of {{ .Report.Summary.TotalTime | duration }} between {{ .Report.From | simpledate }} and {{ .Report.To | simpledate }}

+

You have coded a total of {{ .Report.Summary.TotalTime | duration }} between {{ .Report.From | simpledate }} and {{ .Report.To | simpledate }}.

Projects