diff --git a/auth/pwdless/api.go b/auth/pwdless/api.go index 0c43381..49f03b7 100644 --- a/auth/pwdless/api.go +++ b/auth/pwdless/api.go @@ -33,21 +33,16 @@ type AuthStorer interface { PurgeExpiredToken() error } -// Mailer defines methods to send account emails. -type Mailer interface { - LoginToken(name, email string, c email.ContentLoginToken) error -} - // Resource implements passwordless account authentication against a database. type Resource struct { LoginAuth *LoginTokenAuth TokenAuth *jwt.TokenAuth Store AuthStorer - Mailer Mailer + Mailer email.Mailer } // NewResource returns a configured authentication resource. -func NewResource(authStore AuthStorer, mailer Mailer) (*Resource, error) { +func NewResource(authStore AuthStorer, mailer email.Mailer) (*Resource, error) { loginAuth, err := NewLoginTokenAuth() if err != nil { return nil, err @@ -126,14 +121,17 @@ func (rs *Resource) login(w http.ResponseWriter, r *http.Request) { tokenURL, _ := url.JoinPath(rs.LoginAuth.loginURL, lt.Token) go func() { - content := email.ContentLoginToken{ + content := ContentLoginToken{ Email: acc.Email, Name: acc.Name, URL: tokenURL, Token: lt.Token, Expiry: lt.Expiry, } - if err := rs.Mailer.LoginToken(acc.Name, acc.Email, content); err != nil { + + msg := LoginTokenEmail(acc.Name, acc.Email, content) + + if err := rs.Mailer.Send(msg); err != nil { log(r).WithField("module", "email").Error(err) } }() diff --git a/auth/pwdless/api_test.go b/auth/pwdless/api_test.go index 5c876cd..f5a0aec 100644 --- a/auth/pwdless/api_test.go +++ b/auth/pwdless/api_test.go @@ -24,7 +24,7 @@ import ( var ( auth *Resource authStore MockAuthStore - mailer email.MockMailer + mailer *email.MockMailer ts *httptest.Server ) @@ -35,7 +35,9 @@ func TestMain(m *testing.M) { viper.SetDefault("log_level", "error") var err error - auth, err = NewResource(&authStore, &mailer) + + mailer = email.NewMockMailer() + auth, err = NewResource(&authStore, mailer) if err != nil { fmt.Println(err) os.Exit(1) @@ -72,10 +74,6 @@ func TestAuthResource_login(t *testing.T) { return &a, err } - mailer.LoginTokenFn = func(n, e string, c email.ContentLoginToken) error { - return nil - } - tests := []struct { name string email string @@ -105,11 +103,11 @@ func TestAuthResource_login(t *testing.T) { if tc.err == ErrInvalidLogin && authStore.GetAccountByEmailInvoked { t.Error("GetByLoginToken invoked for invalid email") } - if tc.err == nil && !mailer.LoginTokenInvoked { - t.Error("emailService.LoginToken not invoked") + if tc.err == nil && !mailer.SendInvoked { + t.Error("emailService.Send not invoked") } authStore.GetAccountByEmailInvoked = false - mailer.LoginTokenInvoked = false + mailer.SendInvoked = false }) } } diff --git a/auth/pwdless/emails.go b/auth/pwdless/emails.go new file mode 100644 index 0000000..91cba2a --- /dev/null +++ b/auth/pwdless/emails.go @@ -0,0 +1,28 @@ +package pwdless + +import ( + "os" + "time" + + "github.com/dhax/go-base/email" +) + +// ContentLoginToken defines content for login token email template. +type ContentLoginToken struct { + Email string + Name string + URL string + Token string + Expiry time.Time +} + +// LoginTokenEmail creates and sends a login token email with provided template content. +func LoginTokenEmail(name, address string, content ContentLoginToken) email.Message { + return email.Message{ + From: email.NewEmail(os.Getenv("EMAIL_FROM_NAME"), os.Getenv("EMAIL_FROM_ADDRESS")), + To: email.NewEmail(name, address), + Subject: "Login Token", + Template: "loginToken", + Content: content, + } +} diff --git a/auth/pwdless/logintoken.go b/auth/pwdless/logintoken.go index a2a410a..4c14a78 100644 --- a/auth/pwdless/logintoken.go +++ b/auth/pwdless/logintoken.go @@ -9,9 +9,7 @@ import ( "github.com/spf13/viper" ) -var ( - errTokenNotFound = errors.New("login token not found") -) +var errTokenNotFound = errors.New("login token not found") // LoginToken is an in-memory saved token referencing an account ID and an expiry date. type LoginToken struct { diff --git a/docker-compose.yml b/docker-compose.yml index 6f52e1b..7dcdfcf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,7 +14,7 @@ services: #AUTH_JWT_EXPIRY: 1h #AUTH_JWT_REFRESH_EXPIRY: 72h #AUTH_JWT_SECRET: my secret - #EMAIL_FROM_ADDRESS: go-base + #EMAIL_FROM_ADDRESS: go-base@example.com #EMAIL_FROM_NAME: Go Base #EMAIL_SMTP_HOST: #EMAIL_SMTP_PORT: 465 diff --git a/email/auth.go b/email/auth.go deleted file mode 100644 index 797df1b..0000000 --- a/email/auth.go +++ /dev/null @@ -1,29 +0,0 @@ -package email - -import "time" - -// ContentLoginToken defines content for login token email template. -type ContentLoginToken struct { - Email string - Name string - URL string - Token string - Expiry time.Time -} - -// LoginToken creates and sends a login token email with provided template content. -func (m *Mailer) LoginToken(name, address string, content ContentLoginToken) error { - msg := &message{ - from: m.from, - to: NewEmail(name, address), - subject: "Login Token", - template: "loginToken", - content: content, - } - - if err := msg.parse(); err != nil { - return err - } - - return m.Send(msg) -} diff --git a/email/email.go b/email/email.go deleted file mode 100644 index 8e91636..0000000 --- a/email/email.go +++ /dev/null @@ -1,170 +0,0 @@ -// Package email provides email sending functionality. -package email - -import ( - "bytes" - "fmt" - "html/template" - "log" - "os" - "path/filepath" - "strconv" - "strings" - "time" - - "github.com/go-mail/mail" - "github.com/jaytaylor/html2text" - "github.com/spf13/viper" - "github.com/vanng822/go-premailer/premailer" -) - -var ( - debug bool - templates *template.Template -) - -// Mailer is a SMTP mailer. -type Mailer struct { - client *mail.Dialer - from Email -} - -// NewMailer returns a configured SMTP Mailer. -func NewMailer() (*Mailer, error) { - if err := parseTemplates(); err != nil { - return nil, err - } - - smtp := struct { - Host string - Port int - User string - Password string - }{ - viper.GetString("email_smtp_host"), - viper.GetInt("email_smtp_port"), - viper.GetString("email_smtp_user"), - viper.GetString("email_smtp_password"), - } - - s := &Mailer{ - client: mail.NewDialer(smtp.Host, smtp.Port, smtp.User, smtp.Password), - from: NewEmail(viper.GetString("email_from_name"), viper.GetString("email_from_address")), - } - - if smtp.Host == "" { - log.Println("SMTP host not set => printing emails to stdout") - debug = true - return s, nil - } - - d, err := s.client.Dial() - if err == nil { - d.Close() - return s, nil - } - return nil, err -} - -// Send sends the mail via smtp. -func (m *Mailer) Send(email *message) error { - if debug { - log.Println("To:", email.to.Address) - log.Println("Subject:", email.subject) - log.Println(email.text) - return nil - } - - msg := mail.NewMessage() - msg.SetAddressHeader("From", email.from.Address, email.from.Name) - msg.SetAddressHeader("To", email.to.Address, email.to.Name) - msg.SetHeader("Subject", email.subject) - msg.SetBody("text/plain", email.text) - msg.AddAlternative("text/html", email.html) - - return m.client.DialAndSend(msg) -} - -// message struct holds all parts of a specific email message. -type message struct { - from Email - to Email - subject string - template string - content interface{} - html string - text string -} - -// parse parses the corrsponding template and content -func (m *message) parse() error { - buf := new(bytes.Buffer) - if err := templates.ExecuteTemplate(buf, m.template, m.content); err != nil { - return err - } - prem, err := premailer.NewPremailerFromString(buf.String(), premailer.NewOptions()) - if err != nil { - return err - } - - html, err := prem.Transform() - if err != nil { - return err - } - m.html = html - - text, err := html2text.FromString(html, html2text.Options{PrettyTables: true}) - if err != nil { - return err - } - m.text = text - return nil -} - -// Email struct holds email address and recipient name. -type Email struct { - Name string - Address string -} - -// NewEmail returns an email address. -func NewEmail(name string, address string) Email { - return Email{ - Name: name, - Address: address, - } -} - -func parseTemplates() error { - templates = template.New("").Funcs(fMap) - return filepath.Walk("./templates", func(path string, info os.FileInfo, err error) error { - if strings.Contains(path, ".html") { - _, err = templates.ParseFiles(path) - return err - } - return err - }) -} - -var fMap = template.FuncMap{ - "formatAsDate": formatAsDate, - "formatAsDuration": formatAsDuration, -} - -func formatAsDate(t time.Time) string { - year, month, day := t.Date() - return fmt.Sprintf("%d.%d.%d", day, month, year) -} - -func formatAsDuration(t time.Time) string { - dur := time.Until(t) - hours := int(dur.Hours()) - mins := int(dur.Minutes()) - - v := "" - if hours != 0 { - v += strconv.Itoa(hours) + " hours and " - } - v += strconv.Itoa(mins) + " minutes" - return v -} diff --git a/email/mailer.go b/email/mailer.go new file mode 100644 index 0000000..3607473 --- /dev/null +++ b/email/mailer.go @@ -0,0 +1,106 @@ +// Package email provides email sending functionality. +package email + +import ( + "bytes" + "fmt" + "html/template" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/jaytaylor/html2text" + "github.com/vanng822/go-premailer/premailer" +) + +var templates *template.Template + +type Mailer interface { + Send(Message) error +} + +// Message struct holds all parts of a specific email Message. +type Message struct { + From Email + To Email + Subject string + Template string + Content any + html string + text string +} + +// parse parses the corrsponding template and content +func (m *Message) parse() error { + buf := new(bytes.Buffer) + if err := templates.ExecuteTemplate(buf, m.Template, m.Content); err != nil { + return err + } + prem, err := premailer.NewPremailerFromString(buf.String(), premailer.NewOptions()) + if err != nil { + return err + } + + html, err := prem.Transform() + if err != nil { + return err + } + m.html = html + + text, err := html2text.FromString(html, html2text.Options{PrettyTables: true}) + if err != nil { + return err + } + m.text = text + return nil +} + +// Email struct holds email address and recipient name. +type Email struct { + Name string + Address string +} + +// NewEmail returns an email address. +func NewEmail(name string, address string) Email { + return Email{ + Name: name, + Address: address, + } +} + +func parseTemplates() error { + templates = template.New("").Funcs(fMap) + return filepath.Walk("./templates", func(path string, info os.FileInfo, err error) error { + if strings.Contains(path, ".html") { + _, err = templates.ParseFiles(path) + return err + } + return err + }) +} + +var fMap = template.FuncMap{ + "formatAsDate": formatAsDate, + "formatAsDuration": formatAsDuration, +} + +func formatAsDate(t time.Time) string { + year, month, day := t.Date() + return fmt.Sprintf("%d.%d.%d", day, month, year) +} + +func formatAsDuration(t time.Time) string { + dur := time.Until(t) + hours := int(dur.Hours()) + mins := int(dur.Minutes()) + + v := "" + if hours != 0 { + v += strconv.Itoa(hours) + " hours and " + } + v += strconv.Itoa(mins) + " minutes" + return v +} diff --git a/email/mockMailer.go b/email/mockMailer.go index f70d778..cc4d481 100644 --- a/email/mockMailer.go +++ b/email/mockMailer.go @@ -1,13 +1,28 @@ package email +import "log" + // MockMailer is a mock Mailer type MockMailer struct { - LoginTokenFn func(name, email string, c ContentLoginToken) error - LoginTokenInvoked bool + SendFn func(m Message) error + SendInvoked bool } -// LoginToken is a mock for LoginToken -func (s *MockMailer) LoginToken(n, e string, c ContentLoginToken) error { - s.LoginTokenInvoked = true - return s.LoginTokenFn(n, e, c) +func logMessage(m Message) { + log.Printf("MockMailer email sent:\nSubject: %s\nTo: %s <%s>\nContext: %#v\n", m.Subject, m.To.Name, m.To.Address, m.Content) +} + +func NewMockMailer() *MockMailer { + log.Println("ATTENTION: SMTP Mailer not configured => printing emails to stdout") + return &MockMailer{ + SendFn: func(m Message) error { + logMessage(m) + return nil + }, + } +} + +func (s *MockMailer) Send(m Message) error { + s.SendInvoked = true + return s.SendFn(m) } diff --git a/email/smtp.go b/email/smtp.go new file mode 100644 index 0000000..17dd91b --- /dev/null +++ b/email/smtp.go @@ -0,0 +1,63 @@ +package email + +import ( + "github.com/go-mail/mail" + "github.com/spf13/viper" +) + +// SMTPMailer is a SMTP mailer. +type SMTPMailer struct { + client *mail.Dialer + from Email +} + +// NewMailer returns a configured SMTP Mailer. +func NewMailer() (Mailer, error) { + if err := parseTemplates(); err != nil { + return nil, err + } + + smtp := struct { + Host string + Port int + User string + Password string + }{ + viper.GetString("email_smtp_host"), + viper.GetInt("email_smtp_port"), + viper.GetString("email_smtp_user"), + viper.GetString("email_smtp_password"), + } + + if smtp.Host == "" { + return NewMockMailer(), nil + } + + s := &SMTPMailer{ + client: mail.NewDialer(smtp.Host, smtp.Port, smtp.User, smtp.Password), + from: NewEmail(viper.GetString("email_from_name"), viper.GetString("email_from_address")), + } + + d, err := s.client.Dial() + if err == nil { + d.Close() + return s, nil + } + return nil, err +} + +// Send sends the mail via smtp. +func (m *SMTPMailer) Send(email Message) error { + if err := email.parse(); err != nil { + return err + } + + msg := mail.NewMessage() + msg.SetAddressHeader("From", email.From.Address, email.From.Name) + msg.SetAddressHeader("To", email.To.Address, email.To.Name) + msg.SetHeader("Subject", email.Subject) + msg.SetBody("text/plain", email.text) + msg.AddAlternative("text/html", email.html) + + return m.client.DialAndSend(msg) +}