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 9c704b57..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,7 +39,6 @@ var ( type App struct { DB *gorm.DB Clock clock.Clock - EmailTemplates mailer.Templates EmailBackend mailer.Backend Files map[string][]byte HTTP500Page []byte @@ -60,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/testutils.go b/pkg/server/app/testutils.go index 7f0cfb1b..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,7 +25,6 @@ import ( func NewTest() App { return App{ Clock: clock.NewMock(), - EmailTemplates: mailer.NewTemplates(), EmailBackend: &testutils.MockEmailbackendImplementation{}, HTTP500Page: assets.MustGetHTTP500ErrorPage(), WebURL: "http://127.0.0.0.1", diff --git a/pkg/server/cmd/helpers.go b/pkg/server/cmd/helpers.go index 23e7326a..3c135372 100644 --- a/pkg/server/cmd/helpers.go +++ b/pkg/server/cmd/helpers.go @@ -37,20 +37,24 @@ 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() - 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, WebURL: cfg.WebURL, diff --git a/pkg/server/mailer/backend.go b/pkg/server/mailer/backend.go index b4c0f348..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 { @@ -91,24 +92,24 @@ func NewDefaultBackend() (*DefaultBackend, error) { d := gomail.NewDialer(p.Host, p.Port, p.Username, p.Password) return &DefaultBackend{ - Dialer: &gomailDialer{Dialer: d}, - Enabled: true, + 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 ba6940d3..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) { @@ -79,9 +67,6 @@ func TestNewDefaultBackend(t *testing.T) { 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") } @@ -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"