diff --git a/pkg/e2e/server_test.go b/pkg/e2e/server_test.go index f94347ac..b7a17b2d 100644 --- a/pkg/e2e/server_test.go +++ b/pkg/e2e/server_test.go @@ -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") diff --git a/pkg/e2e/sync/testutils.go b/pkg/e2e/sync/testutils.go index 0a09d963..a5dfdf87 100644 --- a/pkg/e2e/sync/testutils.go +++ b/pkg/e2e/sync/testutils.go @@ -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 diff --git a/pkg/server/.env.dev b/pkg/server/.env.dev deleted file mode 100644 index c78a6704..00000000 --- a/pkg/server/.env.dev +++ /dev/null @@ -1,2 +0,0 @@ -APP_ENV=DEVELOPMENT -DBPath=../../dev-server.db diff --git a/pkg/server/app/app.go b/pkg/server/app/app.go index 80754fbe..6a8c5d14 100644 --- a/pkg/server/app/app.go +++ b/pkg/server/app/app.go @@ -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 } diff --git a/pkg/server/app/email.go b/pkg/server/app/email.go index 14d3ec38..1b78f722 100644 --- a/pkg/server/app/email.go +++ b/pkg/server/app/email.go @@ -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 diff --git a/pkg/server/app/errors.go b/pkg/server/app/errors.go index 9d41ca38..67635b23 100644 --- a/pkg/server/app/errors.go +++ b/pkg/server/app/errors.go @@ -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." diff --git a/pkg/server/app/sessions.go b/pkg/server/app/sessions.go index e3a574b7..6c230d76 100644 --- a/pkg/server/app/sessions.go +++ b/pkg/server/app/sessions.go @@ -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") } diff --git a/pkg/server/app/testutils.go b/pkg/server/app/testutils.go index 8ea6980d..48896cdc 100644 --- a/pkg/server/app/testutils.go +++ b/pkg/server/app/testutils.go @@ -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, diff --git a/pkg/server/cmd/helpers.go b/pkg/server/cmd/helpers.go index 3b981dc5..3c135372 100644 --- a/pkg/server/cmd/helpers.go +++ b/pkg/server/cmd/helpers.go @@ -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, diff --git a/pkg/server/cmd/start.go b/pkg/server/cmd/start.go index c65ac398..465f41ce 100644 --- a/pkg/server/cmd/start.go +++ b/pkg/server/cmd/start.go @@ -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, diff --git a/pkg/server/config/config.go b/pkg/server/config/config.go index 0fbb3617..88b604bc 100644 --- a/pkg/server/config/config.go +++ b/pkg/server/config/config.go @@ -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) diff --git a/pkg/server/database/database.go b/pkg/server/database/database.go index c5804880..cfb18ab7 100644 --- a/pkg/server/database/database.go +++ b/pkg/server/database/database.go @@ -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") } } }() diff --git a/pkg/server/log/log.go b/pkg/server/log/log.go index 60543acf..d8b43c8b 100644 --- a/pkg/server/log/log.go +++ b/pkg/server/log/log.go @@ -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 { diff --git a/pkg/server/mailer/backend.go b/pkg/server/mailer/backend.go index e282e03c..a09cf2e6 100644 --- a/pkg/server/mailer/backend.go +++ b/pkg/server/mailer/backend.go @@ -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 +} diff --git a/pkg/server/mailer/backend_test.go b/pkg/server/mailer/backend_test.go index 2838a19d..2e4e27a7 100644 --- a/pkg/server/mailer/backend_test.go +++ b/pkg/server/mailer/backend_test.go @@ -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 + }) +} diff --git a/pkg/server/mailer/mailer.go b/pkg/server/mailer/mailer.go index 10457252..18887cf5 100644 --- a/pkg/server/mailer/mailer.go +++ b/pkg/server/mailer/mailer.go @@ -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 } diff --git a/pkg/server/mailer/mailer_test.go b/pkg/server/mailer/mailer_test.go index e08c9c55..7f8ab383 100644 --- a/pkg/server/mailer/mailer_test.go +++ b/pkg/server/mailer/mailer_test.go @@ -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) } diff --git a/pkg/server/middleware/limit.go b/pkg/server/middleware/limit.go index 6985809e..bc26664f 100644 --- a/pkg/server/middleware/limit.go +++ b/pkg/server/middleware/limit.go @@ -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) } diff --git a/pkg/server/testutils/main.go b/pkg/server/testutils/main.go index dd10941e..8fce9ebf 100644 --- a/pkg/server/testutils/main.go +++ b/pkg/server/testutils/main.go @@ -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 diff --git a/scripts/server/dev.sh b/scripts/server/dev.sh index 7e1fe80e..3786f270 100755 --- a/scripts/server/dev.sh +++ b/scripts/server/dev.sh @@ -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"