From 292dc7d515f767c9587b5b376b8550c883879afa Mon Sep 17 00:00:00 2001 From: Sung Won Cho Date: Fri, 29 Nov 2019 17:59:04 +0800 Subject: [PATCH] Allow to receive welcome email with login instruction (#352) * Implement email backend * Add job ctx * Remove job ctx and use EmailBackend everywhere * Fix watcher to terminate cmd when inturrupted * Test runner validation * Send welcome email upon register * Use plaintext for verification email * Use plaintext for password reset email * Fix from --- CHANGELOG.md | 4 +- pkg/server/handlers/auth.go | 22 +- pkg/server/handlers/auth_test.go | 43 +-- pkg/server/handlers/main_test.go | 3 - pkg/server/handlers/routes.go | 29 +- pkg/server/handlers/routes_test.go | 87 ++++- pkg/server/handlers/testutils.go | 9 + pkg/server/handlers/user.go | 22 +- pkg/server/handlers/user_test.go | 16 +- pkg/server/handlers/v3_auth.go | 19 + pkg/server/handlers/v3_auth_test.go | 11 +- pkg/server/job/job.go | 105 ++++- pkg/server/job/job_test.go | 81 ++++ pkg/server/job/repetition/repetition.go | 66 ++-- pkg/server/job/repetition/repetition_test.go | 44 ++- pkg/server/mailer/backend.go | 75 ++++ pkg/server/mailer/mailer.go | 217 +++++------ pkg/server/mailer/mailer_test.go | 42 +- pkg/server/mailer/templates/main.go | 66 +++- .../templates/src/email_verification.html | 362 ------------------ .../mailer/templates/src/reset_password.html | 352 ----------------- .../mailer/templates/src/reset_password.txt | 9 + .../mailer/templates/src/verify_email.txt | 9 + pkg/server/mailer/templates/src/welcome.txt | 16 + pkg/server/mailer/types.go | 17 +- pkg/server/main.go | 30 +- pkg/server/testutils/main.go | 30 ++ pkg/watcher/main.go | 22 +- scripts/server/test.sh | 9 +- 29 files changed, 777 insertions(+), 1040 deletions(-) create mode 100644 pkg/server/job/job_test.go create mode 100644 pkg/server/mailer/backend.go delete mode 100644 pkg/server/mailer/templates/src/email_verification.html delete mode 100644 pkg/server/mailer/templates/src/reset_password.html create mode 100644 pkg/server/mailer/templates/src/reset_password.txt create mode 100644 pkg/server/mailer/templates/src/verify_email.txt create mode 100644 pkg/server/mailer/templates/src/welcome.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 540e1634..6e59bea9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,9 @@ The following log documents the history of the server project. ### Unreleased -N/A +#### Added + +- Send welcome email with login instructions upon reigstering ### 0.3.2 - 2019-11-20 diff --git a/pkg/server/handlers/auth.go b/pkg/server/handlers/auth.go index f11dbecc..c7820703 100644 --- a/pkg/server/handlers/auth.go +++ b/pkg/server/handlers/auth.go @@ -128,21 +128,17 @@ func (a *App) createResetToken(w http.ResponseWriter, r *http.Request) { return } - subject := "Reset your password" - data := mailer.EmailResetPasswordTmplData{ - Subject: subject, - Token: resetToken, - WebURL: a.WebURL, - } - email := mailer.NewEmail("noreply@getdnote.com", []string{params.Email}, subject) - if err := email.ParseTemplate(mailer.EmailTypeResetPassword, data); err != nil { - HandleError(w, errors.Wrap(err, "parsing template").Error(), nil, http.StatusInternalServerError) - return + body, err := a.EmailTemplates.Execute(mailer.EmailTypeResetPassword, mailer.EmailKindText, mailer.EmailResetPasswordTmplData{ + AccountEmail: account.Email.String, + Token: resetToken, + WebURL: a.WebURL, + }) + if err != nil { + HandleError(w, errors.Wrap(err, "executing reset password email template").Error(), nil, http.StatusInternalServerError) } - if err := email.Send(); err != nil { - HandleError(w, errors.Wrap(err, "sending email").Error(), nil, http.StatusInternalServerError) - return + if err := a.EmailBackend.Queue("Reset your password", "sung@getdnote.com", []string{params.Email}, "text/html", body); err != nil { + HandleError(w, errors.Wrap(err, "queueing email").Error(), nil, http.StatusInternalServerError) } } diff --git a/pkg/server/handlers/auth_test.go b/pkg/server/handlers/auth_test.go index 4e563d4f..ff3d8784 100644 --- a/pkg/server/handlers/auth_test.go +++ b/pkg/server/handlers/auth_test.go @@ -41,7 +41,7 @@ func TestGetMe(t *testing.T) { defer server.Close() u := testutils.SetupUserData() - testutils.SetupAccountData( u, "alice@example.com", "somepassword") + testutils.SetupAccountData(u, "alice@example.com", "somepassword") dat := `{"email": "alice@example.com"}` req := testutils.MakeReq(server, "POST", "/reset-token", dat) @@ -59,18 +59,17 @@ func TestGetMe(t *testing.T) { func TestCreateResetToken(t *testing.T) { t.Run("success", func(t *testing.T) { - defer testutils.ClearData() // Setup server := MustNewServer(t, &App{ - + Clock: clock.NewMock(), }) defer server.Close() u := testutils.SetupUserData() - testutils.SetupAccountData( u, "alice@example.com", "somepassword") + testutils.SetupAccountData(u, "alice@example.com", "somepassword") dat := `{"email": "alice@example.com"}` req := testutils.MakeReq(server, "POST", "/reset-token", dat) @@ -93,18 +92,18 @@ func TestCreateResetToken(t *testing.T) { }) t.Run("nonexistent email", func(t *testing.T) { - + defer testutils.ClearData() // Setup server := MustNewServer(t, &App{ - + Clock: clock.NewMock(), }) defer server.Close() u := testutils.SetupUserData() - testutils.SetupAccountData( u, "alice@example.com", "somepassword") + testutils.SetupAccountData(u, "alice@example.com", "somepassword") dat := `{"email": "bob@example.com"}` req := testutils.MakeReq(server, "POST", "/reset-token", dat) @@ -123,18 +122,18 @@ func TestCreateResetToken(t *testing.T) { func TestResetPassword(t *testing.T) { t.Run("success", func(t *testing.T) { - + defer testutils.ClearData() // Setup server := MustNewServer(t, &App{ - + Clock: clock.NewMock(), }) defer server.Close() u := testutils.SetupUserData() - a := testutils.SetupAccountData( u, "alice@example.com", "oldpassword") + a := testutils.SetupAccountData(u, "alice@example.com", "oldpassword") tok := database.Token{ UserID: u.ID, Value: "MivFxYiSMMA4An9dP24DNQ==", @@ -170,18 +169,18 @@ func TestResetPassword(t *testing.T) { }) t.Run("nonexistent token", func(t *testing.T) { - + defer testutils.ClearData() // Setup server := MustNewServer(t, &App{ - + Clock: clock.NewMock(), }) defer server.Close() u := testutils.SetupUserData() - a := testutils.SetupAccountData( u, "alice@example.com", "somepassword") + a := testutils.SetupAccountData(u, "alice@example.com", "somepassword") tok := database.Token{ UserID: u.ID, Value: "MivFxYiSMMA4An9dP24DNQ==", @@ -209,18 +208,18 @@ func TestResetPassword(t *testing.T) { }) t.Run("expired token", func(t *testing.T) { - + defer testutils.ClearData() // Setup server := MustNewServer(t, &App{ - + Clock: clock.NewMock(), }) defer server.Close() u := testutils.SetupUserData() - a := testutils.SetupAccountData( u, "alice@example.com", "somepassword") + a := testutils.SetupAccountData(u, "alice@example.com", "somepassword") tok := database.Token{ UserID: u.ID, Value: "MivFxYiSMMA4An9dP24DNQ==", @@ -247,18 +246,18 @@ func TestResetPassword(t *testing.T) { }) t.Run("used token", func(t *testing.T) { - + defer testutils.ClearData() // Setup server := MustNewServer(t, &App{ - + Clock: clock.NewMock(), }) defer server.Close() u := testutils.SetupUserData() - a := testutils.SetupAccountData( u, "alice@example.com", "somepassword") + a := testutils.SetupAccountData(u, "alice@example.com", "somepassword") usedAt := time.Now().Add(time.Hour * -11).UTC() tok := database.Token{ @@ -296,18 +295,18 @@ func TestResetPassword(t *testing.T) { }) t.Run("using wrong type token: email_verification", func(t *testing.T) { - + defer testutils.ClearData() // Setup server := MustNewServer(t, &App{ - + Clock: clock.NewMock(), }) defer server.Close() u := testutils.SetupUserData() - a := testutils.SetupAccountData( u, "alice@example.com", "somepassword") + a := testutils.SetupAccountData(u, "alice@example.com", "somepassword") tok := database.Token{ UserID: u.ID, Value: "MivFxYiSMMA4An9dP24DNQ==", diff --git a/pkg/server/handlers/main_test.go b/pkg/server/handlers/main_test.go index 8e7028d0..c090f138 100644 --- a/pkg/server/handlers/main_test.go +++ b/pkg/server/handlers/main_test.go @@ -4,14 +4,11 @@ import ( "os" "testing" - "github.com/dnote/dnote/pkg/server/mailer" "github.com/dnote/dnote/pkg/server/testutils" ) func TestMain(m *testing.M) { testutils.InitTestDB() - templatePath := os.Getenv("DNOTE_TEST_EMAIL_TEMPLATE_DIR") - mailer.InitTemplates(&templatePath) code := m.Run() testutils.ClearData() diff --git a/pkg/server/handlers/routes.go b/pkg/server/handlers/routes.go index 3dc19890..5981cc0a 100644 --- a/pkg/server/handlers/routes.go +++ b/pkg/server/handlers/routes.go @@ -30,12 +30,26 @@ import ( "github.com/dnote/dnote/pkg/server/database" "github.com/dnote/dnote/pkg/server/helpers" "github.com/dnote/dnote/pkg/server/log" + "github.com/dnote/dnote/pkg/server/mailer" "github.com/gorilla/mux" "github.com/jinzhu/gorm" "github.com/pkg/errors" "github.com/stripe/stripe-go" ) +var ( + // ErrEmptyDB is an error for missing database connection in the app configuration + ErrEmptyDB = errors.New("No database connection was provided") + // ErrEmptyClock is an error for missing clock in the app configuration + 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") +) + // Route represents a single route type Route struct { Method string @@ -307,15 +321,26 @@ type App struct { DB *gorm.DB Clock clock.Clock StripeAPIBackend stripe.Backend + EmailTemplates mailer.Templates + EmailBackend mailer.Backend WebURL string } func (a *App) validate() error { if a.WebURL == "" { - return errors.New("WebURL is empty") + return ErrEmptyWebURL + } + if a.Clock == nil { + return ErrEmptyClock + } + if a.EmailTemplates == nil { + return ErrEmptyEmailTemplates + } + if a.EmailBackend == nil { + return ErrEmptyEmailBackend } if a.DB == nil { - return errors.New("DB is empty") + return ErrEmptyDB } return nil diff --git a/pkg/server/handlers/routes_test.go b/pkg/server/handlers/routes_test.go index 3a43e725..15e2da71 100644 --- a/pkg/server/handlers/routes_test.go +++ b/pkg/server/handlers/routes_test.go @@ -28,6 +28,7 @@ import ( "github.com/dnote/dnote/pkg/assert" "github.com/dnote/dnote/pkg/clock" "github.com/dnote/dnote/pkg/server/database" + "github.com/dnote/dnote/pkg/server/mailer" "github.com/dnote/dnote/pkg/server/testutils" "github.com/jinzhu/gorm" "github.com/pkg/errors" @@ -182,7 +183,6 @@ func TestGetCredential(t *testing.T) { } func TestAuthMiddleware(t *testing.T) { - defer testutils.ClearData() user := testutils.SetupUserData() @@ -296,7 +296,6 @@ func TestAuthMiddleware(t *testing.T) { } func TestAuthMiddleware_ProOnly(t *testing.T) { - defer testutils.ClearData() user := testutils.SetupUserData() @@ -385,7 +384,6 @@ func TestAuthMiddleware_ProOnly(t *testing.T) { } func TestTokenAuthMiddleWare(t *testing.T) { - defer testutils.ClearData() user := testutils.SetupUserData() @@ -515,7 +513,6 @@ func TestTokenAuthMiddleWare(t *testing.T) { } func TestTokenAuthMiddleWare_ProOnly(t *testing.T) { - defer testutils.ClearData() user := testutils.SetupUserData() @@ -691,3 +688,85 @@ func TestNotSupportedVersions(t *testing.T) { }) } } + +func TestNewRouter_AppValidate(t *testing.T) { + testCases := []struct { + app App + expectedErr error + }{ + { + app: App{ + DB: &gorm.DB{}, + Clock: clock.NewMock(), + StripeAPIBackend: nil, + EmailTemplates: mailer.Templates{}, + EmailBackend: &testutils.MockEmailbackendImplementation{}, + WebURL: "http://mock.url", + }, + expectedErr: nil, + }, + { + app: App{ + DB: nil, + Clock: clock.NewMock(), + StripeAPIBackend: nil, + EmailTemplates: mailer.Templates{}, + EmailBackend: &testutils.MockEmailbackendImplementation{}, + WebURL: "http://mock.url", + }, + expectedErr: ErrEmptyDB, + }, + { + app: App{ + DB: &gorm.DB{}, + Clock: nil, + StripeAPIBackend: nil, + EmailTemplates: mailer.Templates{}, + EmailBackend: &testutils.MockEmailbackendImplementation{}, + WebURL: "http://mock.url", + }, + expectedErr: ErrEmptyClock, + }, + { + app: App{ + DB: &gorm.DB{}, + Clock: clock.NewMock(), + StripeAPIBackend: nil, + EmailTemplates: nil, + EmailBackend: &testutils.MockEmailbackendImplementation{}, + WebURL: "http://mock.url", + }, + expectedErr: ErrEmptyEmailTemplates, + }, + { + app: App{ + DB: &gorm.DB{}, + Clock: clock.NewMock(), + StripeAPIBackend: nil, + EmailTemplates: mailer.Templates{}, + EmailBackend: nil, + WebURL: "http://mock.url", + }, + expectedErr: ErrEmptyEmailBackend, + }, + { + app: App{ + DB: &gorm.DB{}, + Clock: clock.NewMock(), + StripeAPIBackend: nil, + EmailTemplates: mailer.Templates{}, + EmailBackend: &testutils.MockEmailbackendImplementation{}, + WebURL: "", + }, + expectedErr: ErrEmptyWebURL, + }, + } + + for idx, tc := range testCases { + t.Run(fmt.Sprintf("test case %d", idx), func(t *testing.T) { + _, err := NewRouter(&tc.app) + + assert.Equal(t, errors.Cause(err), tc.expectedErr, "error mismatch") + }) + } +} diff --git a/pkg/server/handlers/testutils.go b/pkg/server/handlers/testutils.go index 187975fa..d0cb8e21 100644 --- a/pkg/server/handlers/testutils.go +++ b/pkg/server/handlers/testutils.go @@ -23,6 +23,7 @@ import ( "os" "testing" + "github.com/dnote/dnote/pkg/server/mailer" "github.com/dnote/dnote/pkg/server/testutils" "github.com/pkg/errors" ) @@ -33,6 +34,14 @@ func MustNewServer(t *testing.T, app *App) *httptest.Server { app.WebURL = os.Getenv("WebURL") app.DB = testutils.DB + // If email backend was not provided, use the default mock backend + if app.EmailBackend == nil { + app.EmailBackend = &testutils.MockEmailbackendImplementation{} + } + + emailTmplDir := os.Getenv("DNOTE_TEST_EMAIL_TEMPLATE_DIR") + app.EmailTemplates = mailer.NewTemplates(&emailTmplDir) + r, err := NewRouter(app) if err != nil { t.Fatal(errors.Wrap(err, "initializing server")) diff --git a/pkg/server/handlers/user.go b/pkg/server/handlers/user.go index 82432d57..95684163 100644 --- a/pkg/server/handlers/user.go +++ b/pkg/server/handlers/user.go @@ -172,21 +172,15 @@ func (a *App) createVerificationToken(w http.ResponseWriter, r *http.Request) { return } - subject := "Verify your email" - data := mailer.EmailVerificationTmplData{ - Subject: subject, - Token: tokenValue, - WebURL: a.WebURL, + body, err := a.EmailTemplates.Execute(mailer.EmailTypeEmailVerification, mailer.EmailKindText, mailer.EmailVerificationTmplData{ + Token: tokenValue, + WebURL: a.WebURL, + }) + if err != nil { + HandleError(w, errors.Wrap(err, "executing reset verification template").Error(), nil, http.StatusInternalServerError) } - email := mailer.NewEmail("noreply@getdnote.com", []string{account.Email.String}, subject) - if err := email.ParseTemplate(mailer.EmailTypeEmailVerification, data); err != nil { - HandleError(w, "parsing template", err, http.StatusInternalServerError) - return - } - - if err := email.Send(); err != nil { - HandleError(w, "sending email", err, http.StatusInternalServerError) - return + if err := a.EmailBackend.Queue("Verify your Dnote email address", "sung@getdnote.com", []string{account.Email.String}, "text/plain", body); err != nil { + HandleError(w, errors.Wrap(err, "queueing email").Error(), nil, http.StatusInternalServerError) } w.WriteHeader(http.StatusCreated) diff --git a/pkg/server/handlers/user_test.go b/pkg/server/handlers/user_test.go index 877ee55e..e802b0ef 100644 --- a/pkg/server/handlers/user_test.go +++ b/pkg/server/handlers/user_test.go @@ -22,14 +22,12 @@ import ( "encoding/json" "fmt" "net/http" - "os" "testing" "time" "github.com/dnote/dnote/pkg/assert" "github.com/dnote/dnote/pkg/clock" "github.com/dnote/dnote/pkg/server/database" - "github.com/dnote/dnote/pkg/server/mailer" "github.com/dnote/dnote/pkg/server/presenters" "github.com/dnote/dnote/pkg/server/testutils" "github.com/pkg/errors" @@ -123,20 +121,13 @@ func TestUpdatePassword(t *testing.T) { func TestCreateVerificationToken(t *testing.T) { t.Run("success", func(t *testing.T) { - defer testutils.ClearData() // Setup - - // TODO: send emails in the background using job queue to avoid coupling the - // handler itself to the mailer - - templatePath := os.Getenv("DNOTE_TEST_EMAIL_TEMPLATE_DIR") - mailer.InitTemplates(&templatePath) - + emailBackend := testutils.MockEmailbackendImplementation{} server := MustNewServer(t, &App{ - - Clock: clock.NewMock(), + Clock: clock.NewMock(), + EmailBackend: &emailBackend, }) defer server.Close() @@ -161,6 +152,7 @@ func TestCreateVerificationToken(t *testing.T) { assert.NotEqual(t, token.Value, "", "token Value mismatch") assert.Equal(t, tokenCount, 1, "token count mismatch") assert.Equal(t, token.UsedAt, (*time.Time)(nil), "token UsedAt mismatch") + assert.Equal(t, len(emailBackend.Emails), 1, "email queue count mismatch") }) t.Run("already verified", func(t *testing.T) { diff --git a/pkg/server/handlers/v3_auth.go b/pkg/server/handlers/v3_auth.go index b6cf9f70..30cf363f 100644 --- a/pkg/server/handlers/v3_auth.go +++ b/pkg/server/handlers/v3_auth.go @@ -24,6 +24,8 @@ import ( "time" "github.com/dnote/dnote/pkg/server/database" + "github.com/dnote/dnote/pkg/server/log" + "github.com/dnote/dnote/pkg/server/mailer" "github.com/dnote/dnote/pkg/server/operations" "github.com/jinzhu/gorm" "github.com/pkg/errors" @@ -203,6 +205,23 @@ func (a *App) register(w http.ResponseWriter, r *http.Request) { } respondWithSession(a.DB, w, user.ID, http.StatusCreated) + + // send welcome email + body, err := a.EmailTemplates.Execute(mailer.EmailTypeWelcome, mailer.EmailKindText, mailer.WelcomeTmplData{ + AccountEmail: params.Email, + WebURL: a.WebURL, + }) + if err != nil { + log.WithFields(log.Fields{ + "email": params.Email, + }).ErrorWrap(err, "executing welcome email template") + return + } + if err := a.EmailBackend.Queue("Welcome to Dnote!", "sung@getdnote.com", []string{params.Email}, "text/plain", body); err != nil { + log.WithFields(log.Fields{ + "email": params.Email, + }).ErrorWrap(err, "queueing email") + } } // respondWithSession makes a HTTP response with the session from the user with the given userID. diff --git a/pkg/server/handlers/v3_auth_test.go b/pkg/server/handlers/v3_auth_test.go index 9d89d424..f691e9d5 100644 --- a/pkg/server/handlers/v3_auth_test.go +++ b/pkg/server/handlers/v3_auth_test.go @@ -77,13 +77,13 @@ func TestRegister(t *testing.T) { for _, tc := range testCases { t.Run(fmt.Sprintf("register %s %s", tc.email, tc.password), func(t *testing.T) { - defer testutils.ClearData() // Setup + emailBackend := testutils.MockEmailbackendImplementation{} server := MustNewServer(t, &App{ - - Clock: clock.NewMock(), + Clock: clock.NewMock(), + EmailBackend: &emailBackend, }) defer server.Close() @@ -113,6 +113,10 @@ func TestRegister(t *testing.T) { testutils.MustExec(t, testutils.DB.Model(&database.RepetitionRule{}).Where("user_id = ?", account.UserID).Count(&repetitionRuleCount), "counting repetition rules") assert.Equal(t, repetitionRuleCount, 1, "repetitionRuleCount mismatch") + // welcome email + assert.Equalf(t, len(emailBackend.Emails), 1, "email queue count mismatch") + assert.DeepEqual(t, emailBackend.Emails[0].To, []string{tc.email}, "email to mismatch") + // after register, should sign in user assertSessionResp(t, res) }) @@ -178,7 +182,6 @@ func TestRegisterMissingParams(t *testing.T) { } func TestRegisterDuplicateEmail(t *testing.T) { - defer testutils.ClearData() // Setup diff --git a/pkg/server/job/job.go b/pkg/server/job/job.go index dec4fa2c..cb2be116 100644 --- a/pkg/server/job/job.go +++ b/pkg/server/job/job.go @@ -20,15 +20,74 @@ package job import ( "log" - "os" "github.com/dnote/dnote/pkg/clock" "github.com/dnote/dnote/pkg/server/job/repetition" + "github.com/dnote/dnote/pkg/server/mailer" "github.com/jinzhu/gorm" "github.com/pkg/errors" "github.com/robfig/cron" ) +var ( + // ErrEmptyDB is an error for missing database connection in the app configuration + ErrEmptyDB = errors.New("No database connection was provided") + // ErrEmptyClock is an error for missing clock in the app configuration + 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") +) + +// Runner is a configuration for job +type Runner struct { + DB *gorm.DB + Clock clock.Clock + EmailTmpl mailer.Templates + EmailBackend mailer.Backend + WebURL string +} + +// NewRunner returns a new runner +func NewRunner(db *gorm.DB, c clock.Clock, t mailer.Templates, b mailer.Backend, webURL string) (Runner, error) { + ret := Runner{ + DB: db, + EmailTmpl: t, + EmailBackend: b, + Clock: c, + WebURL: webURL, + } + + if err := ret.validate(); err != nil { + return Runner{}, errors.Wrap(err, "validating runner configuration") + } + + return ret, nil +} + +func (r *Runner) validate() error { + if r.DB == nil { + return ErrEmptyDB + } + if r.Clock == nil { + return ErrEmptyClock + } + if r.EmailTmpl == nil { + return ErrEmptyEmailTemplates + } + if r.EmailBackend == nil { + return ErrEmptyEmailBackend + } + if r.WebURL == "" { + return ErrEmptyWebURL + } + + return nil +} + func scheduleJob(c *cron.Cron, spec string, cmd func()) { s, err := cron.ParseStandard(spec) if err != nil { @@ -38,21 +97,11 @@ func scheduleJob(c *cron.Cron, spec string, cmd func()) { c.Schedule(s, cron.FuncJob(cmd)) } -func checkEnvironment() error { - if os.Getenv("WebURL") == "" { - return errors.New("WebURL is empty") - } - - return nil -} - -func schedule(db *gorm.DB, ch chan error) { - cl := clock.New() - +func (r *Runner) schedule(ch chan error) { // Schedule jobs - c := cron.New() - scheduleJob(c, "* * * * *", func() { repetition.Do(db, cl) }) - c.Start() + cr := cron.New() + scheduleJob(cr, "* * * * *", func() { r.DoRepetition() }) + cr.Start() ch <- nil @@ -60,14 +109,15 @@ func schedule(db *gorm.DB, ch chan error) { select {} } -// Run starts the background tasks in a separate goroutine that runs forever -func Run(db *gorm.DB) error { - if err := checkEnvironment(); err != nil { - return errors.Wrap(err, "checking environment variables") +// Do starts the background tasks in a separate goroutine that runs forever +func (r *Runner) Do() error { + // validate + if err := r.validate(); err != nil { + return errors.Wrap(err, "validating job configurations") } ch := make(chan error) - go schedule(db, ch) + go r.schedule(ch) if err := <-ch; err != nil { return errors.Wrap(err, "scheduling jobs") } @@ -76,3 +126,18 @@ func Run(db *gorm.DB) error { return nil } + +// DoRepetition creates spaced repetitions and delivers the results based on the rules +func (r *Runner) DoRepetition() error { + p := repetition.Params{ + DB: r.DB, + Clock: r.Clock, + EmailTmpl: r.EmailTmpl, + EmailBackend: r.EmailBackend, + } + if err := repetition.Do(p); err != nil { + return errors.Wrap(err, "performing repetition job") + } + + return nil +} diff --git a/pkg/server/job/job_test.go b/pkg/server/job/job_test.go new file mode 100644 index 00000000..43c049e6 --- /dev/null +++ b/pkg/server/job/job_test.go @@ -0,0 +1,81 @@ +package job + +import ( + "fmt" + "testing" + + "github.com/dnote/dnote/pkg/assert" + "github.com/dnote/dnote/pkg/clock" + "github.com/dnote/dnote/pkg/server/mailer" + "github.com/dnote/dnote/pkg/server/testutils" + "github.com/jinzhu/gorm" + "github.com/pkg/errors" +) + +func TestNewRunner(t *testing.T) { + testCases := []struct { + db *gorm.DB + clock clock.Clock + emailTmpl mailer.Templates + emailBackend mailer.Backend + webURL string + expectedErr error + }{ + { + db: &gorm.DB{}, + clock: clock.NewMock(), + emailTmpl: mailer.Templates{}, + emailBackend: &testutils.MockEmailbackendImplementation{}, + webURL: "http://mock.url", + expectedErr: nil, + }, + { + db: nil, + clock: clock.NewMock(), + emailTmpl: mailer.Templates{}, + emailBackend: &testutils.MockEmailbackendImplementation{}, + webURL: "http://mock.url", + expectedErr: ErrEmptyDB, + }, + { + db: &gorm.DB{}, + clock: nil, + emailTmpl: mailer.Templates{}, + emailBackend: &testutils.MockEmailbackendImplementation{}, + webURL: "http://mock.url", + expectedErr: ErrEmptyClock, + }, + { + db: &gorm.DB{}, + clock: clock.NewMock(), + emailTmpl: nil, + emailBackend: &testutils.MockEmailbackendImplementation{}, + webURL: "http://mock.url", + expectedErr: ErrEmptyEmailTemplates, + }, + { + db: &gorm.DB{}, + clock: clock.NewMock(), + emailTmpl: mailer.Templates{}, + emailBackend: nil, + webURL: "http://mock.url", + expectedErr: ErrEmptyEmailBackend, + }, + { + db: &gorm.DB{}, + clock: clock.NewMock(), + emailTmpl: mailer.Templates{}, + emailBackend: &testutils.MockEmailbackendImplementation{}, + webURL: "", + expectedErr: ErrEmptyWebURL, + }, + } + + for idx, tc := range testCases { + t.Run(fmt.Sprintf("test case %d", idx), func(t *testing.T) { + _, err := NewRunner(tc.db, tc.clock, tc.emailTmpl, tc.emailBackend, tc.webURL) + + assert.Equal(t, errors.Cause(err), tc.expectedErr, "error mismatch") + }) + } +} diff --git a/pkg/server/job/repetition/repetition.go b/pkg/server/job/repetition/repetition.go index 621f59e4..8bf3948c 100644 --- a/pkg/server/job/repetition/repetition.go +++ b/pkg/server/job/repetition/repetition.go @@ -31,22 +31,29 @@ import ( "github.com/pkg/errors" ) +// Params holds data that repetition job needs in order to perform +type Params struct { + DB *gorm.DB + Clock clock.Clock + EmailTmpl mailer.Templates + EmailBackend mailer.Backend +} + // BuildEmailParams is the params for building an email type BuildEmailParams struct { - Now time.Time - User database.User - EmailAddr string - Digest database.Digest - Rule database.RepetitionRule + Now time.Time + User database.User + Digest database.Digest + Rule database.RepetitionRule } // BuildEmail builds an email for the spaced repetition -func BuildEmail(db *gorm.DB, p BuildEmailParams) (*mailer.Email, error) { +func BuildEmail(db *gorm.DB, emailTmpl mailer.Templates, p BuildEmailParams) (string, string, error) { date := p.Now.Format("Jan 02 2006") subject := fmt.Sprintf("%s %s", p.Rule.Title, date) tok, err := mailer.GetToken(db, p.User, database.TokenTypeRepetition) if err != nil { - return nil, errors.Wrap(err, "getting email frequency token") + return "", "", errors.Wrap(err, "getting email frequency token") } t1 := p.Now.AddDate(0, 0, -3).UnixNano() @@ -87,12 +94,12 @@ func BuildEmail(db *gorm.DB, p BuildEmailParams) (*mailer.Email, error) { WebURL: os.Getenv("WebURL"), } - email := mailer.NewEmail("noreply@getdnote.com", []string{p.EmailAddr}, subject) - if err := email.ParseTemplate(mailer.EmailTypeWeeklyDigest, tmplData); err != nil { - return nil, err + body, err := emailTmpl.Execute(mailer.EmailTypeWeeklyDigest, mailer.EmailKindHTML, tmplData) + if err != nil { + return "", "", errors.Wrap(err, "executing digest email template") } - return email, nil + return subject, body, nil } func getEligibleRules(db *gorm.DB, now time.Time) ([]database.RepetitionRule, error) { @@ -128,9 +135,9 @@ func build(tx *gorm.DB, rule database.RepetitionRule) (database.Digest, error) { return digest, nil } -func notify(db *gorm.DB, now time.Time, user database.User, digest database.Digest, rule database.RepetitionRule) error { +func notify(p Params, now time.Time, user database.User, digest database.Digest, rule database.RepetitionRule) error { var account database.Account - if err := db.Where("user_id = ?", user.ID).First(&account).Error; err != nil { + if err := p.DB.Where("user_id = ?", user.ID).First(&account).Error; err != nil { return errors.Wrap(err, "getting account") } @@ -141,28 +148,25 @@ func notify(db *gorm.DB, now time.Time, user database.User, digest database.Dige return nil } - email, err := BuildEmail(db, BuildEmailParams{ - Now: now, - User: user, - EmailAddr: account.Email.String, - Digest: digest, - Rule: rule, + subject, body, err := BuildEmail(p.DB, p.EmailTmpl, BuildEmailParams{ + Now: now, + User: user, + Digest: digest, + Rule: rule, }) if err != nil { return errors.Wrap(err, "making email") } - err = email.Send() - if err != nil { - return errors.Wrap(err, "sending email") + if err := p.EmailBackend.Queue(subject, "noreply@getdnote.com", []string{account.Email.String}, mailer.EmailKindHTML, body); err != nil { + return errors.Wrap(err, "queueing email") } notif := database.Notification{ Type: "email_weekly", UserID: user.ID, } - - if err := db.Create(¬if).Error; err != nil { + if err := p.DB.Create(¬if).Error; err != nil { return errors.Wrap(err, "creating notification") } @@ -197,12 +201,12 @@ func touchTimestamp(tx *gorm.DB, rule database.RepetitionRule, now time.Time) er return nil } -func process(db *gorm.DB, now time.Time, rule database.RepetitionRule) error { +func process(p Params, now time.Time, rule database.RepetitionRule) error { log.WithFields(log.Fields{ "uuid": rule.UUID, }).Info("processing repetition") - tx := db.Begin() + tx := p.DB.Begin() if !checkCooldown(now, rule) { return nil @@ -235,7 +239,7 @@ func process(db *gorm.DB, now time.Time, rule database.RepetitionRule) error { return errors.Wrap(err, "committing transaction") } - if err := notify(db, now, user, digest, rule); err != nil { + if err := notify(p, now, user, digest, rule); err != nil { return errors.Wrap(err, "notifying user") } @@ -247,10 +251,10 @@ func process(db *gorm.DB, now time.Time, rule database.RepetitionRule) error { } // Do creates spaced repetitions and delivers the results based on the rules -func Do(db *gorm.DB, c clock.Clock) error { - now := c.Now().UTC() +func Do(p Params) error { + now := p.Clock.Now().UTC() - rules, err := getEligibleRules(db, now) + rules, err := getEligibleRules(p.DB, now) if err != nil { return errors.Wrap(err, "getting eligible repetition rules") } @@ -262,7 +266,7 @@ func Do(db *gorm.DB, c clock.Clock) error { }).Info("processing rules") for _, rule := range rules { - if err := process(db, now, rule); err != nil { + if err := process(p, now, rule); err != nil { log.WithFields(log.Fields{ "rule uuid": rule.UUID, }).ErrorWrap(err, "Could not process the repetition rule") diff --git a/pkg/server/job/repetition/repetition_test.go b/pkg/server/job/repetition/repetition_test.go index ba02ee1b..ceacfbb9 100644 --- a/pkg/server/job/repetition/repetition_test.go +++ b/pkg/server/job/repetition/repetition_test.go @@ -26,11 +26,11 @@ import ( "github.com/dnote/dnote/pkg/assert" "github.com/dnote/dnote/pkg/clock" "github.com/dnote/dnote/pkg/server/database" + "github.com/dnote/dnote/pkg/server/mailer" "github.com/dnote/dnote/pkg/server/testutils" ) func assertLastActive(t *testing.T, ruleUUID string, lastActive int64) { - var rule database.RepetitionRule testutils.MustExec(t, testutils.DB.Where("uuid = ?", ruleUUID).First(&rule), "finding rule1") @@ -44,6 +44,15 @@ func assertDigestCount(t *testing.T, rule database.RepetitionRule, expected int) assert.Equal(t, digestCount, expected, "digest count mismatch") } +func getTestParams(c clock.Clock) Params { + return Params{ + DB: testutils.DB, + Clock: c, + EmailTmpl: mailer.Templates{}, + EmailBackend: &testutils.MockEmailbackendImplementation{}, + } +} + func TestDo(t *testing.T) { t.Run("processes the rule on time", func(t *testing.T) { defer testutils.ClearData() @@ -71,64 +80,63 @@ func TestDo(t *testing.T) { testutils.MustExec(t, testutils.DB.Save(&r1), "preparing rule1") c := clock.NewMock() - // Test // 1 day later c.SetNow(time.Date(2009, time.November, 2, 12, 2, 1, 0, time.UTC)) - Do(testutils.DB, c) + Do(getTestParams(c)) assertLastActive(t, r1.UUID, int64(0)) assertDigestCount(t, r1, 0) // 2 days later c.SetNow(time.Date(2009, time.November, 3, 12, 2, 1, 0, time.UTC)) - Do(testutils.DB, c) + Do(getTestParams(c)) assertLastActive(t, r1.UUID, int64(0)) assertDigestCount(t, r1, 0) // 3 days later - should be processed c.SetNow(time.Date(2009, time.November, 4, 12, 1, 1, 0, time.UTC)) - Do(testutils.DB, c) + Do(getTestParams(c)) assertLastActive(t, r1.UUID, int64(0)) assertDigestCount(t, r1, 0) c.SetNow(time.Date(2009, time.November, 4, 12, 2, 1, 0, time.UTC)) - Do(testutils.DB, c) + Do(getTestParams(c)) assertLastActive(t, r1.UUID, int64(1257336120000)) assertDigestCount(t, r1, 1) c.SetNow(time.Date(2009, time.November, 4, 12, 3, 1, 0, time.UTC)) - Do(testutils.DB, c) + Do(getTestParams(c)) assertLastActive(t, r1.UUID, int64(1257336120000)) assertDigestCount(t, r1, 1) // 4 day later c.SetNow(time.Date(2009, time.November, 5, 12, 2, 1, 0, time.UTC)) - Do(testutils.DB, c) + Do(getTestParams(c)) assertLastActive(t, r1.UUID, int64(1257336120000)) assertDigestCount(t, r1, 1) // 5 days later c.SetNow(time.Date(2009, time.November, 6, 12, 2, 1, 0, time.UTC)) - Do(testutils.DB, c) + Do(getTestParams(c)) assertLastActive(t, r1.UUID, int64(1257336120000)) assertDigestCount(t, r1, 1) // 6 days later - should be processed c.SetNow(time.Date(2009, time.November, 7, 12, 2, 1, 0, time.UTC)) - Do(testutils.DB, c) + Do(getTestParams(c)) assertLastActive(t, r1.UUID, int64(1257595320000)) assertDigestCount(t, r1, 2) // 7 days later c.SetNow(time.Date(2009, time.November, 8, 12, 2, 1, 0, time.UTC)) - Do(testutils.DB, c) + Do(getTestParams(c)) assertLastActive(t, r1.UUID, int64(1257595320000)) assertDigestCount(t, r1, 2) // 8 days later c.SetNow(time.Date(2009, time.November, 9, 12, 2, 1, 0, time.UTC)) - Do(testutils.DB, c) + Do(getTestParams(c)) assertLastActive(t, r1.UUID, int64(1257595320000)) assertDigestCount(t, r1, 2) // 9 days later - should be processed c.SetNow(time.Date(2009, time.November, 10, 12, 2, 1, 0, time.UTC)) - Do(testutils.DB, c) + Do(getTestParams(c)) assertLastActive(t, r1.UUID, int64(1257854520000)) assertDigestCount(t, r1, 3) }) @@ -174,7 +182,7 @@ func TestDo(t *testing.T) { c := clock.NewMock() c.SetNow(time.Date(2009, time.November, 10, 12, 2, 1, 0, time.UTC)) - Do(testutils.DB, c) + Do(getTestParams(c)) var rule database.RepetitionRule testutils.MustExec(t, testutils.DB.Where("uuid = ?", r1.UUID).First(&rule), "finding rule1") @@ -213,7 +221,7 @@ func TestDo_Disabled(t *testing.T) { // Execute c := clock.NewMock() c.SetNow(time.Date(2009, time.November, 4, 12, 2, 0, 0, time.UTC)) - Do(testutils.DB, c) + Do(getTestParams(c)) // Test assertLastActive(t, r1.UUID, int64(0)) @@ -307,7 +315,7 @@ func TestDo_BalancedStrategy(t *testing.T) { c := clock.NewMock() c.SetNow(time.Date(2009, time.November, 8, 21, 0, 0, 0, time.UTC)) - Do(testutils.DB, c) + Do(getTestParams(c)) // Test assertLastActive(t, r1.UUID, int64(1257714000000)) @@ -362,7 +370,7 @@ func TestDo_BalancedStrategy(t *testing.T) { c := clock.NewMock() c.SetNow(time.Date(2009, time.November, 8, 21, 0, 1, 0, time.UTC)) - Do(testutils.DB, c) + Do(getTestParams(c)) // Test assertLastActive(t, r1.UUID, int64(1257714000000)) @@ -416,7 +424,7 @@ func TestDo_BalancedStrategy(t *testing.T) { c := clock.NewMock() c.SetNow(time.Date(2009, time.November, 8, 21, 0, 0, 0, time.UTC)) - Do(testutils.DB, c) + Do(getTestParams(c)) // Test assertLastActive(t, r1.UUID, int64(1257714000000)) diff --git a/pkg/server/mailer/backend.go b/pkg/server/mailer/backend.go new file mode 100644 index 00000000..11fa4ab4 --- /dev/null +++ b/pkg/server/mailer/backend.go @@ -0,0 +1,75 @@ +package mailer + +import ( + "fmt" + "log" + "os" + "strconv" + + "github.com/pkg/errors" + "gopkg.in/gomail.v2" +) + +// Backend is an interface for sending emails. +type Backend interface { + Queue(subject, from string, to []string, contentType, body string) error +} + +// SimpleBackendImplementation is an implementation of the Backend +// that sends an email without queueing. +type SimpleBackendImplementation struct { +} + +type dialerParams struct { + Host string + Port int + Username string + Password string +} + +func getSMTPParams() (*dialerParams, error) { + portStr := os.Getenv("SmtpPort") + port, err := strconv.Atoi(portStr) + if err != nil { + return nil, errors.Wrap(err, "parsing SMTP port") + } + + p := &dialerParams{ + Host: os.Getenv("SmtpHost"), + Port: port, + Username: os.Getenv("SmtpUsername"), + Password: os.Getenv("SmtpPassword"), + } + + return p, nil +} + +// Queue is an implementation of Backend.Queue. +func (b *SimpleBackendImplementation) Queue(subject, from string, to []string, contentType, body string) error { + // If not production, never actually send an email + if os.Getenv("GO_ENV") != "PRODUCTION" { + log.Println("Not sending email because Dnote is not running in a production environment.") + log.Printf("Subject: %s, to: %s, from: %s", subject, to, from) + fmt.Println(body) + return nil + } + + m := gomail.NewMessage() + m.SetHeader("From", from) + m.SetHeader("To", to...) + m.SetHeader("Subject", subject) + m.SetBody(contentType, body) + // m.SetBody("text/html", body) + + p, err := getSMTPParams() + if err != nil { + return errors.Wrap(err, "getting dialer params") + } + + d := gomail.NewPlainDialer(p.Host, p.Port, p.Username, p.Password) + if err := d.DialAndSend(m); err != nil { + return err + } + + return nil +} diff --git a/pkg/server/mailer/mailer.go b/pkg/server/mailer/mailer.go index 55177c37..a50346b9 100644 --- a/pkg/server/mailer/mailer.go +++ b/pkg/server/mailer/mailer.go @@ -22,43 +22,100 @@ package mailer import ( "bytes" "fmt" - "html/template" - "os" - "path" - "strconv" + htemplate "html/template" + "io" + ttemplate "text/template" "github.com/aymerick/douceur/inliner" "github.com/gobuffalo/packr/v2" "github.com/pkg/errors" - "gopkg.in/gomail.v2" ) -// Email represents email to be sent out -type Email struct { - from string - to []string - subject string - Body string -} - var ( - // T is a map of templates - T = map[string]*template.Template{} // EmailTypeResetPassword represents a reset password email EmailTypeResetPassword = "reset_password" // EmailTypeWeeklyDigest represents a weekly digest email EmailTypeWeeklyDigest = "digest" // EmailTypeEmailVerification represents an email verification email - EmailTypeEmailVerification = "email_verification" + EmailTypeEmailVerification = "verify_email" + // EmailTypeWelcome represents an welcome email + EmailTypeWelcome = "welcome" ) -func getTemplatePath(templateDirPath, filename string) string { - return path.Join(templateDirPath, fmt.Sprintf("%s.html", filename)) +var ( + // EmailKindHTML is the type of html email + EmailKindHTML = "html" + // EmailKindHTML is the type of text email + EmailKindText = "text" +) + +// template is the common interface shared between Template from +// html/template and text/template +type template interface { + Execute(wr io.Writer, data interface{}) error } -// initTemplate returns a template instance by parsing the template with the +// Templates holds the parsed email templates +type Templates map[string]template + +func getTemplateKey(name, kind string) string { + return fmt.Sprintf("%s.%s", name, kind) +} + +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) + } + + return t, nil +} + +func (tmpl Templates) set(name, kind string, t template) { + key := getTemplateKey(name, kind) + tmpl[key] = t +} + +// NewTemplates initializes templates +func NewTemplates(srcDir *string) Templates { + var box *packr.Box + + if srcDir != nil { + box = packr.Folder(*srcDir) + } else { + box = packr.New("emailTemplates", "./templates/src") + } + + weeklyDigestHTML, err := initHTMLTmpl(box, EmailTypeWeeklyDigest) + if err != nil { + panic(errors.Wrap(err, "initializing weekly digest template")) + } + welcomeText, err := initTextTmpl(box, EmailTypeWelcome) + if err != nil { + panic(errors.Wrap(err, "initializing welcome template")) + } + verifyEmailText, err := initTextTmpl(box, EmailTypeEmailVerification) + if err != nil { + panic(errors.Wrap(err, "initializing email verification template")) + } + passwordResetText, err := initTextTmpl(box, EmailTypeResetPassword) + if err != nil { + panic(errors.Wrap(err, "initializing password reset template")) + } + + T := Templates{} + T.set(EmailTypeWeeklyDigest, EmailKindHTML, weeklyDigestHTML) + T.set(EmailTypeResetPassword, EmailKindText, passwordResetText) + T.set(EmailTypeEmailVerification, EmailKindText, verifyEmailText) + T.set(EmailTypeWelcome, EmailKindText, welcomeText) + + return T +} + +// initHTMLTmpl returns a template instance by parsing the template with the // given name along with partials -func initTemplate(box *packr.Box, templateName string) (*template.Template, error) { +func initHTMLTmpl(box *packr.Box, templateName string) (template, error) { filename := fmt.Sprintf("%s.html", templateName) content, err := box.FindString(filename) @@ -74,7 +131,7 @@ func initTemplate(box *packr.Box, templateName string) (*template.Template, erro return nil, errors.Wrap(err, "reading footer template") } - t := template.New(templateName) + t := htemplate.New(templateName) if _, err = t.Parse(content); err != nil { return nil, errors.Wrap(err, "parsing template") } @@ -88,114 +145,44 @@ func initTemplate(box *packr.Box, templateName string) (*template.Template, erro return t, nil } -// InitTemplates initializes templates -func InitTemplates(srcDir *string) { - var box *packr.Box +// initTextTmpl returns a template instance by parsing the template with the given name +func initTextTmpl(box *packr.Box, templateName string) (template, error) { + filename := fmt.Sprintf("%s.txt", templateName) - if srcDir != nil { - box = packr.Folder(*srcDir) - } else { - box = packr.New("emailTemplates", "./templates/src") + content, err := box.FindString(filename) + if err != nil { + return nil, errors.Wrap(err, "reading template") } - weeklyDigestTmpl, err := initTemplate(box, EmailTypeWeeklyDigest) - if err != nil { - panic(errors.Wrap(err, "initializing weekly digest template")) - } - emailVerificationTmpl, err := initTemplate(box, EmailTypeEmailVerification) - if err != nil { - panic(errors.Wrap(err, "initializing email verification template")) - } - passwowrdResetTmpl, err := initTemplate(box, EmailTypeResetPassword) - if err != nil { - panic(errors.Wrap(err, "initializing password reset template")) + t := ttemplate.New(templateName) + if _, err = t.Parse(content); err != nil { + return nil, errors.Wrap(err, "parsing template") } - T[EmailTypeWeeklyDigest] = weeklyDigestTmpl - T[EmailTypeEmailVerification] = emailVerificationTmpl - T[EmailTypeResetPassword] = passwowrdResetTmpl + return t, nil } -// NewEmail returns a pointer to an Email struct with the given data -func NewEmail(from string, to []string, subject string) *Email { - return &Email{ - from: from, - to: to, - subject: subject, - } -} - -type dialerParams struct { - Host string - Port int - Username string - Password string -} - -func getSMTPParams() (*dialerParams, error) { - portStr := os.Getenv("SmtpPort") - port, err := strconv.Atoi(portStr) +// Execute executes the template with the given name with the givn data +func (tmpl Templates) Execute(name, kind string, data interface{}) (string, error) { + t, err := tmpl.get(name, kind) if err != nil { - return nil, errors.Wrap(err, "parsing SMTP port") - } - - p := &dialerParams{ - Host: os.Getenv("SmtpHost"), - Port: port, - Username: os.Getenv("SmtpUsername"), - Password: os.Getenv("SmtpPassword"), - } - - return p, nil -} - -// Send sends the email -func (e *Email) Send() error { - // If not production, never actually send an email - if os.Getenv("GO_ENV") != "PRODUCTION" { - fmt.Println("Not sending email because not production") - fmt.Println(e.subject, e.to, e.from) - // fmt.Println("Body", e.Body) - return nil - } - - m := gomail.NewMessage() - m.SetHeader("From", e.from) - m.SetHeader("To", e.to...) - m.SetHeader("Subject", e.subject) - m.SetBody("text/html", e.Body) - - p, err := getSMTPParams() - if err != nil { - return errors.Wrap(err, "getting dialer params") - } - - d := gomail.NewPlainDialer(p.Host, p.Port, p.Username, p.Password) - if err := d.DialAndSend(m); err != nil { - return err - } - - return nil -} - -// ParseTemplate sets the email body by parsing the file at the given path, -// evaluating all partials and inlining CSS rules -func (e *Email) ParseTemplate(templateName string, data interface{}) error { - t := T[templateName] - if t == nil { - return errors.Errorf("unsupported template '%s'", templateName) + 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") + return "", errors.Wrap(err, "executing the template") } - html, err := inliner.Inline(buf.String()) - if err != nil { - return errors.Wrap(err, "inlining the css rules") + // If HTML email, inline the CSS rules + if kind == EmailKindHTML { + html, err := inliner.Inline(buf.String()) + if err != nil { + return "", errors.Wrap(err, "inlining the css rules") + } + + return html, nil } - e.Body = html - return nil + return buf.String(), nil } diff --git a/pkg/server/mailer/mailer_test.go b/pkg/server/mailer/mailer_test.go index d87b158d..03dc22ef 100644 --- a/pkg/server/mailer/mailer_test.go +++ b/pkg/server/mailer/mailer_test.go @@ -24,17 +24,9 @@ import ( "strings" "testing" - "github.com/dnote/dnote/pkg/server/testutils" "github.com/pkg/errors" ) -func init() { - testutils.InitTestDB() - - templatePath := os.Getenv("DNOTE_TEST_EMAIL_TEMPLATE_DIR") - InitTemplates(&templatePath) -} - func TestEmailVerificationEmail(t *testing.T) { testCases := []struct { token string @@ -50,24 +42,24 @@ func TestEmailVerificationEmail(t *testing.T) { }, } + tmplPath := os.Getenv("DNOTE_TEST_EMAIL_TEMPLATE_DIR") + tmpl := NewTemplates(&tmplPath) + for _, tc := range testCases { t.Run(fmt.Sprintf("with WebURL %s", tc.webURL), func(t *testing.T) { - m := NewEmail("alice@example.com", []string{"bob@example.com"}, "Test email") - dat := EmailVerificationTmplData{ - Subject: "Test email verification email", - Token: tc.token, - WebURL: tc.webURL, + Token: tc.token, + WebURL: tc.webURL, } - err := m.ParseTemplate(EmailTypeEmailVerification, dat) + body, err := tmpl.Execute(EmailTypeEmailVerification, EmailKindText, dat) if err != nil { t.Fatal(errors.Wrap(err, "executing")) } - if ok := strings.Contains(m.Body, tc.webURL); !ok { + if ok := strings.Contains(body, tc.webURL); !ok { t.Errorf("email body did not contain %s", tc.webURL) } - if ok := strings.Contains(m.Body, tc.token); !ok { + if ok := strings.Contains(body, tc.token); !ok { t.Errorf("email body did not contain %s", tc.token) } }) @@ -89,24 +81,24 @@ func TestResetPasswordEmail(t *testing.T) { }, } + tmplPath := os.Getenv("DNOTE_TEST_EMAIL_TEMPLATE_DIR") + tmpl := NewTemplates(&tmplPath) + for _, tc := range testCases { t.Run(fmt.Sprintf("with WebURL %s", tc.webURL), func(t *testing.T) { - m := NewEmail("alice@example.com", []string{"bob@example.com"}, "Test email") - - dat := EmailVerificationTmplData{ - Subject: "Test reset passowrd email", - Token: tc.token, - WebURL: tc.webURL, + dat := EmailResetPasswordTmplData{ + Token: tc.token, + WebURL: tc.webURL, } - err := m.ParseTemplate(EmailTypeResetPassword, dat) + body, err := tmpl.Execute(EmailTypeResetPassword, EmailKindText, dat) if err != nil { t.Fatal(errors.Wrap(err, "executing")) } - if ok := strings.Contains(m.Body, tc.webURL); !ok { + if ok := strings.Contains(body, tc.webURL); !ok { t.Errorf("email body did not contain %s", tc.webURL) } - if ok := strings.Contains(m.Body, tc.token); !ok { + if ok := strings.Contains(body, tc.token); !ok { t.Errorf("email body did not contain %s", tc.token) } }) diff --git a/pkg/server/mailer/templates/main.go b/pkg/server/mailer/templates/main.go index 11a4b15a..7b04ebef 100644 --- a/pkg/server/mailer/templates/main.go +++ b/pkg/server/mailer/templates/main.go @@ -63,38 +63,60 @@ func (c Context) digestHandler(w http.ResponseWriter, r *http.Request) { } now := time.Now() - email, err := repetition.BuildEmail(db, repetition.BuildEmailParams{ - Now: now, - User: user, - EmailAddr: "sung@getdnote.com", - Digest: digest, - Rule: rule, + _, body, err := repetition.BuildEmail(db, c.Tmpl, repetition.BuildEmailParams{ + Now: now, + User: user, + Digest: digest, + Rule: rule, }) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } - body := email.Body w.Write([]byte(body)) } -func (c Context) emailVerificationHandler(w http.ResponseWriter, r *http.Request) { - data := struct { - Subject string - Token string - }{ - "Verify your email", - "testToken", +func (c Context) passwordResetHandler(w http.ResponseWriter, r *http.Request) { + data := mailer.EmailResetPasswordTmplData{ + AccountEmail: "alice@example.com", + Token: "testToken", + WebURL: "http://localhost:3000", } - email := mailer.NewEmail("noreply@getdnote.com", []string{"sung@getdnote.com"}, "Reset your password") - err := email.ParseTemplate(mailer.EmailTypeEmailVerification, data) + body, err := c.Tmpl.Execute(mailer.EmailTypeResetPassword, mailer.EmailKindText, data) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Write([]byte(body)) +} + +func (c Context) emailVerificationHandler(w http.ResponseWriter, r *http.Request) { + data := mailer.EmailVerificationTmplData{ + Token: "testToken", + WebURL: "http://localhost:3000", + } + body, err := c.Tmpl.Execute(mailer.EmailTypeEmailVerification, mailer.EmailKindText, data) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Write([]byte(body)) +} + +func (c Context) welcomeHandler(w http.ResponseWriter, r *http.Request) { + data := mailer.WelcomeTmplData{ + AccountEmail: "alice@example.com", + WebURL: "http://localhost:3000", + } + body, err := c.Tmpl.Execute(mailer.EmailTypeWelcome, mailer.EmailKindText, data) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } - body := email.Body w.Write([]byte(body)) } @@ -111,7 +133,8 @@ func init() { // Context is a context holding global information type Context struct { - DB *gorm.DB + DB *gorm.DB + Tmpl mailer.Templates } func main() { @@ -124,14 +147,15 @@ func main() { }) defer db.Close() - mailer.InitTemplates(nil) - log.Println("Email template development server running on http://127.0.0.1:2300") - ctx := Context{DB: db} + tmpl := mailer.NewTemplates(nil) + ctx := Context{DB: db, Tmpl: tmpl} http.HandleFunc("/", ctx.homeHandler) http.HandleFunc("/digest", ctx.digestHandler) http.HandleFunc("/email-verification", ctx.emailVerificationHandler) + http.HandleFunc("/password-reset", ctx.passwordResetHandler) + http.HandleFunc("/welcome", ctx.welcomeHandler) log.Fatal(http.ListenAndServe(":2300", nil)) } diff --git a/pkg/server/mailer/templates/src/email_verification.html b/pkg/server/mailer/templates/src/email_verification.html deleted file mode 100644 index f6ccf01a..00000000 --- a/pkg/server/mailer/templates/src/email_verification.html +++ /dev/null @@ -1,362 +0,0 @@ - - - - - - {{ .Subject }} - - - - - - - - -
-
- You have requested to reset your password on Dnote. - - {{ template "header" }} - - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - -
- Hello. -
- Please click the link below to verify your email. This link will expire in 30 minutes. -
- - - - - - -
- - - - - - -
- Verify email -
-
-
- Alternatively you can manually go to the following URL: {{ .WebURL }}/verify-email/{{ .Token }} -
- — Dnote -
-
- - - {{ template "footer" . }} - - - -
-
 
- - diff --git a/pkg/server/mailer/templates/src/reset_password.html b/pkg/server/mailer/templates/src/reset_password.html deleted file mode 100644 index 682dc27b..00000000 --- a/pkg/server/mailer/templates/src/reset_password.html +++ /dev/null @@ -1,352 +0,0 @@ - - - - - - {{ .Subject }} - - - - - - {{ template "header" }} - - - - - -
-
- - - You have requested to reset your password on Dnote. - - - - - - - - -
- - - - - - - - - - - - - - - - -
- You have requested to reset your password on Dnote. -
- Here is the link to reset your password. -
- - - - - - -
- - - - - - -
- Reset Password -
-
-
- -
- - - {{ template "footer" . }} - - - -
-
 
- - diff --git a/pkg/server/mailer/templates/src/reset_password.txt b/pkg/server/mailer/templates/src/reset_password.txt new file mode 100644 index 00000000..a66c0ba8 --- /dev/null +++ b/pkg/server/mailer/templates/src/reset_password.txt @@ -0,0 +1,9 @@ +You are receiving this because you (or someone else) requested to reset the password of the '{{ .AccountEmail }}' Dnote account. + +Please click on the following link, or paste this into your browser to complete the process: + + {{ .WebURL }}/password-reset/{{ .Token }} + +You can reply to this message, if you have questions. + +- Sung (Maker of Dnote) diff --git a/pkg/server/mailer/templates/src/verify_email.txt b/pkg/server/mailer/templates/src/verify_email.txt new file mode 100644 index 00000000..8e40bf89 --- /dev/null +++ b/pkg/server/mailer/templates/src/verify_email.txt @@ -0,0 +1,9 @@ +Hi. + +Welcome to Dnote! To verify your email so that you can automate spaced reptition, visit the following link: + + {{ .WebURL }}/verify-email/{{ .Token }} + +Thanks for using my software. + +- Sung (Maker of Dnote) diff --git a/pkg/server/mailer/templates/src/welcome.txt b/pkg/server/mailer/templates/src/welcome.txt new file mode 100644 index 00000000..c7e60b59 --- /dev/null +++ b/pkg/server/mailer/templates/src/welcome.txt @@ -0,0 +1,16 @@ +Hi, welcome to Dnote. + +Dnote is a simple personal knowledge base just for you. It helps you quickly capture new information and retain them. + +YOUR ACCOUNT + +Your {{ .WebURL }} account is "{{ .AccountEmail }}". Log in at {{ .WebURL }}/login +If you ever forget your password, you can reset it at {{ .WebURL }}/password-reset + +SOURCE CODE + +Dnote is open source and you can see the source code at https://github.com/dnote/dnote + +Feel free to reply anytime. Thanks for using my software. + +- Sung (Maker of Dnote) diff --git a/pkg/server/mailer/types.go b/pkg/server/mailer/types.go index b4604d73..ea05b6de 100644 --- a/pkg/server/mailer/types.go +++ b/pkg/server/mailer/types.go @@ -61,14 +61,19 @@ type DigestTmplData struct { // EmailVerificationTmplData is a template data for email verification emails type EmailVerificationTmplData struct { - Subject string - Token string - WebURL string + Token string + WebURL string } // EmailResetPasswordTmplData is a template data for reset password emails type EmailResetPasswordTmplData struct { - Subject string - Token string - WebURL string + AccountEmail string + Token string + WebURL string +} + +// WelcomeTmplData is a template data for welcome emails +type WelcomeTmplData struct { + AccountEmail string + WebURL string } diff --git a/pkg/server/main.go b/pkg/server/main.go index a1df6537..d85bba6a 100644 --- a/pkg/server/main.go +++ b/pkg/server/main.go @@ -110,26 +110,40 @@ func initDB() *gorm.DB { return db } -func initApp(db *gorm.DB) handlers.App { +func initApp() handlers.App { + db := initDB() + return handlers.App{ DB: db, Clock: clock.New(), StripeAPIBackend: nil, + EmailTemplates: mailer.NewTemplates(nil), + EmailBackend: &mailer.SimpleBackendImplementation{}, WebURL: os.Getenv("WebURL"), } } -func startCmd() { - db := initDB() - defer db.Close() +func runJob(a handlers.App) error { + jobRunner, err := job.NewRunner(a.DB, a.Clock, a.EmailTemplates, a.EmailBackend, a.WebURL) + if err != nil { + return errors.Wrap(err, "getting a job runner") + } + if err := jobRunner.Do(); err != nil { + return errors.Wrap(err, "running job") + } - app := initApp(db) - mailer.InitTemplates(nil) + return nil +} + +func startCmd() { + app := initApp() + defer app.DB.Close() if err := database.Migrate(app.DB); err != nil { panic(errors.Wrap(err, "running migrations")) } - if err := job.Run(db); err != nil { + + if err := runJob(app); err != nil { panic(errors.Wrap(err, "running job")) } @@ -147,7 +161,7 @@ func versionCmd() { } func rootCmd() { - fmt.Printf(`Dnote Server - A simple notebook for developers + fmt.Printf(`Dnote Server - A simple personal knowledge base Usage: dnote-server [command] diff --git a/pkg/server/testutils/main.go b/pkg/server/testutils/main.go index 26c0567e..4e19695b 100644 --- a/pkg/server/testutils/main.go +++ b/pkg/server/testutils/main.go @@ -28,6 +28,7 @@ import ( "net/http/httptest" "os" "strings" + "sync" "testing" "time" @@ -282,3 +283,32 @@ func MustRespondJSON(t *testing.T, w http.ResponseWriter, i interface{}, message t.Fatal(message) } } + +// MockEmail is a mock email data +type MockEmail struct { + Subject string + From string + To []string + Body string +} + +// MockEmailbackendImplementation is an email backend that simply discards the emails +type MockEmailbackendImplementation struct { + mu sync.RWMutex + Emails []MockEmail +} + +// Queue is an implementation of Backend.Queue. +func (b *MockEmailbackendImplementation) Queue(subject, from string, to []string, contentType, body string) error { + b.mu.Lock() + defer b.mu.Unlock() + + b.Emails = append(b.Emails, MockEmail{ + Subject: subject, + From: from, + To: to, + Body: body, + }) + + return nil +} diff --git a/pkg/watcher/main.go b/pkg/watcher/main.go index 426b61da..87604fd7 100644 --- a/pkg/watcher/main.go +++ b/pkg/watcher/main.go @@ -23,6 +23,7 @@ import ( "log" "os" "os/exec" + "os/signal" "strings" "syscall" "time" @@ -71,6 +72,13 @@ func init() { } } +func killCmdProcess(cmd *exec.Cmd) { + pgid, err := syscall.Getpgid(cmd.Process.Pid) + if err == nil { + syscall.Kill(-pgid, syscall.SIGKILL) + } +} + func main() { w := watcher.New() w.IgnoreHiddenFiles(true) @@ -88,10 +96,7 @@ func main() { // Killing the process here. if e != nil { - pgid, err := syscall.Getpgid(e.Process.Pid) - if err == nil { - syscall.Kill(-pgid, syscall.SIGKILL) - } + killCmdProcess(e) e.Wait() } @@ -122,6 +127,15 @@ func main() { e = execCmd(task, context) + // watch for quit signals and kill the child process + go func() { + signalChan := make(chan os.Signal, 1) + signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT) + <-signalChan + killCmdProcess(e) + os.Exit(0) + }() + log.Printf("watching %d files", len(w.WatchedFiles())) if err := w.Start(time.Millisecond * 1000); err != nil { log.Fatalln(errors.Wrap(err, "starting watcher")) diff --git a/scripts/server/test.sh b/scripts/server/test.sh index 5795c6ed..62497108 100755 --- a/scripts/server/test.sh +++ b/scripts/server/test.sh @@ -8,13 +8,16 @@ pushd "$dir/../../pkg/server" export DNOTE_TEST_EMAIL_TEMPLATE_DIR="$dir/../../pkg/server/mailer/templates/src" +function run_test { + go test ./... -cover -p 1 +} + if [ "${WATCH-false}" == true ]; then set +e - while inotifywait --exclude .swp -e modify -r .; do go test ./... -cover -p 1; done; + while inotifywait --exclude .swp -e modify -r .; do run_test; done; set -e else - # go test ./... -cover -p 1 - go test ./... -cover -p 1 + run_test fi popd