(Feat): Initial Commit
This commit is contained in:
162
internal/mailer/mailer.go
Normal file
162
internal/mailer/mailer.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package mailer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/config"
|
||||
"allaboutapps.dev/aw/go-starter/internal/data/dto"
|
||||
"allaboutapps.dev/aw/go-starter/internal/mailer/transport"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/jordan-wright/email"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrEmailTemplateNotFound = errors.New("email template not found")
|
||||
emailTemplatePasswordReset = "password_reset" // /app/templates/email/password_reset/**.
|
||||
emailTemplateAccountConfirmation = "account_confirmation" // /app/templates/email/account_confirmation/**
|
||||
)
|
||||
|
||||
type Mailer struct {
|
||||
Config config.Mailer
|
||||
Transport transport.MailTransporter
|
||||
Templates map[string]*template.Template
|
||||
}
|
||||
|
||||
func New(config config.Mailer, transport transport.MailTransporter) *Mailer {
|
||||
return &Mailer{
|
||||
Config: config,
|
||||
Transport: transport,
|
||||
Templates: map[string]*template.Template{},
|
||||
}
|
||||
}
|
||||
|
||||
func NewWithConfig(cfg config.Mailer, smtpConfig transport.SMTPMailTransportConfig) (*Mailer, error) {
|
||||
var mailer *Mailer
|
||||
|
||||
switch config.MailerTransporter(cfg.Transporter) {
|
||||
case config.MailerTransporterMock:
|
||||
log.Warn().Msg("Initializing mock mailer")
|
||||
mailer = New(cfg, transport.NewMock())
|
||||
case config.MailerTransporterSMTP:
|
||||
mailer = New(cfg, transport.NewSMTP(smtpConfig))
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported mail transporter: %s", cfg.Transporter)
|
||||
}
|
||||
|
||||
if err := mailer.ParseTemplates(); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse mailer templates: %w", err)
|
||||
}
|
||||
|
||||
return mailer, nil
|
||||
}
|
||||
|
||||
func (m *Mailer) ParseTemplates() error {
|
||||
files, err := os.ReadDir(m.Config.WebTemplatesEmailBaseDirAbs)
|
||||
if err != nil {
|
||||
log.Error().Str("dir", m.Config.WebTemplatesEmailBaseDirAbs).Err(err).Msg("Failed to read email templates directory while parsing templates")
|
||||
return fmt.Errorf("failed to read email templates directory while parsing templates: %w", err)
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
if !file.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
tmpl, err := template.ParseGlob(filepath.Join(m.Config.WebTemplatesEmailBaseDirAbs, file.Name(), "**"))
|
||||
if err != nil {
|
||||
log.Error().Str("template", file.Name()).Err(err).Msg("Failed to parse email template files as glob")
|
||||
return fmt.Errorf("failed to parse email template files as glob: %w", err)
|
||||
}
|
||||
|
||||
m.Templates[file.Name()] = tmpl
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Mailer) SendPasswordReset(ctx context.Context, to string, passwordResetLink string) error {
|
||||
log := util.LogFromContext(ctx).With().Str("component", "mailer").Str("email_template", emailTemplatePasswordReset).Logger()
|
||||
|
||||
tmpl, ok := m.Templates[emailTemplatePasswordReset]
|
||||
if !ok {
|
||||
log.Error().Msg("Password reset email template not found")
|
||||
return ErrEmailTemplateNotFound
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"passwordResetLink": passwordResetLink,
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, data); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to execute password reset email template")
|
||||
return fmt.Errorf("failed to execute password reset email template: %w", err)
|
||||
}
|
||||
|
||||
mail := email.NewEmail()
|
||||
|
||||
mail.From = m.Config.DefaultSender
|
||||
mail.To = []string{to}
|
||||
mail.Subject = "Password reset"
|
||||
mail.HTML = buf.Bytes()
|
||||
|
||||
if !m.Config.Send {
|
||||
log.Warn().Str("to", to).Str("passwordResetLink", passwordResetLink).Msg("Sending has been disabled in mailer config, skipping password reset email")
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := m.Transport.Send(mail); err != nil {
|
||||
log.Debug().Err(err).Msg("Failed to send password reset email")
|
||||
return fmt.Errorf("failed to send password reset email: %w", err)
|
||||
}
|
||||
|
||||
log.Debug().Msg("Successfully sent password reset email")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Mailer) SendAccountConfirmation(ctx context.Context, to string, payload dto.ConfirmatioNotificationPayload) error {
|
||||
log := util.LogFromContext(ctx)
|
||||
|
||||
tmpl, ok := m.Templates[emailTemplateAccountConfirmation]
|
||||
if !ok {
|
||||
log.Error().Msg("Account confirmation email template not found")
|
||||
return ErrEmailTemplateNotFound
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"confirmationLink": payload.ConfirmationLink,
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, data); err != nil {
|
||||
log.Error().Err(err).Msg("Failed to execute account confirmation email template")
|
||||
return fmt.Errorf("failed to execute account confirmation email template: %w", err)
|
||||
}
|
||||
|
||||
mail := email.NewEmail()
|
||||
|
||||
mail.From = m.Config.DefaultSender
|
||||
mail.To = []string{to}
|
||||
mail.Subject = "Account confirmation"
|
||||
mail.HTML = buf.Bytes()
|
||||
|
||||
if !m.Config.Send {
|
||||
log.Warn().Str("to", to).Msg("Sending has been disabled in mailer config, skipping account confirmation email")
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := m.Transport.Send(mail); err != nil {
|
||||
log.Debug().Err(err).Msg("Failed to send account confirmation email")
|
||||
return fmt.Errorf("failed to send account confirmation email: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
38
internal/mailer/mailer_test.go
Normal file
38
internal/mailer/mailer_test.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package mailer_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test/fixtures"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMailerSendPasswordReset(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
mailer := test.NewTestMailer(t)
|
||||
mailTransport := test.GetTestMailerMockTransport(t, mailer)
|
||||
mailTransport.Expect(1)
|
||||
|
||||
//nolint:gosec
|
||||
passwordResetLink := "http://localhost/password/reset/12345"
|
||||
err := mailer.SendPasswordReset(ctx, fix.User1.Username.String, passwordResetLink)
|
||||
require.NoError(t, err)
|
||||
|
||||
mailTransport.WaitWithTimeout(time.Second)
|
||||
|
||||
mail := mailTransport.GetLastSentMail()
|
||||
mails := mailTransport.GetSentMails()
|
||||
require.NotNil(t, mail)
|
||||
require.Len(t, mails, 1)
|
||||
assert.Equal(t, mailer.Config.DefaultSender, mail.From)
|
||||
assert.Len(t, mail.To, 1)
|
||||
assert.Equal(t, fix.User1.Username.String, mail.To[0])
|
||||
assert.Equal(t, test.TestMailerDefaultSender, mail.From)
|
||||
assert.Equal(t, "Password reset", mail.Subject)
|
||||
assert.Contains(t, string(mail.HTML), passwordResetLink)
|
||||
}
|
||||
95
internal/mailer/transport/mock.go
Normal file
95
internal/mailer/transport/mock.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/jordan-wright/email"
|
||||
)
|
||||
|
||||
const defaultWaitTimeout = time.Second * 10
|
||||
|
||||
type MockMailTransport struct {
|
||||
sync.RWMutex
|
||||
mails []*email.Email
|
||||
OnMailSent func(mail email.Email) // non pointer to prevent concurrent read errors
|
||||
wg sync.WaitGroup
|
||||
expected int
|
||||
}
|
||||
|
||||
func NewMock() *MockMailTransport {
|
||||
return &MockMailTransport{
|
||||
RWMutex: sync.RWMutex{},
|
||||
mails: make([]*email.Email, 0),
|
||||
OnMailSent: func(_ email.Email) {},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MockMailTransport) Send(mail *email.Email) error {
|
||||
m.Lock()
|
||||
defer m.Unlock()
|
||||
|
||||
// Calling wg.Done might panic leaving a user clueless what was the reason of test failure.
|
||||
// We will add more information before exiting.
|
||||
defer func() {
|
||||
rcp := recover()
|
||||
if rcp == nil {
|
||||
return
|
||||
}
|
||||
|
||||
err, ok := rcp.(error)
|
||||
if !ok {
|
||||
err = fmt.Errorf("%v", rcp)
|
||||
}
|
||||
|
||||
log.Fatalf("Unexpected email sent! MockMailTransport panicked: %s", err)
|
||||
}()
|
||||
|
||||
m.mails = append(m.mails, mail)
|
||||
m.OnMailSent(*mail)
|
||||
|
||||
if m.expected > 0 {
|
||||
m.wg.Done()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockMailTransport) GetLastSentMail() *email.Email {
|
||||
m.RLock()
|
||||
defer m.RUnlock()
|
||||
|
||||
if len(m.mails) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return m.mails[len(m.mails)-1]
|
||||
}
|
||||
|
||||
func (m *MockMailTransport) GetSentMails() []*email.Email {
|
||||
m.RLock()
|
||||
defer m.RUnlock()
|
||||
|
||||
return m.mails
|
||||
}
|
||||
|
||||
// Expect adds the mailCnt to a waitgroup. Done() is called by Send
|
||||
func (m *MockMailTransport) Expect(mailCnt int) {
|
||||
m.expected = mailCnt
|
||||
m.wg.Add(mailCnt)
|
||||
}
|
||||
|
||||
// Wait until all expected mails have arrived
|
||||
func (m *MockMailTransport) Wait() {
|
||||
m.WaitWithTimeout(defaultWaitTimeout)
|
||||
}
|
||||
|
||||
func (m *MockMailTransport) WaitWithTimeout(timeout time.Duration) {
|
||||
if err := util.WaitTimeout(&m.wg, timeout); errors.Is(err, util.ErrWaitTimeout) {
|
||||
log.Fatalf("Timeout waiting for %d mails to be sent", m.expected)
|
||||
}
|
||||
}
|
||||
56
internal/mailer/transport/smtp.go
Normal file
56
internal/mailer/transport/smtp.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"strconv"
|
||||
|
||||
"github.com/jordan-wright/email"
|
||||
)
|
||||
|
||||
type SMTPMailTransport struct {
|
||||
config SMTPMailTransportConfig
|
||||
addr string
|
||||
auth smtp.Auth
|
||||
}
|
||||
|
||||
func NewSMTP(config SMTPMailTransportConfig) *SMTPMailTransport {
|
||||
mailTransport := &SMTPMailTransport{
|
||||
config: config,
|
||||
addr: net.JoinHostPort(config.Host, strconv.Itoa(config.Port)),
|
||||
auth: nil,
|
||||
}
|
||||
|
||||
switch config.AuthType {
|
||||
case SMTPAuthTypePlain:
|
||||
mailTransport.auth = smtp.PlainAuth("", config.Username, config.Password, config.Host)
|
||||
case SMTPAuthTypeCRAMMD5:
|
||||
mailTransport.auth = smtp.CRAMMD5Auth(config.Username, config.Password)
|
||||
case SMTPAuthTypeLogin:
|
||||
mailTransport.auth = LoginAuth(config.Username, config.Password, config.Host)
|
||||
}
|
||||
|
||||
return mailTransport
|
||||
}
|
||||
|
||||
func (m *SMTPMailTransport) Send(mail *email.Email) error {
|
||||
var err error
|
||||
|
||||
switch m.config.Encryption {
|
||||
case SMTPEncryptionNone:
|
||||
err = mail.Send(m.addr, m.auth)
|
||||
case SMTPEncryptionTLS:
|
||||
err = mail.SendWithTLS(m.addr, m.auth, m.config.TLSConfig)
|
||||
case SMTPEncryptionStartTLS:
|
||||
err = mail.SendWithStartTLS(m.addr, m.auth, m.config.TLSConfig)
|
||||
default:
|
||||
return fmt.Errorf("invalid SMTP encryption %q", m.config.Encryption)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send email: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
81
internal/mailer/transport/smtp_config.go
Normal file
81
internal/mailer/transport/smtp_config.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type SMTPAuthType int
|
||||
|
||||
const (
|
||||
// SMTPAuthTypeNone indicates no SMTP authentication should be performed.
|
||||
SMTPAuthTypeNone SMTPAuthType = iota
|
||||
// SMTPAuthTypePlain indicates SMTP authentication should be performed using the "AUTH PLAIN" protocol.
|
||||
SMTPAuthTypePlain
|
||||
// SMTPAuthTypeCRAMMD5 indicates SMTP authentication should be performed using the "CRAM-MD5" protocol.
|
||||
SMTPAuthTypeCRAMMD5
|
||||
// SMTPAuthTypeLogin indicates SMTP authentication should be performed using the "LOGIN" protocol.
|
||||
SMTPAuthTypeLogin
|
||||
)
|
||||
|
||||
func (t SMTPAuthType) String() string {
|
||||
switch t {
|
||||
case SMTPAuthTypeNone:
|
||||
return "none"
|
||||
case SMTPAuthTypePlain:
|
||||
return "plain"
|
||||
case SMTPAuthTypeCRAMMD5:
|
||||
return "cram-md5"
|
||||
case SMTPAuthTypeLogin:
|
||||
return "login"
|
||||
default:
|
||||
return fmt.Sprintf("unknown (%d)", t)
|
||||
}
|
||||
}
|
||||
|
||||
func SMTPAuthTypeFromString(s string) SMTPAuthType {
|
||||
switch strings.ToLower(s) {
|
||||
case "plain":
|
||||
return SMTPAuthTypePlain
|
||||
case "cram-md5":
|
||||
return SMTPAuthTypeCRAMMD5
|
||||
case "login":
|
||||
return SMTPAuthTypeLogin
|
||||
default:
|
||||
return SMTPAuthTypeNone
|
||||
}
|
||||
}
|
||||
|
||||
type SMTPEncryption string
|
||||
|
||||
const (
|
||||
SMTPEncryptionNone SMTPEncryption = "none"
|
||||
SMTPEncryptionTLS SMTPEncryption = "tls"
|
||||
SMTPEncryptionStartTLS SMTPEncryption = "starttls"
|
||||
)
|
||||
|
||||
func (e SMTPEncryption) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func SMTPEncryptionFromString(s string) SMTPEncryption {
|
||||
switch strings.ToLower(s) {
|
||||
case "tls":
|
||||
return SMTPEncryptionTLS
|
||||
case "starttls":
|
||||
return SMTPEncryptionStartTLS
|
||||
default:
|
||||
return SMTPEncryptionNone
|
||||
}
|
||||
}
|
||||
|
||||
type SMTPMailTransportConfig struct {
|
||||
Host string
|
||||
Port int
|
||||
AuthType SMTPAuthType `json:"-"` // iota
|
||||
Username string
|
||||
Password string `json:"-"` // sensitive
|
||||
Encryption SMTPEncryption `json:"-"` // iota
|
||||
TLSConfig *tls.Config `json:"-"` // pointer
|
||||
}
|
||||
50
internal/mailer/transport/smtp_login_auth.go
Normal file
50
internal/mailer/transport/smtp_login_auth.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/smtp"
|
||||
)
|
||||
|
||||
type loginAuth struct {
|
||||
username []byte
|
||||
password []byte
|
||||
host string
|
||||
}
|
||||
|
||||
func LoginAuth(username string, password string, host ...string) smtp.Auth {
|
||||
auth := &loginAuth{
|
||||
username: []byte(username),
|
||||
password: []byte(password),
|
||||
host: "",
|
||||
}
|
||||
|
||||
if len(host) > 0 {
|
||||
auth.host = host[0]
|
||||
}
|
||||
|
||||
return auth
|
||||
}
|
||||
|
||||
func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {
|
||||
if len(a.host) > 0 && server.Name != a.host {
|
||||
return "", nil, errors.New("wrong host name")
|
||||
}
|
||||
|
||||
return "LOGIN", a.username, nil
|
||||
}
|
||||
|
||||
func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
|
||||
if more {
|
||||
switch string(fromServer) {
|
||||
case "Username:":
|
||||
return a.username, nil
|
||||
case "Password:":
|
||||
return a.password, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown challenge received from server: %q", fromServer)
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
7
internal/mailer/transport/transport.go
Normal file
7
internal/mailer/transport/transport.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package transport
|
||||
|
||||
import "github.com/jordan-wright/email"
|
||||
|
||||
type MailTransporter interface {
|
||||
Send(mail *email.Email) error
|
||||
}
|
||||
Reference in New Issue
Block a user