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 }}
-
-
-
-
-
-
-
-
-
- {{ template "header" }}
-
-
-
-
-
-
-
-
-
- |
- Hello.
- |
-
-
- |
- Please click the link below to verify your email. This link will expire in 30 minutes.
- |
-
-
- |
-
-
- |
-
- |
-
-
-
- |
- 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.
- |
-
-
- |
-
-
- |
- Here is the link to reset your 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