make Mailer an interface

This commit is contained in:
dhax 2025-03-05 15:42:02 +01:00
parent dd2412463b
commit 08c09ffee7
10 changed files with 234 additions and 227 deletions

View file

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

View file

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

28
auth/pwdless/emails.go Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

106
email/mailer.go Normal file
View file

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

View file

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

63
email/smtp.go Normal file
View file

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