(Feat): Initial Commit
Some checks failed
Build & Test / build-test (push) Has been cancelled
Build & Test / swagger-codegen-cli (push) Has been cancelled
CodeQL / Analyze (go) (push) Has been cancelled

This commit is contained in:
2026-07-03 19:41:31 +05:30
commit 7e940c83a7
461 changed files with 45002 additions and 0 deletions

162
internal/mailer/mailer.go Normal file
View 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
}

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

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

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

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

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

View File

@@ -0,0 +1,7 @@
package transport
import "github.com/jordan-wright/email"
type MailTransporter interface {
Send(mail *email.Email) error
}