mirror of
https://github.com/dnote/dnote
synced 2026-03-14 14:35:50 +01:00
Simplify email backend and remove --appEnv (#710)
* Improve logging * Remove AppEnv * Simplify email backend
This commit is contained in:
parent
e0c4cb1545
commit
e72322f847
20 changed files with 196 additions and 161 deletions
|
|
@ -50,8 +50,6 @@ func TestServerStart(t *testing.T) {
|
|||
cmd := exec.Command(testServerBinary, "start", "--port", port)
|
||||
cmd.Env = append(os.Environ(),
|
||||
"DBPath="+tmpDB,
|
||||
"WebURL=http://localhost:"+port,
|
||||
"APP_ENV=PRODUCTION",
|
||||
)
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
|
|
@ -140,7 +138,6 @@ func TestServerStartHelp(t *testing.T) {
|
|||
|
||||
outputStr := string(output)
|
||||
assert.Equal(t, strings.Contains(outputStr, "dnote-server start [flags]"), true, "output should contain usage")
|
||||
assert.Equal(t, strings.Contains(outputStr, "--appEnv"), true, "output should contain appEnv flag")
|
||||
assert.Equal(t, strings.Contains(outputStr, "--port"), true, "output should contain port flag")
|
||||
assert.Equal(t, strings.Contains(outputStr, "--webUrl"), true, "output should contain webUrl flag")
|
||||
assert.Equal(t, strings.Contains(outputStr, "--dbPath"), true, "output should contain dbPath flag")
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@ import (
|
|||
"github.com/dnote/dnote/pkg/server/app"
|
||||
"github.com/dnote/dnote/pkg/server/controllers"
|
||||
"github.com/dnote/dnote/pkg/server/database"
|
||||
"github.com/dnote/dnote/pkg/server/mailer"
|
||||
apitest "github.com/dnote/dnote/pkg/server/testutils"
|
||||
"github.com/pkg/errors"
|
||||
"gorm.io/gorm"
|
||||
|
|
@ -98,7 +97,6 @@ func setupTestServer(t *testing.T, serverTime time.Time) (*httptest.Server, *gor
|
|||
|
||||
a := app.NewTest()
|
||||
a.Clock = mockClock
|
||||
a.EmailTemplates = mailer.Templates{}
|
||||
a.EmailBackend = &apitest.MockEmailbackendImplementation{}
|
||||
a.DB = db
|
||||
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
APP_ENV=DEVELOPMENT
|
||||
DBPath=../../dev-server.db
|
||||
|
|
@ -29,8 +29,6 @@ var (
|
|||
ErrEmptyClock = errors.New("No clock was provided")
|
||||
// ErrEmptyWebURL is an error for missing WebURL content in the app configuration
|
||||
ErrEmptyWebURL = errors.New("No WebURL was provided")
|
||||
// ErrEmptyEmailTemplates is an error for missing EmailTemplates content in the app configuration
|
||||
ErrEmptyEmailTemplates = errors.New("No EmailTemplate store was provided")
|
||||
// ErrEmptyEmailBackend is an error for missing EmailBackend content in the app configuration
|
||||
ErrEmptyEmailBackend = errors.New("No EmailBackend was provided")
|
||||
// ErrEmptyHTTP500Page is an error for missing HTTP 500 page content
|
||||
|
|
@ -41,11 +39,9 @@ var (
|
|||
type App struct {
|
||||
DB *gorm.DB
|
||||
Clock clock.Clock
|
||||
EmailTemplates mailer.Templates
|
||||
EmailBackend mailer.Backend
|
||||
Files map[string][]byte
|
||||
HTTP500Page []byte
|
||||
AppEnv string
|
||||
WebURL string
|
||||
DisableRegistration bool
|
||||
Port string
|
||||
|
|
@ -61,9 +57,6 @@ func (a *App) Validate() error {
|
|||
if a.Clock == nil {
|
||||
return ErrEmptyClock
|
||||
}
|
||||
if a.EmailTemplates == nil {
|
||||
return ErrEmptyEmailTemplates
|
||||
}
|
||||
if a.EmailBackend == nil {
|
||||
return ErrEmptyEmailBackend
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,21 +64,18 @@ func getNoreplySender(webURL string) (string, error) {
|
|||
|
||||
// SendWelcomeEmail sends welcome email
|
||||
func (a *App) SendWelcomeEmail(email string) error {
|
||||
body, err := a.EmailTemplates.Execute(mailer.EmailTypeWelcome, mailer.EmailKindText, mailer.WelcomeTmplData{
|
||||
AccountEmail: email,
|
||||
WebURL: a.WebURL,
|
||||
})
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "executing reset verification template for %s", email)
|
||||
}
|
||||
|
||||
from, err := GetSenderEmail(a.WebURL, defaultSender)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "getting the sender email")
|
||||
}
|
||||
|
||||
if err := a.EmailBackend.Queue("Welcome to Dnote!", from, []string{email}, mailer.EmailKindText, body); err != nil {
|
||||
return errors.Wrapf(err, "queueing email for %s", email)
|
||||
data := mailer.WelcomeTmplData{
|
||||
AccountEmail: email,
|
||||
WebURL: a.WebURL,
|
||||
}
|
||||
|
||||
if err := a.EmailBackend.SendEmail(mailer.EmailTypeWelcome, from, []string{email}, data); err != nil {
|
||||
return errors.Wrapf(err, "sending welcome email for %s", email)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
@ -90,26 +87,23 @@ func (a *App) SendPasswordResetEmail(email, tokenValue string) error {
|
|||
return ErrEmailRequired
|
||||
}
|
||||
|
||||
body, err := a.EmailTemplates.Execute(mailer.EmailTypeResetPassword, mailer.EmailKindText, mailer.EmailResetPasswordTmplData{
|
||||
AccountEmail: email,
|
||||
Token: tokenValue,
|
||||
WebURL: a.WebURL,
|
||||
})
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "executing reset password template for %s", email)
|
||||
}
|
||||
|
||||
from, err := GetSenderEmail(a.WebURL, defaultSender)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "getting the sender email")
|
||||
}
|
||||
|
||||
if err := a.EmailBackend.Queue("Reset your password", from, []string{email}, mailer.EmailKindText, body); err != nil {
|
||||
data := mailer.EmailResetPasswordTmplData{
|
||||
AccountEmail: email,
|
||||
Token: tokenValue,
|
||||
WebURL: a.WebURL,
|
||||
}
|
||||
|
||||
if err := a.EmailBackend.SendEmail(mailer.EmailTypeResetPassword, from, []string{email}, data); err != nil {
|
||||
if errors.Cause(err) == mailer.ErrSMTPNotConfigured {
|
||||
return ErrInvalidSMTPConfig
|
||||
}
|
||||
|
||||
return errors.Wrapf(err, "queueing email for %s", email)
|
||||
return errors.Wrapf(err, "sending password reset email for %s", email)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
@ -117,21 +111,18 @@ func (a *App) SendPasswordResetEmail(email, tokenValue string) error {
|
|||
|
||||
// SendPasswordResetAlertEmail sends email that notifies users of a password change
|
||||
func (a *App) SendPasswordResetAlertEmail(email string) error {
|
||||
body, err := a.EmailTemplates.Execute(mailer.EmailTypeResetPasswordAlert, mailer.EmailKindText, mailer.EmailResetPasswordAlertTmplData{
|
||||
AccountEmail: email,
|
||||
WebURL: a.WebURL,
|
||||
})
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "executing reset password alert template for %s", email)
|
||||
}
|
||||
|
||||
from, err := GetSenderEmail(a.WebURL, defaultSender)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "getting the sender email")
|
||||
}
|
||||
|
||||
if err := a.EmailBackend.Queue("Dnote password changed", from, []string{email}, mailer.EmailKindText, body); err != nil {
|
||||
return errors.Wrapf(err, "queueing email for %s", email)
|
||||
data := mailer.EmailResetPasswordAlertTmplData{
|
||||
AccountEmail: email,
|
||||
WebURL: a.WebURL,
|
||||
}
|
||||
|
||||
if err := a.EmailBackend.SendEmail(mailer.EmailTypeResetPasswordAlert, from, []string{email}, data); err != nil {
|
||||
return errors.Wrapf(err, "sending password reset alert email for %s", email)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ var (
|
|||
// ErrInvalidPasswordChangeInput is an error for changing password
|
||||
ErrInvalidPasswordChangeInput appError = "Both current and new passwords are required to change the password."
|
||||
|
||||
ErrInvalidPassword appError = "Invalid currnet password."
|
||||
ErrInvalidPassword appError = "Invalid current password."
|
||||
// ErrEmailTooLong is an error for email length exceeding the limit
|
||||
ErrEmailTooLong appError = "Email is too long."
|
||||
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ func (a *App) CreateSession(userID int) (database.Session, error) {
|
|||
// DeleteUserSessions deletes all existing sessions for the given user. It effectively
|
||||
// invalidates all existing sessions.
|
||||
func (a *App) DeleteUserSessions(db *gorm.DB, userID int) error {
|
||||
if err := db.Debug().Where("user_id = ?", userID).Delete(&database.Session{}).Error; err != nil {
|
||||
if err := db.Where("user_id = ?", userID).Delete(&database.Session{}).Error; err != nil {
|
||||
return errors.Wrap(err, "deleting sessions")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ package app
|
|||
import (
|
||||
"github.com/dnote/dnote/pkg/clock"
|
||||
"github.com/dnote/dnote/pkg/server/assets"
|
||||
"github.com/dnote/dnote/pkg/server/mailer"
|
||||
"github.com/dnote/dnote/pkg/server/testutils"
|
||||
)
|
||||
|
||||
|
|
@ -26,10 +25,8 @@ import (
|
|||
func NewTest() App {
|
||||
return App{
|
||||
Clock: clock.NewMock(),
|
||||
EmailTemplates: mailer.NewTemplates(),
|
||||
EmailBackend: &testutils.MockEmailbackendImplementation{},
|
||||
HTTP500Page: assets.MustGetHTTP500ErrorPage(),
|
||||
AppEnv: "TEST",
|
||||
WebURL: "http://127.0.0.0.1",
|
||||
Port: "3000",
|
||||
DisableRegistration: false,
|
||||
|
|
|
|||
|
|
@ -37,23 +37,26 @@ func initDB(dbPath string) *gorm.DB {
|
|||
return db
|
||||
}
|
||||
|
||||
func getEmailBackend() mailer.Backend {
|
||||
defaultBackend, err := mailer.NewDefaultBackend()
|
||||
if err != nil {
|
||||
log.Debug("SMTP not configured, using StdoutBackend for emails")
|
||||
return mailer.NewStdoutBackend()
|
||||
}
|
||||
|
||||
log.Debug("Email backend configured")
|
||||
return defaultBackend
|
||||
}
|
||||
|
||||
func initApp(cfg config.Config) app.App {
|
||||
db := initDB(cfg.DBPath)
|
||||
|
||||
emailBackend, err := mailer.NewDefaultBackend(cfg.IsProd())
|
||||
if err != nil {
|
||||
emailBackend = &mailer.DefaultBackend{Enabled: false}
|
||||
} else {
|
||||
log.Info("Email backend configured")
|
||||
}
|
||||
emailBackend := getEmailBackend()
|
||||
|
||||
return app.App{
|
||||
DB: db,
|
||||
Clock: clock.New(),
|
||||
EmailTemplates: mailer.NewTemplates(),
|
||||
EmailBackend: emailBackend,
|
||||
HTTP500Page: cfg.HTTP500Page,
|
||||
AppEnv: cfg.AppEnv,
|
||||
WebURL: cfg.WebURL,
|
||||
DisableRegistration: cfg.DisableRegistration,
|
||||
Port: cfg.Port,
|
||||
|
|
|
|||
|
|
@ -32,7 +32,6 @@ import (
|
|||
func startCmd(args []string) {
|
||||
fs := setupFlagSet("start", "dnote-server start")
|
||||
|
||||
appEnv := fs.String("appEnv", "", "Application environment (env: APP_ENV, default: PRODUCTION)")
|
||||
port := fs.String("port", "", "Server port (env: PORT, default: 3001)")
|
||||
webURL := fs.String("webUrl", "", "Full URL to server without trailing slash (env: WebURL, default: http://localhost:3001)")
|
||||
dbPath := fs.String("dbPath", "", "Path to SQLite database file (env: DBPath, default: $XDG_DATA_HOME/dnote/server.db)")
|
||||
|
|
@ -42,7 +41,6 @@ func startCmd(args []string) {
|
|||
fs.Parse(args)
|
||||
|
||||
cfg, err := config.New(config.Params{
|
||||
AppEnv: *appEnv,
|
||||
Port: *port,
|
||||
WebURL: *webURL,
|
||||
DBPath: *dbPath,
|
||||
|
|
|
|||
|
|
@ -26,8 +26,6 @@ import (
|
|||
)
|
||||
|
||||
const (
|
||||
// AppEnvProduction represents an app environment for production.
|
||||
AppEnvProduction string = "PRODUCTION"
|
||||
// DefaultDBDir is the default directory name for Dnote data
|
||||
DefaultDBDir = "dnote"
|
||||
// DefaultDBFilename is the default database filename
|
||||
|
|
@ -65,7 +63,6 @@ func getOrEnv(value, envKey, defaultVal string) string {
|
|||
|
||||
// Config is an application configuration
|
||||
type Config struct {
|
||||
AppEnv string
|
||||
WebURL string
|
||||
DisableRegistration bool
|
||||
Port string
|
||||
|
|
@ -77,7 +74,6 @@ type Config struct {
|
|||
|
||||
// Params are the configuration parameters for creating a new Config
|
||||
type Params struct {
|
||||
AppEnv string
|
||||
Port string
|
||||
WebURL string
|
||||
DBPath string
|
||||
|
|
@ -89,7 +85,6 @@ type Params struct {
|
|||
// Empty string params will fall back to environment variables and defaults.
|
||||
func New(p Params) (Config, error) {
|
||||
c := Config{
|
||||
AppEnv: getOrEnv(p.AppEnv, "APP_ENV", AppEnvProduction),
|
||||
Port: getOrEnv(p.Port, "PORT", "3001"),
|
||||
WebURL: getOrEnv(p.WebURL, "WebURL", "http://localhost:3001"),
|
||||
DBPath: getOrEnv(p.DBPath, "DBPath", DefaultDBPath),
|
||||
|
|
@ -106,11 +101,6 @@ func New(p Params) (Config, error) {
|
|||
return c, nil
|
||||
}
|
||||
|
||||
// IsProd checks if the app environment is configured to be production.
|
||||
func (c Config) IsProd() bool {
|
||||
return c.AppEnv == AppEnvProduction
|
||||
}
|
||||
|
||||
func validate(c Config) error {
|
||||
if _, err := url.ParseRequestURI(c.WebURL); err != nil {
|
||||
return errors.Wrapf(ErrWebURLInvalid, "'%s'", c.WebURL)
|
||||
|
|
|
|||
|
|
@ -20,9 +20,11 @@ import (
|
|||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/dnote/dnote/pkg/server/log"
|
||||
"github.com/pkg/errors"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
var (
|
||||
|
|
@ -30,6 +32,22 @@ var (
|
|||
MigrationTableName = "migrations"
|
||||
)
|
||||
|
||||
// getDBLogLevel converts application log level to GORM log level
|
||||
func getDBLogLevel(level string) logger.LogLevel {
|
||||
switch level {
|
||||
case log.LevelDebug:
|
||||
return logger.Info
|
||||
case log.LevelInfo:
|
||||
return logger.Info
|
||||
case log.LevelWarn:
|
||||
return logger.Warn
|
||||
case log.LevelError:
|
||||
return logger.Error
|
||||
default:
|
||||
return logger.Error
|
||||
}
|
||||
}
|
||||
|
||||
// InitSchema migrates database schema to reflect the latest model definition
|
||||
func InitSchema(db *gorm.DB) {
|
||||
if err := db.AutoMigrate(
|
||||
|
|
@ -51,7 +69,9 @@ func Open(dbPath string) *gorm.DB {
|
|||
panic(errors.Wrapf(err, "creating database directory at %s", dir))
|
||||
}
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
|
||||
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(getDBLogLevel(log.GetLevel())),
|
||||
})
|
||||
if err != nil {
|
||||
panic(errors.Wrap(err, "opening database conection"))
|
||||
}
|
||||
|
|
@ -96,16 +116,14 @@ func StartWALCheckpointing(db *gorm.DB, interval time.Duration) {
|
|||
for range ticker.C {
|
||||
// TRUNCATE mode removes the WAL file after checkpointing
|
||||
if err := db.Exec("PRAGMA wal_checkpoint(TRUNCATE)").Error; err != nil {
|
||||
// Log error but don't panic - this is a background maintenance task
|
||||
// TODO: Use proper logging once available
|
||||
_ = err
|
||||
log.ErrorWrap(err, "WAL checkpoint failed")
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// StartPeriodicVacuum runs full VACUUM on a schedule to reclaim space and defragment.
|
||||
// WARNING: VACUUM acquires an exclusive lock and blocks all database operations briefly.
|
||||
// VACUUM acquires an exclusive lock and blocks all database operations briefly.
|
||||
func StartPeriodicVacuum(db *gorm.DB, interval time.Duration) {
|
||||
go func() {
|
||||
ticker := time.NewTicker(interval)
|
||||
|
|
@ -113,9 +131,7 @@ func StartPeriodicVacuum(db *gorm.DB, interval time.Duration) {
|
|||
|
||||
for range ticker.C {
|
||||
if err := db.Exec("VACUUM").Error; err != nil {
|
||||
// Log error but don't panic - this is a background maintenance task
|
||||
// TODO: Use proper logging once available
|
||||
_ = err
|
||||
log.ErrorWrap(err, "VACUUM failed")
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
|
|
|||
|
|
@ -70,6 +70,11 @@ func SetLevel(level string) {
|
|||
currentLevel = level
|
||||
}
|
||||
|
||||
// GetLevel returns the current global log level
|
||||
func GetLevel() string {
|
||||
return currentLevel
|
||||
}
|
||||
|
||||
// levelPriority returns a numeric priority for comparison
|
||||
func levelPriority(level string) int {
|
||||
switch level {
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ var ErrSMTPNotConfigured = errors.New("SMTP is not configured")
|
|||
|
||||
// Backend is an interface for sending emails.
|
||||
type Backend interface {
|
||||
Queue(subject, from string, to []string, contentType, body string) error
|
||||
SendEmail(templateType, from string, to []string, data interface{}) error
|
||||
}
|
||||
|
||||
// EmailDialer is an interface for sending email messages
|
||||
|
|
@ -44,9 +44,10 @@ type gomailDialer struct {
|
|||
|
||||
// DefaultBackend is an implementation of the Backend
|
||||
// that sends an email without queueing.
|
||||
// This backend is always enabled and will send emails via SMTP.
|
||||
type DefaultBackend struct {
|
||||
Dialer EmailDialer
|
||||
Enabled bool
|
||||
Dialer EmailDialer
|
||||
Templates Templates
|
||||
}
|
||||
|
||||
type dialerParams struct {
|
||||
|
|
@ -82,7 +83,7 @@ func getSMTPParams() (*dialerParams, error) {
|
|||
}
|
||||
|
||||
// NewDefaultBackend creates a default backend
|
||||
func NewDefaultBackend(enabled bool) (*DefaultBackend, error) {
|
||||
func NewDefaultBackend() (*DefaultBackend, error) {
|
||||
p, err := getSMTPParams()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -91,24 +92,24 @@ func NewDefaultBackend(enabled bool) (*DefaultBackend, error) {
|
|||
d := gomail.NewDialer(p.Host, p.Port, p.Username, p.Password)
|
||||
|
||||
return &DefaultBackend{
|
||||
Dialer: &gomailDialer{Dialer: d},
|
||||
Enabled: enabled,
|
||||
Dialer: &gomailDialer{Dialer: d},
|
||||
Templates: NewTemplates(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Queue is an implementation of Backend.Queue.
|
||||
func (b *DefaultBackend) Queue(subject, from string, to []string, contentType, body string) error {
|
||||
// If not enabled, just log the email
|
||||
if !b.Enabled {
|
||||
log.WithFields(log.Fields{
|
||||
"subject": subject,
|
||||
"to": to,
|
||||
"from": from,
|
||||
"body": body,
|
||||
}).Info("Not sending email because email backend is not configured.")
|
||||
return nil
|
||||
// SendEmail is an implementation of Backend.SendEmail.
|
||||
// It renders the template and sends the email immediately via SMTP.
|
||||
func (b *DefaultBackend) SendEmail(templateType, from string, to []string, data interface{}) error {
|
||||
subject, body, err := b.Templates.Execute(templateType, EmailKindText, data)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "executing template")
|
||||
}
|
||||
|
||||
return b.queue(subject, from, to, EmailKindText, body)
|
||||
}
|
||||
|
||||
// queue sends the email immediately via SMTP.
|
||||
func (b *DefaultBackend) queue(subject, from string, to []string, contentType, body string) error {
|
||||
m := gomail.NewMessage()
|
||||
m.SetHeader("From", from)
|
||||
m.SetHeader("To", to...)
|
||||
|
|
@ -121,3 +122,34 @@ func (b *DefaultBackend) Queue(subject, from string, to []string, contentType, b
|
|||
|
||||
return nil
|
||||
}
|
||||
|
||||
// StdoutBackend is an implementation of the Backend
|
||||
// that prints emails to stdout instead of sending them.
|
||||
// This is useful for development and testing.
|
||||
type StdoutBackend struct {
|
||||
Templates Templates
|
||||
}
|
||||
|
||||
// NewStdoutBackend creates a stdout backend
|
||||
func NewStdoutBackend() *StdoutBackend {
|
||||
return &StdoutBackend{
|
||||
Templates: NewTemplates(),
|
||||
}
|
||||
}
|
||||
|
||||
// SendEmail is an implementation of Backend.SendEmail.
|
||||
// It renders the template and logs the email to stdout instead of sending it.
|
||||
func (b *StdoutBackend) SendEmail(templateType, from string, to []string, data interface{}) error {
|
||||
subject, body, err := b.Templates.Execute(templateType, EmailKindText, data)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "executing template")
|
||||
}
|
||||
|
||||
log.WithFields(log.Fields{
|
||||
"subject": subject,
|
||||
"to": to,
|
||||
"from": from,
|
||||
"body": body,
|
||||
}).Info("Email (not sent, using StdoutBackend)")
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,40 +31,28 @@ func (m *mockDialer) DialAndSend(msgs ...*gomail.Message) error {
|
|||
return m.err
|
||||
}
|
||||
|
||||
func TestDefaultBackendQueue(t *testing.T) {
|
||||
t.Run("enabled sends email", func(t *testing.T) {
|
||||
func TestDefaultBackendSendEmail(t *testing.T) {
|
||||
t.Run("sends email", func(t *testing.T) {
|
||||
mock := &mockDialer{}
|
||||
backend := &DefaultBackend{
|
||||
Dialer: mock,
|
||||
Enabled: true,
|
||||
Dialer: mock,
|
||||
Templates: NewTemplates(),
|
||||
}
|
||||
|
||||
err := backend.Queue("Test Subject", "alice@example.com", []string{"bob@example.com"}, "text/plain", "Test body")
|
||||
data := WelcomeTmplData{
|
||||
AccountEmail: "bob@example.com",
|
||||
WebURL: "https://example.com",
|
||||
}
|
||||
|
||||
err := backend.SendEmail(EmailTypeWelcome, "alice@example.com", []string{"bob@example.com"}, data)
|
||||
if err != nil {
|
||||
t.Fatalf("Queue failed: %v", err)
|
||||
t.Fatalf("SendEmail failed: %v", err)
|
||||
}
|
||||
|
||||
if len(mock.sentMessages) != 1 {
|
||||
t.Errorf("expected 1 message sent, got %d", len(mock.sentMessages))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("disabled does not send email", func(t *testing.T) {
|
||||
mock := &mockDialer{}
|
||||
backend := &DefaultBackend{
|
||||
Dialer: mock,
|
||||
Enabled: false,
|
||||
}
|
||||
|
||||
err := backend.Queue("Test Subject", "alice@example.com", []string{"bob@example.com"}, "text/plain", "Test body")
|
||||
if err != nil {
|
||||
t.Fatalf("Queue failed: %v", err)
|
||||
}
|
||||
|
||||
if len(mock.sentMessages) != 0 {
|
||||
t.Errorf("expected 0 messages sent when disabled, got %d", len(mock.sentMessages))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewDefaultBackend(t *testing.T) {
|
||||
|
|
@ -74,14 +62,11 @@ func TestNewDefaultBackend(t *testing.T) {
|
|||
t.Setenv("SmtpUsername", "user@example.com")
|
||||
t.Setenv("SmtpPassword", "secret")
|
||||
|
||||
backend, err := NewDefaultBackend(true)
|
||||
backend, err := NewDefaultBackend()
|
||||
if err != nil {
|
||||
t.Fatalf("NewDefaultBackend failed: %v", err)
|
||||
}
|
||||
|
||||
if backend.Enabled != true {
|
||||
t.Errorf("expected Enabled to be true, got %v", backend.Enabled)
|
||||
}
|
||||
if backend.Dialer == nil {
|
||||
t.Error("expected Dialer to be set")
|
||||
}
|
||||
|
|
@ -93,7 +78,7 @@ func TestNewDefaultBackend(t *testing.T) {
|
|||
t.Setenv("SmtpUsername", "")
|
||||
t.Setenv("SmtpPassword", "")
|
||||
|
||||
_, err := NewDefaultBackend(true)
|
||||
_, err := NewDefaultBackend()
|
||||
if err == nil {
|
||||
t.Error("expected error when SMTP not configured")
|
||||
}
|
||||
|
|
@ -102,3 +87,21 @@ func TestNewDefaultBackend(t *testing.T) {
|
|||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestStdoutBackendSendEmail(t *testing.T) {
|
||||
t.Run("logs email without sending", func(t *testing.T) {
|
||||
backend := NewStdoutBackend()
|
||||
|
||||
data := WelcomeTmplData{
|
||||
AccountEmail: "bob@example.com",
|
||||
WebURL: "https://example.com",
|
||||
}
|
||||
|
||||
err := backend.SendEmail(EmailTypeWelcome, "alice@example.com", []string{"bob@example.com"}, data)
|
||||
if err != nil {
|
||||
t.Fatalf("SendEmail failed: %v", err)
|
||||
}
|
||||
|
||||
// StdoutBackend should never return an error, just log
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,13 +40,19 @@ var (
|
|||
EmailKindText = "text/plain"
|
||||
)
|
||||
|
||||
// template is the common interface shared between Template from
|
||||
// tmpl is the common interface shared between Template from
|
||||
// html/template and text/template
|
||||
type template interface {
|
||||
type tmpl interface {
|
||||
Execute(wr io.Writer, data interface{}) error
|
||||
}
|
||||
|
||||
// Templates holds the parsed email templates
|
||||
// template wraps a template with its subject line
|
||||
type template struct {
|
||||
tmpl tmpl
|
||||
subject string
|
||||
}
|
||||
|
||||
// Templates holds the parsed email templates with their subjects
|
||||
type Templates map[string]template
|
||||
|
||||
func getTemplateKey(name, kind string) string {
|
||||
|
|
@ -56,16 +62,19 @@ func getTemplateKey(name, kind string) string {
|
|||
func (tmpl Templates) get(name, kind string) (template, error) {
|
||||
key := getTemplateKey(name, kind)
|
||||
t := tmpl[key]
|
||||
if t == nil {
|
||||
return nil, errors.Errorf("unsupported template '%s' with type '%s'", name, kind)
|
||||
if t.tmpl == nil {
|
||||
return template{}, errors.Errorf("unsupported template '%s' with type '%s'", name, kind)
|
||||
}
|
||||
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (tmpl Templates) set(name, kind string, t template) {
|
||||
func (tmpl Templates) set(name, kind string, t tmpl, subject string) {
|
||||
key := getTemplateKey(name, kind)
|
||||
tmpl[key] = t
|
||||
tmpl[key] = template{
|
||||
tmpl: t,
|
||||
subject: subject,
|
||||
}
|
||||
}
|
||||
|
||||
// NewTemplates initializes templates
|
||||
|
|
@ -84,15 +93,15 @@ func NewTemplates() Templates {
|
|||
}
|
||||
|
||||
T := Templates{}
|
||||
T.set(EmailTypeResetPassword, EmailKindText, passwordResetText)
|
||||
T.set(EmailTypeResetPasswordAlert, EmailKindText, passwordResetAlertText)
|
||||
T.set(EmailTypeWelcome, EmailKindText, welcomeText)
|
||||
T.set(EmailTypeResetPassword, EmailKindText, passwordResetText, "Reset your Dnote password")
|
||||
T.set(EmailTypeResetPasswordAlert, EmailKindText, passwordResetAlertText, "Your Dnote password was changed")
|
||||
T.set(EmailTypeWelcome, EmailKindText, welcomeText, "Welcome to Dnote!")
|
||||
|
||||
return T
|
||||
}
|
||||
|
||||
// initTextTmpl returns a template instance by parsing the template with the given name
|
||||
func initTextTmpl(templateName string) (template, error) {
|
||||
func initTextTmpl(templateName string) (tmpl, error) {
|
||||
filename := fmt.Sprintf("%s.txt", templateName)
|
||||
|
||||
content, err := templates.Files.ReadFile(filename)
|
||||
|
|
@ -108,17 +117,17 @@ func initTextTmpl(templateName string) (template, error) {
|
|||
return t, nil
|
||||
}
|
||||
|
||||
// Execute executes the template with the given name with the givn data
|
||||
func (tmpl Templates) Execute(name, kind string, data any) (string, error) {
|
||||
// Execute executes the template and returns the subject, body, and any error
|
||||
func (tmpl Templates) Execute(name, kind string, data any) (subject, body string, err error) {
|
||||
t, err := tmpl.get(name, kind)
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "getting template")
|
||||
return "", "", errors.Wrap(err, "getting template")
|
||||
}
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
if err := t.Execute(buf, data); err != nil {
|
||||
return "", errors.Wrap(err, "executing the template")
|
||||
if err := t.tmpl.Execute(buf, data); err != nil {
|
||||
return "", "", errors.Wrap(err, "executing the template")
|
||||
}
|
||||
|
||||
return buf.String(), nil
|
||||
return t.subject, buf.String(), nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,11 +65,14 @@ func TestResetPasswordEmail(t *testing.T) {
|
|||
Token: tc.token,
|
||||
WebURL: tc.webURL,
|
||||
}
|
||||
body, err := tmpl.Execute(EmailTypeResetPassword, EmailKindText, dat)
|
||||
subject, body, err := tmpl.Execute(EmailTypeResetPassword, EmailKindText, dat)
|
||||
if err != nil {
|
||||
t.Fatal(errors.Wrap(err, "executing"))
|
||||
}
|
||||
|
||||
if subject != "Reset your Dnote password" {
|
||||
t.Errorf("expected subject 'Reset your Dnote password', got '%s'", subject)
|
||||
}
|
||||
if ok := strings.Contains(body, tc.webURL); !ok {
|
||||
t.Errorf("email body did not contain %s", tc.webURL)
|
||||
}
|
||||
|
|
@ -103,11 +106,14 @@ func TestWelcomeEmail(t *testing.T) {
|
|||
AccountEmail: tc.accountEmail,
|
||||
WebURL: tc.webURL,
|
||||
}
|
||||
body, err := tmpl.Execute(EmailTypeWelcome, EmailKindText, dat)
|
||||
subject, body, err := tmpl.Execute(EmailTypeWelcome, EmailKindText, dat)
|
||||
if err != nil {
|
||||
t.Fatal(errors.Wrap(err, "executing"))
|
||||
}
|
||||
|
||||
if subject != "Welcome to Dnote!" {
|
||||
t.Errorf("expected subject 'Welcome to Dnote!', got '%s'", subject)
|
||||
}
|
||||
if ok := strings.Contains(body, tc.webURL); !ok {
|
||||
t.Errorf("email body did not contain %s", tc.webURL)
|
||||
}
|
||||
|
|
@ -141,11 +147,14 @@ func TestResetPasswordAlertEmail(t *testing.T) {
|
|||
AccountEmail: tc.accountEmail,
|
||||
WebURL: tc.webURL,
|
||||
}
|
||||
body, err := tmpl.Execute(EmailTypeResetPasswordAlert, EmailKindText, dat)
|
||||
subject, body, err := tmpl.Execute(EmailTypeResetPasswordAlert, EmailKindText, dat)
|
||||
if err != nil {
|
||||
t.Fatal(errors.Wrap(err, "executing"))
|
||||
}
|
||||
|
||||
if subject != "Your Dnote password was changed" {
|
||||
t.Errorf("expected subject 'Your Dnote password was changed', got '%s'", subject)
|
||||
}
|
||||
if ok := strings.Contains(body, tc.webURL); !ok {
|
||||
t.Errorf("email body did not contain %s", tc.webURL)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ package middleware
|
|||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
|
@ -143,7 +142,7 @@ func (rl *RateLimiter) Limit(next http.Handler) http.HandlerFunc {
|
|||
func ApplyLimit(h http.HandlerFunc, rateLimit bool) http.Handler {
|
||||
ret := h
|
||||
|
||||
if rateLimit && os.Getenv("APP_ENV") != "TEST" {
|
||||
if rateLimit {
|
||||
ret = defaultLimiter.Limit(ret)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -210,10 +210,10 @@ func MustRespondJSON(t *testing.T, w http.ResponseWriter, i interface{}, message
|
|||
|
||||
// MockEmail is a mock email data
|
||||
type MockEmail struct {
|
||||
Subject string
|
||||
From string
|
||||
To []string
|
||||
Body string
|
||||
TemplateType string
|
||||
From string
|
||||
To []string
|
||||
Data interface{}
|
||||
}
|
||||
|
||||
// MockEmailbackendImplementation is an email backend that simply discards the emails
|
||||
|
|
@ -230,16 +230,16 @@ func (b *MockEmailbackendImplementation) Clear() {
|
|||
b.Emails = []MockEmail{}
|
||||
}
|
||||
|
||||
// Queue is an implementation of Backend.Queue.
|
||||
func (b *MockEmailbackendImplementation) Queue(subject, from string, to []string, contentType, body string) error {
|
||||
// SendEmail is an implementation of Backend.SendEmail.
|
||||
func (b *MockEmailbackendImplementation) SendEmail(templateType, from string, to []string, data interface{}) error {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
b.Emails = append(b.Emails, MockEmail{
|
||||
Subject: subject,
|
||||
From: from,
|
||||
To: to,
|
||||
Body: body,
|
||||
TemplateType: templateType,
|
||||
From: from,
|
||||
To: to,
|
||||
Data: data,
|
||||
})
|
||||
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -7,11 +7,8 @@ dir=$(dirname "${BASH_SOURCE[0]}")
|
|||
basePath="$dir/../.."
|
||||
serverPath="$basePath/pkg/server"
|
||||
|
||||
# load env
|
||||
set -a
|
||||
dotenvPath="$serverPath/.env.dev"
|
||||
source "$dotenvPath"
|
||||
set +a
|
||||
# Set env
|
||||
DBPath=../../dev-server.db
|
||||
|
||||
# copy assets
|
||||
mkdir -p "$basePath/pkg/server/static"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue