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
This commit is contained in:
Sung Won Cho 2019-11-29 17:59:04 +08:00 committed by GitHub
commit 292dc7d515
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 768 additions and 1031 deletions

View file

@ -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

View file

@ -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)
}
}

View file

@ -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==",

View file

@ -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()

View file

@ -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

View file

@ -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")
})
}
}

View file

@ -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"))

View file

@ -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)

View file

@ -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) {

View file

@ -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.

View file

@ -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

View file

@ -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
}

View file

@ -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")
})
}
}

View file

@ -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(&notif).Error; err != nil {
if err := p.DB.Create(&notif).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")

View file

@ -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))

View file

@ -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
}

View file

@ -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
}

View file

@ -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)
}
})

View file

@ -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))
}

View file

@ -1,362 +0,0 @@
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>{{ .Subject }}</title>
<style>
/* -------------------------------------
GLOBAL RESETS
------------------------------------- */
img {
border: none;
-ms-interpolation-mode: bicubic;
max-width: 100%; }
body {
background-color: #f6f6f6;
font-family: sans-serif;
-webkit-font-smoothing: antialiased;
font-size: 14px;
line-height: 1.4;
margin: 0;
padding: 0;
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%; }
table {
border-collapse: separate;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
width: 100%; }
table td {
font-family: sans-serif;
font-size: 14px;
vertical-align: top; }
/* -------------------------------------
BODY & CONTAINER
------------------------------------- */
.body {
background-color: #f6f6f6;
width: 100%; }
/* Set a max-width, and make it display as block so it will automatically stretch to that width, but will also shrink down on a phone or something */
.container {
display: block;
Margin: 0 auto !important;
/* makes it centered */
max-width: 580px;
padding: 10px;
width: 580px; }
/* This should also be a block element, so that it will fill 100% of the .container */
.content {
box-sizing: border-box;
display: block;
Margin: 0 auto;
max-width: 580px;
padding: 10px; }
/* -------------------------------------
HEADER, FOOTER, MAIN
------------------------------------- */
.main {
background: #fff;
border-radius: 3px;
width: 100%; }
.wrapper {
box-sizing: border-box;
padding: 20px; }
.footer {
clear: both;
padding-top: 10px;
text-align: center;
width: 100%; }
.footer td,
.footer p,
.footer span,
.footer a {
color: #999999;
font-size: 12px;
text-align: center; }
/* -------------------------------------
TYPOGRAPHY
------------------------------------- */
h1,
h2,
h3,
h4 {
color: #000000;
font-family: sans-serif;
font-weight: 400;
line-height: 1.4;
margin: 0;
Margin-bottom: 30px; }
h1 {
font-size: 35px;
font-weight: 300;
text-align: center;
text-transform: capitalize; }
p,
ul,
ol {
font-family: sans-serif;
font-size: 14px;
font-weight: normal;
margin: 0;
Margin-bottom: 15px; }
p li,
ul li,
ol li {
list-style-position: inside;
margin-left: 5px; }
a {
color: #3498db;
text-decoration: underline; }
/* -------------------------------------
BUTTONS
------------------------------------- */
.btn {
box-sizing: border-box;
width: 100%; }
.btn > tbody > tr > td {
padding-bottom: 15px; }
.btn table {
width: auto; }
.btn table td {
background-color: #ffffff;
border-radius: 5px;
text-align: center; }
.btn a {
background-color: #ffffff;
border: solid 1px #333745;
border-radius: 5px;
box-sizing: border-box;
color: #333745;
cursor: pointer;
display: inline-block;
font-size: 14px;
font-weight: bold;
margin: 0;
padding: 12px 25px;
text-decoration: none;
text-transform: capitalize; }
.btn-primary table td {
background-color: #333745; }
.btn-primary a {
background-color: #333745;
border-color: #333745;
color: #ffffff; }
/* -------------------------------------
OTHER STYLES THAT MIGHT BE USEFUL
------------------------------------- */
.last {
margin-bottom: 0; }
.first {
margin-top: 0; }
.align-center {
text-align: center; }
.align-right {
text-align: right; }
.align-left {
text-align: left; }
.clear {
clear: both; }
.mt0 {
margin-top: 0; }
.mb0 {
margin-bottom: 0; }
.preheader {
color: transparent;
display: none;
height: 0;
max-height: 0;
max-width: 0;
opacity: 0;
overflow: hidden;
mso-hide: all;
visibility: hidden;
width: 0; }
.powered-by a {
text-decoration: none; }
hr {
border: 0;
border-bottom: 1px solid #f6f6f6;
Margin: 20px 0; }
/* -------------------------------------
RESPONSIVE AND MOBILE FRIENDLY STYLES
------------------------------------- */
@media only screen and (max-width: 620px) {
table[class=body] h1 {
font-size: 28px !important;
margin-bottom: 10px !important; }
table[class=body] p,
table[class=body] ul,
table[class=body] ol,
table[class=body] td,
table[class=body] span,
table[class=body] a {
font-size: 16px !important; }
table[class=body] .wrapper,
table[class=body] .article {
padding: 10px !important; }
table[class=body] .content {
padding: 0 !important; }
table[class=body] .container {
padding: 0 !important;
width: 100% !important; }
table[class=body] .main {
border-left-width: 0 !important;
border-radius: 0 !important;
border-right-width: 0 !important; }
table[class=body] .btn table {
width: 100% !important; }
table[class=body] .btn a {
width: 100% !important; }
table[class=body] .img-responsive {
height: auto !important;
max-width: 100% !important;
width: auto !important; }}
/* -------------------------------------
PRESERVE THESE STYLES IN THE HEAD
------------------------------------- */
@media all {
.ExternalClass {
width: 100%; }
.ExternalClass,
.ExternalClass p,
.ExternalClass span,
.ExternalClass font,
.ExternalClass td,
.ExternalClass div {
line-height: 100%; }
.apple-link a {
color: inherit !important;
font-family: inherit !important;
font-size: inherit !important;
font-weight: inherit !important;
line-height: inherit !important;
text-decoration: none !important; }
.btn-primary table td:hover {
background-color: #42475a !important; }
.btn-primary a:hover {
background-color: #42475a !important;
border-color: #42475a !important; } }
/* custom */
.spacer td {
padding-top: 7px;
}
.text-center {
text-align: center;
}
.content-block {
padding: 0 0 20px;
}
</style>
</head>
<body class="">
<table border="0" cellpadding="0" cellspacing="0" class="body">
<tr>
<td class="container">
<div class="content">
<span class="preheader">You have requested to reset your password on Dnote.</span>
{{ template "header" }}
<!-- START CENTERED WHITE CONTAINER -->
<table class="main">
<!-- START MAIN CONTENT AREA -->
<tr>
<td class="wrapper">
<table border="0" cellpadding="0" cellspacing="0">
<tr>
<td class="content-block">
Hello.
</td>
</tr>
<tr>
<td>
Please click the link below to verify your email. This link will expire in 30 minutes.
</td>
</tr>
<tr class="spacer">
<td></td>
</tr>
<tr>
<td>
<table border="0" cellpadding="0" cellspacing="0" class="btn btn-primary">
<tbody>
<tr>
<td align="left">
<table border="0" cellpadding="0" cellspacing="0">
<tbody>
<tr>
<td>
<a href="{{ .WebURL }}/verify-email/{{ .Token }}" target="_blank">Verify email</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td class="content-block">
Alternatively you can manually go to the following URL: <a href="{{ .WebURL }}/verify-email/{{ .Token }}">{{ .WebURL }}/verify-email/{{ .Token }}</a>
</td>
</tr>
<tr>
<td class="content-block">
— Dnote
</td>
</tr>
</table>
</td>
</tr>
<!-- END MAIN CONTENT AREA -->
</table>
<!-- START FOOTER -->
{{ template "footer" . }}
<!-- END FOOTER -->
<!-- END CENTERED WHITE CONTAINER -->
</div>
</td>
<td>&nbsp;</td>
</tr>
</table>
</body>
</html>

View file

@ -1,352 +0,0 @@
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>{{ .Subject }}</title>
<style>
/* -------------------------------------
GLOBAL RESETS
------------------------------------- */
img {
border: none;
-ms-interpolation-mode: bicubic;
max-width: 100%; }
body {
background-color: #f6f6f6;
font-family: sans-serif;
-webkit-font-smoothing: antialiased;
font-size: 14px;
line-height: 1.4;
margin: 0;
padding: 0;
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%; }
table {
border-collapse: separate;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
width: 100%; }
table td {
font-family: sans-serif;
font-size: 14px;
vertical-align: top; }
/* -------------------------------------
BODY & CONTAINER
------------------------------------- */
.body {
background-color: #f6f6f6;
width: 100%; }
/* Set a max-width, and make it display as block so it will automatically stretch to that width, but will also shrink down on a phone or something */
.container {
display: block;
Margin: 0 auto !important;
/* makes it centered */
max-width: 580px;
padding: 10px;
width: 580px; }
/* This should also be a block element, so that it will fill 100% of the .container */
.content {
box-sizing: border-box;
display: block;
Margin: 0 auto;
max-width: 580px;
padding: 10px; }
/* -------------------------------------
HEADER, FOOTER, MAIN
------------------------------------- */
.main {
background: #fff;
border-radius: 3px;
width: 100%; }
.wrapper {
box-sizing: border-box;
padding: 20px; }
.footer {
clear: both;
padding-top: 10px;
text-align: center;
width: 100%; }
.footer td,
.footer p,
.footer span,
.footer a {
color: #999999;
font-size: 12px;
text-align: center; }
/* -------------------------------------
TYPOGRAPHY
------------------------------------- */
h1,
h2,
h3,
h4 {
color: #000000;
font-family: sans-serif;
font-weight: 400;
line-height: 1.4;
margin: 0;
Margin-bottom: 30px; }
h1 {
font-size: 35px;
font-weight: 300;
text-align: center;
text-transform: capitalize; }
p,
ul,
ol {
font-family: sans-serif;
font-size: 14px;
font-weight: normal;
margin: 0;
Margin-bottom: 15px; }
p li,
ul li,
ol li {
list-style-position: inside;
margin-left: 5px; }
a {
color: #3498db;
text-decoration: underline; }
/* -------------------------------------
BUTTONS
------------------------------------- */
.btn {
box-sizing: border-box;
width: 100%; }
.btn > tbody > tr > td {
padding-bottom: 15px; }
.btn table {
width: auto; }
.btn table td {
background-color: #ffffff;
border-radius: 5px;
text-align: center; }
.btn a {
background-color: #ffffff;
border: solid 1px #333745;
border-radius: 5px;
box-sizing: border-box;
color: #333745;
cursor: pointer;
display: inline-block;
font-size: 14px;
font-weight: bold;
margin: 0;
padding: 12px 25px;
text-decoration: none;
text-transform: capitalize; }
.btn-primary table td {
background-color: #333745; }
.btn-primary a {
background-color: #333745;
border-color: #333745;
color: #ffffff; }
/* -------------------------------------
OTHER STYLES THAT MIGHT BE USEFUL
------------------------------------- */
.last {
margin-bottom: 0; }
.first {
margin-top: 0; }
.align-center {
text-align: center; }
.align-right {
text-align: right; }
.align-left {
text-align: left; }
.clear {
clear: both; }
.mt0 {
margin-top: 0; }
.mb0 {
margin-bottom: 0; }
.preheader {
color: transparent;
display: none;
height: 0;
max-height: 0;
max-width: 0;
opacity: 0;
overflow: hidden;
mso-hide: all;
visibility: hidden;
width: 0; }
.powered-by a {
text-decoration: none; }
hr {
border: 0;
border-bottom: 1px solid #f6f6f6;
Margin: 20px 0; }
/* -------------------------------------
RESPONSIVE AND MOBILE FRIENDLY STYLES
------------------------------------- */
@media only screen and (max-width: 620px) {
table[class=body] h1 {
font-size: 28px !important;
margin-bottom: 10px !important; }
table[class=body] p,
table[class=body] ul,
table[class=body] ol,
table[class=body] td,
table[class=body] span,
table[class=body] a {
font-size: 16px !important; }
table[class=body] .wrapper,
table[class=body] .article {
padding: 10px !important; }
table[class=body] .content {
padding: 0 !important; }
table[class=body] .container {
padding: 0 !important;
width: 100% !important; }
table[class=body] .main {
border-left-width: 0 !important;
border-radius: 0 !important;
border-right-width: 0 !important; }
table[class=body] .btn table {
width: 100% !important; }
table[class=body] .btn a {
width: 100% !important; }
table[class=body] .img-responsive {
height: auto !important;
max-width: 100% !important;
width: auto !important; }}
/* -------------------------------------
PRESERVE THESE STYLES IN THE HEAD
------------------------------------- */
@media all {
.ExternalClass {
width: 100%; }
.ExternalClass,
.ExternalClass p,
.ExternalClass span,
.ExternalClass font,
.ExternalClass td,
.ExternalClass div {
line-height: 100%; }
.apple-link a {
color: inherit !important;
font-family: inherit !important;
font-size: inherit !important;
font-weight: inherit !important;
line-height: inherit !important;
text-decoration: none !important; }
.btn-primary table td:hover {
background-color: #42475a !important; }
.btn-primary a:hover {
background-color: #42475a !important;
border-color: #42475a !important; } }
/* custom */
.spacer td {
padding-top: 7px;
}
.text-center {
text-align: center;
}
</style>
</head>
<body class="">
<table border="0" cellpadding="0" cellspacing="0" class="body">
{{ template "header" }}
<tr>
<td class="container">
<div class="content">
<!-- START CENTERED WHITE CONTAINER -->
<span class="preheader">You have requested to reset your password on Dnote.</span>
<table class="main">
<!-- START MAIN CONTENT AREA -->
<tr>
<td class="wrapper">
<table border="0" cellpadding="0" cellspacing="0">
<tr>
<td>
You have requested to reset your password on Dnote.
</td>
</tr>
<tr class="spacer">
<td></td>
</tr>
<tr>
<td>
Here is the link to reset your password.
</td>
</tr>
<tr class="spacer">
<td></td>
</tr>
<tr>
<td>
<table border="0" cellpadding="0" cellspacing="0" class="btn btn-primary">
<tbody>
<tr>
<td align="left">
<table border="0" cellpadding="0" cellspacing="0">
<tbody>
<tr>
<td>
<a href="{{ .WebURL }}/password-reset/{{ .Token }}" target="_blank">Reset Password</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</table>
</td>
</tr>
<!-- END MAIN CONTENT AREA -->
</table>
<!-- START FOOTER -->
{{ template "footer" . }}
<!-- END FOOTER -->
<!-- END CENTERED WHITE CONTAINER -->
</div>
</td>
<td>&nbsp;</td>
</tr>
</table>
</body>
</html>

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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
}

View file

@ -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]

View file

@ -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
}

View file

@ -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"))

View file

@ -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