mirror of
https://github.com/dnote/dnote
synced 2026-03-14 22:45:50 +01:00
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:
parent
4adb7764ed
commit
292dc7d515
29 changed files with 768 additions and 1031 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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==",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
81
pkg/server/job/job_test.go
Normal file
81
pkg/server/job/job_test.go
Normal 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")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
75
pkg/server/mailer/backend.go
Normal file
75
pkg/server/mailer/backend.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> </td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -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> </td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
9
pkg/server/mailer/templates/src/reset_password.txt
Normal file
9
pkg/server/mailer/templates/src/reset_password.txt
Normal 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)
|
||||
9
pkg/server/mailer/templates/src/verify_email.txt
Normal file
9
pkg/server/mailer/templates/src/verify_email.txt
Normal 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)
|
||||
16
pkg/server/mailer/templates/src/welcome.txt
Normal file
16
pkg/server/mailer/templates/src/welcome.txt
Normal 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)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue