Allow to send email verification email

This commit is contained in:
Sung Won Cho 2022-04-23 15:58:32 +10:00
commit b6572a5d13
14 changed files with 35 additions and 485 deletions

View file

@ -75,7 +75,7 @@
.setting-row-summary {
display: flex;
flex-direction: column;
align-items: flex-start;
// align-items: flex-start;
@include breakpoint(md) {
flex-direction: row;
@ -109,11 +109,13 @@
.setting-right {
display: flex;
flex-direction: column;
word-break: break-all;
justify-content: space-between;
align-items: center;
@include breakpoint(md) {
flex-direction: row;
align-items: center;
}
}
@ -136,4 +138,8 @@
}
}
}
.email-verification-form {
margin-left: rem(12px);
}
}

View file

@ -673,37 +673,37 @@ func (u *Users) CreateEmailVerificationToken(w http.ResponseWriter, r *http.Requ
user := context.User(r.Context())
if user == nil {
handleHTMLError(w, r, app.ErrLoginRequired, "No authenticated user found", u.EmailVerificationView, vd)
handleHTMLError(w, r, app.ErrLoginRequired, "No authenticated user found", u.SettingView, vd)
return
}
var account database.Account
err := u.app.DB.Where("user_id = ?", user.ID).First(&account).Error
if err != nil {
handleHTMLError(w, r, err, "finding account", u.EmailVerificationView, vd)
handleHTMLError(w, r, err, "finding account", u.SettingView, vd)
return
}
if account.EmailVerified {
handleHTMLError(w, r, app.ErrEmailAlreadyVerified, "email is already verified.", u.EmailVerificationView, vd)
handleHTMLError(w, r, app.ErrEmailAlreadyVerified, "email is already verified.", u.SettingView, vd)
return
}
if account.Email.String == "" {
handleHTMLError(w, r, app.ErrEmailRequired, "email is empty.", u.EmailVerificationView, vd)
handleHTMLError(w, r, app.ErrEmailRequired, "email is empty.", u.SettingView, vd)
return
}
tok, err := token.Create(u.app.DB, account.UserID, database.TokenTypeEmailVerification)
if err != nil {
handleHTMLError(w, r, err, "saving token", u.EmailVerificationView, vd)
handleHTMLError(w, r, err, "saving token", u.SettingView, vd)
return
}
if err := u.app.SendVerificationEmail(account.Email.String, tok.Value); err != nil {
if errors.Cause(err) == mailer.ErrSMTPNotConfigured {
handleHTMLError(w, r, app.ErrInvalidSMTPConfig, "SMTP config is not configured correctly.", u.EmailVerificationView, vd)
handleHTMLError(w, r, app.ErrInvalidSMTPConfig, "SMTP config is not configured correctly.", u.SettingView, vd)
} else {
handleHTMLError(w, r, err, "sending verification email", u.EmailVerificationView, vd)
handleHTMLError(w, r, err, "sending verification email", u.SettingView, vd)
}
return

View file

@ -23,8 +23,6 @@ import (
"github.com/dnote/dnote/pkg/clock"
"github.com/dnote/dnote/pkg/server/config"
"github.com/dnote/dnote/pkg/server/job/remind"
"github.com/dnote/dnote/pkg/server/log"
"github.com/dnote/dnote/pkg/server/mailer"
"github.com/jinzhu/gorm"
"github.com/pkg/errors"
@ -102,7 +100,6 @@ func scheduleJob(c *cron.Cron, spec string, cmd func()) {
func (r *Runner) schedule(ch chan error) {
// Schedule jobs
cr := cron.New()
scheduleJob(cr, "0 8 * * *", func() { r.RemindNoRecentNotes() })
cr.Start()
ch <- nil
@ -128,26 +125,3 @@ func (r *Runner) Do() error {
return nil
}
// RemindNoRecentNotes remind users if no notes have been added recently
func (r *Runner) RemindNoRecentNotes() {
c := remind.Context{
DB: r.DB,
Clock: r.Clock,
EmailTmpl: r.EmailTmpl,
EmailBackend: r.EmailBackend,
Config: r.Config,
}
result, err := remind.DoInactive(c)
m := log.WithFields(log.Fields{
"success_count": result.SuccessCount,
"failed_user_ids": result.FailedUserIDs,
})
if err == nil {
m.Info("successfully processed no recent note reminder job")
} else {
m.ErrorWrap(err, "error processing no recent note reminder job")
}
}

View file

@ -1,210 +0,0 @@
/* Copyright (C) 2019, 2020, 2021 Monomax Software Pty Ltd
*
* This file is part of Dnote.
*
* Dnote is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
*/
package remind
import (
"github.com/dnote/dnote/pkg/clock"
"github.com/dnote/dnote/pkg/server/app"
"github.com/dnote/dnote/pkg/server/config"
"github.com/dnote/dnote/pkg/server/database"
"github.com/dnote/dnote/pkg/server/log"
"github.com/dnote/dnote/pkg/server/mailer"
"github.com/jinzhu/gorm"
"github.com/pkg/errors"
)
// Context holds data that repetition job needs in order to perform
type Context struct {
DB *gorm.DB
Clock clock.Clock
EmailTmpl mailer.Templates
EmailBackend mailer.Backend
Config config.Config
}
type inactiveUserInfo struct {
userID int
email string
sampleNoteUUID string
}
func (c *Context) sampleUserNote(userID int) (database.Note, error) {
var ret database.Note
// FIXME: ordering by random() requires a sequential scan on the whole table and does not scale
if err := c.DB.Where("user_id = ?", userID).Order("random() DESC").First(&ret).Error; err != nil {
return ret, errors.Wrap(err, "getting a random note")
}
return ret, nil
}
func (c *Context) getInactiveUserInfo() ([]inactiveUserInfo, error) {
ret := []inactiveUserInfo{}
threshold := c.Clock.Now().AddDate(0, 0, -14).Unix()
rows, err := c.DB.Raw(`
SELECT
notes.user_id AS user_id,
accounts.email,
SUM(
CASE
WHEN notes.created_at > to_timestamp(?) THEN 1
ELSE 0
END
) AS recent_note_count,
COUNT(*) AS total_note_count
FROM notes
INNER JOIN accounts ON accounts.user_id = notes.user_id
WHERE accounts.email IS NOT NULL AND accounts.email_verified IS TRUE
GROUP BY notes.user_id, accounts.email`, threshold).Rows()
if err != nil {
return ret, errors.Wrap(err, "executing note count SQL query")
}
defer rows.Close()
for rows.Next() {
var userID, recentNoteCount, totalNoteCount int
var email string
if err := rows.Scan(&userID, &email, &recentNoteCount, &totalNoteCount); err != nil {
return nil, errors.Wrap(err, "scanning a row")
}
if recentNoteCount == 0 && totalNoteCount > 0 {
note, err := c.sampleUserNote(userID)
if err != nil {
return nil, errors.Wrap(err, "sampling user note")
}
ret = append(ret, inactiveUserInfo{
userID: userID,
email: email,
sampleNoteUUID: note.UUID,
})
}
}
return ret, nil
}
func (c *Context) canNotify(info inactiveUserInfo) (bool, error) {
var pref database.EmailPreference
if err := c.DB.Where("user_id = ?", info.userID).First(&pref).Error; err != nil {
return false, errors.Wrap(err, "getting email preference")
}
if !pref.InactiveReminder {
return false, nil
}
var notif database.Notification
conn := c.DB.Where("user_id = ? AND type = ?", info.userID, mailer.EmailTypeInactiveReminder).Order("created_at DESC").First(&notif)
if conn.RecordNotFound() {
return true, nil
} else if err := conn.Error; err != nil {
return false, errors.Wrap(err, "checking cooldown")
}
t := c.Clock.Now().AddDate(0, 0, -14)
if notif.CreatedAt.Before(t) {
return true, nil
}
return false, nil
}
func (c *Context) process(info inactiveUserInfo) error {
ok, err := c.canNotify(info)
if err != nil {
return errors.Wrap(err, "checking if user can be notified")
}
if !ok {
return nil
}
sender, err := app.GetSenderEmail(c.Config, "noreply@getdnote.com")
if err != nil {
return errors.Wrap(err, "getting sender email")
}
tok, err := mailer.GetToken(c.DB, info.userID, database.TokenTypeEmailPreference)
if err != nil {
return errors.Wrap(err, "getting email token")
}
tmplData := mailer.InactiveReminderTmplData{
WebURL: c.Config.WebURL,
SampleNoteUUID: info.sampleNoteUUID,
Token: tok.Value,
}
body, err := c.EmailTmpl.Execute(mailer.EmailTypeInactiveReminder, mailer.EmailKindText, tmplData)
if err != nil {
return errors.Wrap(err, "executing inactive email template")
}
if err := c.EmailBackend.Queue("Your Dnote stopped growing", sender, []string{info.email}, mailer.EmailKindText, body); err != nil {
return errors.Wrap(err, "queueing email")
}
if err := c.DB.Create(&database.Notification{
Type: mailer.EmailTypeInactiveReminder,
UserID: info.userID,
}).Error; err != nil {
return errors.Wrap(err, "creating notification")
}
return nil
}
// Result holds the result of the job
type Result struct {
SuccessCount int
FailedUserIDs []int
}
// DoInactive sends reminder for users with no recent notes
func DoInactive(c Context) (Result, error) {
log.Info("performing reminder for no recent notes")
result := Result{}
items, err := c.getInactiveUserInfo()
if err != nil {
return result, errors.Wrap(err, "getting inactive user information")
}
log.WithFields(log.Fields{
"user_count": len(items),
}).Info("counted inactive users")
for _, item := range items {
err := c.process(item)
if err == nil {
result.SuccessCount = result.SuccessCount + 1
} else {
log.WithFields(log.Fields{
"user_id": item.userID,
}).ErrorWrap(err, "Could not process no recent notes reminder")
result.FailedUserIDs = append(result.FailedUserIDs, item.userID)
}
}
return result, nil
}

View file

@ -1,194 +0,0 @@
/* Copyright (C) 2019, 2020, 2021 Monomax Software Pty Ltd
*
* This file is part of Dnote.
*
* Dnote is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
*/
package remind
import (
"os"
"sort"
"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/testutils"
"github.com/pkg/errors"
)
func getTestContext(c clock.Clock, be *testutils.MockEmailbackendImplementation) Context {
emailTmplDir := os.Getenv("DNOTE_TEST_EMAIL_TEMPLATE_DIR")
con := Context{
DB: testutils.DB,
Clock: c,
EmailTmpl: mailer.NewTemplates(&emailTmplDir),
EmailBackend: be,
}
return con
}
func TestDoInactive(t *testing.T) {
defer testutils.ClearData(testutils.DB)
t1 := time.Now()
// u1 is an active user
u1 := testutils.SetupUserData()
a1 := testutils.SetupAccountData(u1, "alice@example.com", "pass1234")
testutils.MustExec(t, testutils.DB.Model(&a1).Update("email_verified", true), "setting email verified")
testutils.MustExec(t, testutils.DB.Save(&database.EmailPreference{UserID: u1.ID, InactiveReminder: true}), "preparing email preference")
b1 := database.Book{
UserID: u1.ID,
Label: "js",
}
testutils.MustExec(t, testutils.DB.Save(&b1), "preparing b1")
n1 := database.Note{
BookUUID: b1.UUID,
UserID: u1.ID,
}
testutils.MustExec(t, testutils.DB.Save(&n1), "preparing n1")
// u2 is an inactive user
u2 := testutils.SetupUserData()
a2 := testutils.SetupAccountData(u2, "bob@example.com", "pass1234")
testutils.MustExec(t, testutils.DB.Model(&a2).Update("email_verified", true), "setting email verified")
testutils.MustExec(t, testutils.DB.Save(&database.EmailPreference{UserID: u2.ID, InactiveReminder: true}), "preparing email preference")
b2 := database.Book{
UserID: u2.ID,
Label: "css",
}
testutils.MustExec(t, testutils.DB.Save(&b2), "preparing b2")
n2 := database.Note{
UserID: u2.ID,
BookUUID: b2.UUID,
}
testutils.MustExec(t, testutils.DB.Save(&n2), "preparing n2")
testutils.MustExec(t, testutils.DB.Model(&n2).Update("created_at", t1.AddDate(0, 0, -15)), "preparing n2")
// u3 is an inactive user with inactive alert email preference disabled
u3 := testutils.SetupUserData()
a3 := testutils.SetupAccountData(u3, "alice@example.com", "pass1234")
testutils.MustExec(t, testutils.DB.Model(&a3).Update("email_verified", true), "setting email verified")
emailPref3 := database.EmailPreference{UserID: u3.ID}
testutils.MustExec(t, testutils.DB.Save(&emailPref3), "preparing email preference")
testutils.MustExec(t, testutils.DB.Model(&emailPref3).Update(map[string]interface{}{"inactive_reminder": false}), "updating email preference")
b3 := database.Book{
UserID: u3.ID,
Label: "js",
}
testutils.MustExec(t, testutils.DB.Save(&b3), "preparing b3")
n3 := database.Note{
BookUUID: b3.UUID,
UserID: u3.ID,
}
testutils.MustExec(t, testutils.DB.Save(&n3), "preparing n3")
testutils.MustExec(t, testutils.DB.Model(&n3).Update("created_at", t1.AddDate(0, 0, -15)), "preparing n3")
c := clock.NewMock()
c.SetNow(t1)
be := &testutils.MockEmailbackendImplementation{}
con := getTestContext(c, be)
if _, err := DoInactive(con); err != nil {
t.Fatal(errors.Wrap(err, "performing"))
}
assert.Equalf(t, len(be.Emails), 1, "email queue count mismatch")
assert.DeepEqual(t, be.Emails[0].To, []string{a2.Email.String}, "email address mismatch")
}
func TestDoInactive_Cooldown(t *testing.T) {
defer testutils.ClearData(testutils.DB)
// setup sets up an inactive user
setup := func(t *testing.T, now time.Time, email string) database.User {
u := testutils.SetupUserData()
a := testutils.SetupAccountData(u, email, "pass1234")
testutils.MustExec(t, testutils.DB.Model(&a).Update("email_verified", true), "setting email verified")
testutils.MustExec(t, testutils.DB.Save(&database.EmailPreference{UserID: u.ID, InactiveReminder: true}), "preparing email preference")
b := database.Book{
UserID: u.ID,
Label: "css",
}
testutils.MustExec(t, testutils.DB.Save(&b), "preparing book")
n := database.Note{
UserID: u.ID,
BookUUID: b.UUID,
}
testutils.MustExec(t, testutils.DB.Save(&n), "preparing note")
testutils.MustExec(t, testutils.DB.Model(&n).Update("created_at", now.AddDate(0, 0, -15)), "preparing note")
return u
}
// Set up
now := time.Now()
setup(t, now, "alice@example.com")
bob := setup(t, now, "bob@example.com")
bobNotif := database.Notification{Type: mailer.EmailTypeInactiveReminder, UserID: bob.ID}
testutils.MustExec(t, testutils.DB.Create(&bobNotif), "preparing inactive notification for bob")
testutils.MustExec(t, testutils.DB.Model(&bobNotif).Update("created_at", now.AddDate(0, 0, -7)), "preparing created_at for inactive notification for bob")
chuck := setup(t, now, "chuck@example.com")
chuckNotif := database.Notification{Type: mailer.EmailTypeInactiveReminder, UserID: chuck.ID}
testutils.MustExec(t, testutils.DB.Create(&chuckNotif), "preparing inactive notification for chuck")
testutils.MustExec(t, testutils.DB.Model(&chuckNotif).Update("created_at", now.AddDate(0, 0, -15)), "preparing created_at for inactive notification for chuck")
dan := setup(t, now, "dan@example.com")
danNotif1 := database.Notification{Type: mailer.EmailTypeInactiveReminder, UserID: dan.ID}
testutils.MustExec(t, testutils.DB.Create(&danNotif1), "preparing inactive notification 1 for dan")
testutils.MustExec(t, testutils.DB.Model(&danNotif1).Update("created_at", now.AddDate(0, 0, -10)), "preparing created_at for inactive notification for dan")
danNotif2 := database.Notification{Type: mailer.EmailTypeInactiveReminder, UserID: dan.ID}
testutils.MustExec(t, testutils.DB.Create(&danNotif2), "preparing inactive notification 2 for dan")
testutils.MustExec(t, testutils.DB.Model(&danNotif2).Update("created_at", now.AddDate(0, 0, -15)), "preparing created_at for inactive notification for dan")
c := clock.NewMock()
c.SetNow(now)
be := &testutils.MockEmailbackendImplementation{}
// Execute
con := getTestContext(c, be)
if _, err := DoInactive(con); err != nil {
t.Fatal(errors.Wrap(err, "performing"))
}
// Test
assert.Equalf(t, len(be.Emails), 2, "email queue count mismatch")
var recipients []string
for _, email := range be.Emails {
recipients = append(recipients, email.To[0])
}
sort.SliceStable(recipients, func(i, j int) bool {
r1 := recipients[i]
r2 := recipients[j]
return r1 < r2
})
assert.DeepEqual(t, recipients, []string{"alice@example.com", "chuck@example.com"}, "email address mismatch")
}

View file

@ -1,35 +0,0 @@
/* Copyright (C) 2019, 2020, 2021 Monomax Software Pty Ltd
*
* This file is part of Dnote.
*
* Dnote is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Dnote is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Dnote. If not, see <https://www.gnu.org/licenses/>.
*/
package remind
import (
"os"
"testing"
"github.com/dnote/dnote/pkg/server/testutils"
)
func TestMain(m *testing.M) {
testutils.InitTestDB()
code := m.Run()
testutils.ClearData(testutils.DB)
os.Exit(code)
}

View file

@ -6,4 +6,4 @@ Please click on the following link, or paste this into your browser to complete
You can reply to this message, if you have questions.
- Sung (Maker of Dnote)
- Dnote team

View file

@ -6,4 +6,4 @@ If you did not initiate this password change, please notify us by replying, and
Thanks.
- Sung (Maker of Dnote)
- Dnote team

View file

@ -3,10 +3,10 @@ Hi, thanks for signing up for Dnote Pro.
Now you can take your notes with you wherever you go!
* Synchronize data among an unlimited number of machines.
* Access notes anywhere via the web interface.
* Manage notes via REST API.
Your account is "{{ .AccountEmail }}". Log in at {{ .WebURL }}/login
Thank you for using Dnote. Your support makes it possible to develop it for developers around the world.
- Sung (Maker of Dnote)
- Dnote team

View file

@ -1,9 +1,9 @@
Hi.
Welcome to Dnote! To verify your email so that you can automate spaced reptition, visit the following link:
Welcome to Dnote! To verify your email, visit the following link:
{{ .WebURL }}/verify-email/{{ .Token }}
{{ .WebURL }}/verify-email?token={{ .Token }}
Thanks for using my software.
Thanks for using Dnote.
- Sung (Maker of Dnote)
- Dnote team

View file

@ -13,4 +13,4 @@ Dnote is open source and you can see the source code at https://github.com/dnote
Feel free to reply anytime. Thanks for using Dnote.
- Sung (Maker of Dnote)
- Dnote team

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -40,6 +40,15 @@
Yes
{{else}}
No
<form action="/verification-token" method="POST" class="email-verification-form">
<button
class="button button-second button-small"
type="submit"
>
Send verification email
</button>
</form>
{{end}}
</div>
</div>