1
0
mirror of https://github.com/muety/wakapi.git synced 2023-08-10 21:12:56 +03:00

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.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 | | `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.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.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.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 | | `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 sample_rate_heartbeats: 0.1 # probability of tracing a heartbeat request
mail: mail:
enabled: true # whether to enable mails (used for password resets, reports, etc.) enabled: true # whether to enable mails (used for password resets, reports, etc.)
provider: smtp # method for sending mails, currently one of ['smtp', 'mailwhale'] provider: smtp # method for sending mails, currently one of ['smtp', 'mailwhale']
smtp: # smtp settings when sending mails via smtp sender: Wakapi <noreply@wakapi.dev> # ignored for mailwhale
host:
port: # smtp settings when sending mails via smtp
username: smtp:
password: host:
tls: port:
sender: Wakapi <noreply@wakapi.dev> username:
mailwhale: # mailwhale.dev settings when using mailwhale as sending service password:
tls:
# mailwhale.dev settings when using mailwhale as sending service
mailwhale:
url: url:
client_id: client_id:
client_secret: client_secret:

View File

@ -116,6 +116,7 @@ type mailConfig struct {
Provider string `env:"WAKAPI_MAIL_PROVIDER" default:"smtp"` Provider string `env:"WAKAPI_MAIL_PROVIDER" default:"smtp"`
MailWhale MailwhaleMailConfig `yaml:"mailwhale"` MailWhale MailwhaleMailConfig `yaml:"mailwhale"`
Smtp SMTPMailConfig `yaml:"smtp"` Smtp SMTPMailConfig `yaml:"smtp"`
Sender string `env:"WAKAPI_MAIL_SENDER" yaml:"sender"`
} }
type MailwhaleMailConfig struct { type MailwhaleMailConfig struct {
@ -130,7 +131,6 @@ type SMTPMailConfig struct {
Username string `env:"WAKAPI_MAIL_SMTP_USER"` Username string `env:"WAKAPI_MAIL_SMTP_USER"`
Password string `env:"WAKAPI_MAIL_SMTP_PASS"` Password string `env:"WAKAPI_MAIL_SMTP_PASS"`
TLS bool `env:"WAKAPI_MAIL_SMTP_TLS"` TLS bool `env:"WAKAPI_MAIL_SMTP_TLS"`
Sender string `env:"WAKAPI_MAIL_SMTP_SENDER"`
} }
type Config struct { type Config struct {

View File

@ -5,6 +5,9 @@ import (
"strings" "strings"
) )
const HtmlType = "text/html; charset=UTF-8"
const PlainType = "text/html; charset=UTF-8"
type Mail struct { type Mail struct {
From MailAddress From MailAddress
To MailAddresses To MailAddresses
@ -15,13 +18,13 @@ type Mail struct {
func (m *Mail) WithText(text string) *Mail { func (m *Mail) WithText(text string) *Mail {
m.Body = text m.Body = text
m.Type = "text/plain; charset=UTF-8" m.Type = PlainType
return m return m
} }
func (m *Mail) WithHTML(html string) *Mail { func (m *Mail) WithHTML(html string) *Mail {
m.Body = html m.Body = html
m.Type = "text/html; charset=UTF-8" m.Type = HtmlType
return m return m
} }

View File

@ -5,11 +5,12 @@ import (
"fmt" "fmt"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
"github.com/muety/wakapi/routes" "github.com/muety/wakapi/routes"
"github.com/muety/wakapi/services"
"html/template" "html/template"
"io/ioutil" "io/ioutil"
"time"
conf "github.com/muety/wakapi/config" conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/views" "github.com/muety/wakapi/views"
) )
@ -22,31 +23,76 @@ const (
subjectReport = "Wakapi Your Latest Report" subjectReport = "Wakapi Your Latest Report"
) )
type PasswordResetTplData struct { type SendingService interface {
ResetLink string Send(*models.Mail) error
} }
type ImportNotificationTplData struct { type MailService struct {
PublicUrl string config *conf.Config
Duration string sendingService SendingService
NumHeartbeats int
} }
type ReportTplData struct {
Report *models.Report
}
// Factory
func NewMailService() services.IMailService { func NewMailService() services.IMailService {
config := conf.Get() config := conf.Get()
var sendingService SendingService
sendingService = &NoopSendingService{}
if config.Mail.Enabled { if config.Mail.Enabled {
if config.Mail.Provider == conf.MailProviderMailWhale { 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 { } 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) { func getPasswordResetTemplate(data PasswordResetTplData) (*bytes.Buffer, error) {

View File

@ -11,8 +11,7 @@ import (
"time" "time"
) )
type MailWhaleMailService struct { type MailWhaleSendingService struct {
publicUrl string
config conf.MailwhaleMailConfig config conf.MailwhaleMailConfig
httpClient *http.Client httpClient *http.Client
} }
@ -26,57 +25,28 @@ type MailWhaleSendRequest struct {
TemplateVars map[string]string `json:"template_vars"` TemplateVars map[string]string `json:"template_vars"`
} }
func NewMailWhaleService(config conf.MailwhaleMailConfig, publicUrl string) *MailWhaleMailService { func NewMailWhaleSendingService(config conf.MailwhaleMailConfig) *MailWhaleSendingService {
return &MailWhaleMailService{ return &MailWhaleSendingService{
publicUrl: publicUrl, config: config,
config: config,
httpClient: &http.Client{ httpClient: &http.Client{
Timeout: 10 * time.Second, Timeout: 10 * time.Second,
}, },
} }
} }
func (s *MailWhaleMailService) SendPasswordReset(recipient *models.User, resetLink string) error { func (s *MailWhaleSendingService) Send(mail *models.Mail) error {
template, err := getPasswordResetTemplate(PasswordResetTplData{ResetLink: resetLink}) if len(mail.To) == 0 {
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 == "" {
return errors.New("not sending mail as recipient mail address seems to be invalid") return errors.New("not sending mail as recipient mail address seems to be invalid")
} }
sendRequest := &MailWhaleSendRequest{ sendRequest := &MailWhaleSendRequest{
To: []string{to}, To: mail.To.Strings(),
Subject: subject, Subject: mail.Subject,
} }
if isHtml { if mail.Type == models.HtmlType {
sendRequest.Html = body sendRequest.Html = mail.Body
} else { } else {
sendRequest.Text = body sendRequest.Text = mail.Body
} }
payload, _ := json.Marshal(sendRequest) payload, _ := json.Marshal(sendRequest)

View File

@ -3,24 +3,11 @@ package mail
import ( import (
"github.com/emvi/logbuch" "github.com/emvi/logbuch"
"github.com/muety/wakapi/models" "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 *NoopSendingService) Send(mail *models.Mail) error {
logbuch.Info("noop mail service doing nothing instead of sending password reset mail to [%v]", mail.To.Strings())
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)
return nil return nil
} }

View File

@ -2,29 +2,21 @@ package mail
import ( import (
"errors" "errors"
"fmt"
"github.com/emersion/go-sasl" "github.com/emersion/go-sasl"
"github.com/emersion/go-smtp" "github.com/emersion/go-smtp"
conf "github.com/muety/wakapi/config" conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
"io" "io"
"time"
) )
type SMTPMailService struct { type SMTPSendingService struct {
publicUrl string config conf.SMTPMailConfig
config conf.SMTPMailConfig auth sasl.Client
auth sasl.Client
} }
func (s *SMTPMailService) SendReport(recipient *models.User, report *models.Report) error { func NewSMTPSendingService(config conf.SMTPMailConfig) *SMTPSendingService {
panic("implement me") // TODO return &SMTPSendingService{
} config: config,
func NewSMTPMailService(config conf.SMTPMailConfig, publicUrl string) *SMTPMailService {
return &SMTPMailService{
publicUrl: publicUrl,
config: config,
auth: sasl.NewPlainClient( auth: sasl.NewPlainClient(
"", "",
config.Username, config.Username,
@ -33,51 +25,15 @@ func NewSMTPMailService(config conf.SMTPMailConfig, publicUrl string) *SMTPMailS
} }
} }
func (s *SMTPMailService) SendPasswordReset(recipient *models.User, resetLink string) error { func (s *SMTPSendingService) Send(mail *models.Mail) 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 {
dial := smtp.Dial dial := smtp.Dial
if tls { if s.config.TLS {
dial = func(addr string) (*smtp.Client, error) { dial = func(addr string) (*smtp.Client, error) {
return smtp.DialTLS(addr, nil) return smtp.DialTLS(addr, nil)
} }
} }
c, err := dial(addr) c, err := dial(s.config.ConnStr())
if err != nil { if err != nil {
return err return err
} }
@ -89,18 +45,18 @@ func (s *SMTPMailService) send(addr string, tls bool, a sasl.Client, from string
return err return err
} }
} }
if a != nil { if s.auth != nil {
if ok, _ := c.Extension("AUTH"); !ok { if ok, _ := c.Extension("AUTH"); !ok {
return errors.New("smtp: server doesn't support AUTH") 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 return err
} }
} }
if err = c.Mail(from, nil); err != nil { if err = c.Mail(mail.From.Raw(), nil); err != nil {
return err return err
} }
for _, addr := range to { for _, addr := range mail.To.RawStrings() {
if err = c.Rcpt(addr); err != nil { if err = c.Rcpt(addr); err != nil {
return err return err
} }
@ -109,7 +65,7 @@ func (s *SMTPMailService) send(addr string, tls bool, a sasl.Client, from string
if err != nil { if err != nil {
return err return err
} }
_, err = io.Copy(w, r) _, err = io.Copy(w, mail.Reader())
if err != nil { if err != nil {
return err 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> <tr>
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;"> <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: 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> <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;"> <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;">