refactor: mail service abstraction layer

This commit is contained in:
Ferdinand Mütsch 2021-04-30 15:14:29 +02:00
parent 29c04c3ac5
commit a4e7158db2
10 changed files with 128 additions and 144 deletions

View File

@ -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 |

View File

@ -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 <noreply@wakapi.dev>
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 <noreply@wakapi.dev> # 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:

View File

@ -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 {

View File

@ -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
}

View File

@ -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) {

View File

@ -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)

View File

@ -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
}

View File

@ -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
}

17
services/mail/types.go Normal file
View File

@ -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
}

View File

@ -111,7 +111,7 @@
<tr>
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;">
<p style="font-family: sans-serif; font-size: 18px; font-weight: 500; margin: 0; Margin-bottom: 15px;">Your Stats from {{ .Report.From | simpledate }} to {{ .Report.To | simpledate }}</p>
<p style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; Margin-bottom: 15px;">You have coded a total of <strong>{{ .Report.Summary.TotalTime | duration }}</strong> between {{ .Report.From | simpledate }} and {{ .Report.To | simpledate }}</p>
<p style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; Margin-bottom: 15px;">You have coded a total of <strong>{{ .Report.Summary.TotalTime | duration }}</strong> between {{ .Report.From | simpledate }} and {{ .Report.To | simpledate }}.</p>
<p style="font-family: sans-serif; font-size: 16px; font-weight: 500; margin: 0; Margin-bottom: 15px; Margin-top: 30px;">Projects</p>
<table border="0" cellpadding="0" cellspacing="0" class="btn btn-primary" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; box-sizing: border-box;">