From b2e4fde63aa6a1094cbc757926b892f543550b46 Mon Sep 17 00:00:00 2001 From: konrad Date: Fri, 18 Dec 2020 23:21:17 +0000 Subject: [PATCH] Add email reminders (#743) Fix tests Expose email reminder setting through jwt Set reminders on by default Fix lint Make user email configurable Expose email reminder setting through /info Don't try to send any reminders if none were found More spacing for buttons Fix db time format Enable reminders by default Make emails look more like the frontend Add config to disable it Add sending emaisl Add getting all task users and reminding them Add getting the next reminder in a cron Move task reminder to separate file Add cron Co-authored-by: kolaente Reviewed-on: https://kolaente.dev/vikunja/api/pulls/743 Co-Authored-By: konrad Co-Committed-By: konrad --- config.yml.sample | 3 + docs/content/doc/setup/config.md | 7 + go.mod | 1 + go.sum | 2 + pkg/cmd/web.go | 3 + pkg/config/config.go | 2 + pkg/cron/cron.go | 40 +++++ pkg/initialize/init.go | 5 + pkg/migration/20201218220204.go | 43 +++++ pkg/models/label_task_test.go | 13 +- pkg/models/label_test.go | 56 ++++--- pkg/models/list_users_test.go | 28 ++-- pkg/models/namespace_users_test.go | 28 ++-- pkg/models/task_collection_test.go | 43 ++--- pkg/models/task_reminder.go | 160 ++++++++++++++++++ pkg/models/tasks.go | 13 -- pkg/models/users_list_test.go | 197 ++++++++++++----------- pkg/modules/auth/auth.go | 1 + pkg/routes/api/v1/info.go | 2 + pkg/routes/api/v1/user_settings.go | 26 +-- pkg/routes/routes.go | 2 +- pkg/swagger/docs.go | 34 ++-- pkg/swagger/swagger.json | 34 ++-- pkg/swagger/swagger.yaml | 26 +-- pkg/user/user.go | 4 + templates/mail/confirm-email.html.tmpl | 2 +- templates/mail/mail-header.tmpl | 4 +- templates/mail/reminder-email.html.tmpl | 17 ++ templates/mail/reminder-email.plain.tmpl | 9 ++ templates/mail/reset-password.html.tmpl | 2 +- 30 files changed, 568 insertions(+), 239 deletions(-) create mode 100644 pkg/cron/cron.go create mode 100644 pkg/migration/20201218220204.go create mode 100644 pkg/models/task_reminder.go create mode 100644 templates/mail/reminder-email.html.tmpl create mode 100644 templates/mail/reminder-email.plain.tmpl diff --git a/config.yml.sample b/config.yml.sample index e8a26824c..250610660 100644 --- a/config.yml.sample +++ b/config.yml.sample @@ -39,6 +39,9 @@ service: # each request made to this endpoint neefs to provide an `Authorization: ` header with the token from below.
# **You should never use this unless you know exactly what you're doing** testingtoken: '' + # If enabled, vikunja will send an email to everyone who is either assigned to a task or created it when a task reminder + # is due. + enableemailreminders: true database: # Database type to use. Supported types are mysql, postgres and sqlite. diff --git a/docs/content/doc/setup/config.md b/docs/content/doc/setup/config.md index 45a180f83..4850c9f20 100644 --- a/docs/content/doc/setup/config.md +++ b/docs/content/doc/setup/config.md @@ -170,6 +170,13 @@ each request made to this endpoint neefs to provide an `Authorization: ` Default: `` +### enableemailreminders + +If enabled, vikunja will send an email to everyone who is either assigned to a task or created it when a task reminder +is due. + +Default: `true` + --- ## database diff --git a/go.mod b/go.mod index 2a4ef1b1a..64adc59e8 100644 --- a/go.mod +++ b/go.mod @@ -61,6 +61,7 @@ require ( github.com/pquerna/cachecontrol v0.0.0-20200921180117-858c6e7e6b7e // indirect github.com/pquerna/otp v1.3.0 github.com/prometheus/client_golang v1.9.0 + github.com/robfig/cron/v3 v3.0.1 github.com/samedi/caldav-go v3.0.0+incompatible github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749 github.com/shurcooL/vfsgen v0.0.0-20200824052919-0d455de96546 diff --git a/go.sum b/go.sum index 9675311f3..e86d03688 100644 --- a/go.sum +++ b/go.sum @@ -679,6 +679,8 @@ github.com/prometheus/procfs v0.2.0 h1:wH4vA7pcjKuZzjF7lM8awk4fnuJO6idemZXoKnULU github.com/prometheus/procfs v0.2.0/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= diff --git a/pkg/cmd/web.go b/pkg/cmd/web.go index 194241555..3a9707c88 100644 --- a/pkg/cmd/web.go +++ b/pkg/cmd/web.go @@ -22,6 +22,8 @@ import ( "os/signal" "time" + "code.vikunja.io/api/pkg/cron" + "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/initialize" "code.vikunja.io/api/pkg/log" @@ -70,5 +72,6 @@ var webCmd = &cobra.Command{ if err := e.Shutdown(ctx); err != nil { e.Logger.Fatal(err) } + cron.Stop() }, } diff --git a/pkg/config/config.go b/pkg/config/config.go index 039d4d737..4b4220dfb 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -52,6 +52,7 @@ const ( ServiceEnableTotp Key = `service.enabletotp` ServiceSentryDsn Key = `service.sentrydsn` ServiceTestingtoken Key = `service.testingtoken` + ServiceEnableEmailReminders Key = `service.enableemailreminders` AuthLocalEnabled Key = `auth.local.enabled` AuthOpenIDEnabled Key = `auth.openid.enabled` @@ -233,6 +234,7 @@ func InitDefaultConfig() { ServiceTimeZone.setDefault("GMT") ServiceEnableTaskComments.setDefault(true) ServiceEnableTotp.setDefault(true) + ServiceEnableEmailReminders.setDefault(true) // Auth AuthLocalEnabled.setDefault(true) diff --git a/pkg/cron/cron.go b/pkg/cron/cron.go new file mode 100644 index 000000000..c1c65fc75 --- /dev/null +++ b/pkg/cron/cron.go @@ -0,0 +1,40 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-2020 Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program 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 General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package cron + +import ( + "github.com/robfig/cron/v3" +) + +var c *cron.Cron + +// Init starts the cron +func Init() { + c = cron.New() + c.Start() +} + +// Schedule schedules a job as a cron job +func Schedule(schedule string, f func()) (err error) { + _, err = c.AddFunc(schedule, f) + return +} + +// Stop stops the cron scheduler +func Stop() { + c.Stop() +} diff --git a/pkg/initialize/init.go b/pkg/initialize/init.go index e08a9fd98..21884a3e3 100644 --- a/pkg/initialize/init.go +++ b/pkg/initialize/init.go @@ -18,6 +18,7 @@ package initialize import ( "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/cron" "code.vikunja.io/api/pkg/files" "code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/mail" @@ -80,4 +81,8 @@ func FullInit() { // Start the mail daemon mail.StartMailDaemon() + + // Start the cron + cron.Init() + models.RegisterReminderCron() } diff --git a/pkg/migration/20201218220204.go b/pkg/migration/20201218220204.go new file mode 100644 index 000000000..ef5517b17 --- /dev/null +++ b/pkg/migration/20201218220204.go @@ -0,0 +1,43 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-2020 Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program 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 General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package migration + +import ( + "src.techknowlogick.com/xormigrate" + "xorm.io/xorm" +) + +type users20201218220204 struct { + EmailRemindersEnabled bool `xorm:"bool default true" json:"-"` +} + +func (users20201218220204) TableName() string { + return "users" +} + +func init() { + migrations = append(migrations, &xormigrate.Migration{ + ID: "20201218220204", + Description: "Add email reminder setting to user", + Migrate: func(tx *xorm.Engine) error { + return tx.Sync2(users20201218220204{}) + }, + Rollback: func(tx *xorm.Engine) error { + return nil + }, + }) +} diff --git a/pkg/models/label_task_test.go b/pkg/models/label_task_test.go index a7f26558a..66cc79c99 100644 --- a/pkg/models/label_task_test.go +++ b/pkg/models/label_task_test.go @@ -53,12 +53,13 @@ func TestLabelTask_ReadAll(t *testing.T) { Updated: testUpdatedTime, CreatedByID: 2, CreatedBy: &user.User{ - ID: 2, - Username: "user2", - Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", - Issuer: "local", - Created: testCreatedTime, - Updated: testUpdatedTime, + ID: 2, + Username: "user2", + Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", + Issuer: "local", + EmailRemindersEnabled: true, + Created: testCreatedTime, + Updated: testUpdatedTime, }, }, }, diff --git a/pkg/models/label_test.go b/pkg/models/label_test.go index add57c28b..3139fa64d 100644 --- a/pkg/models/label_test.go +++ b/pkg/models/label_test.go @@ -48,13 +48,14 @@ func TestLabel_ReadAll(t *testing.T) { page int } user1 := &user.User{ - ID: 1, - Username: "user1", - Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", - IsActive: true, - Issuer: "local", - Created: testCreatedTime, - Updated: testUpdatedTime, + ID: 1, + Username: "user1", + Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", + IsActive: true, + Issuer: "local", + EmailRemindersEnabled: true, + Created: testCreatedTime, + Updated: testUpdatedTime, } tests := []struct { name string @@ -97,12 +98,13 @@ func TestLabel_ReadAll(t *testing.T) { Updated: testUpdatedTime, CreatedByID: 2, CreatedBy: &user.User{ - ID: 2, - Username: "user2", - Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", - Issuer: "local", - Created: testCreatedTime, - Updated: testUpdatedTime, + ID: 2, + Username: "user2", + Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", + Issuer: "local", + EmailRemindersEnabled: true, + Created: testCreatedTime, + Updated: testUpdatedTime, }, }, }, @@ -157,13 +159,14 @@ func TestLabel_ReadOne(t *testing.T) { Rights web.Rights } user1 := &user.User{ - ID: 1, - Username: "user1", - Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", - IsActive: true, - Issuer: "local", - Created: testCreatedTime, - Updated: testUpdatedTime, + ID: 1, + Username: "user1", + Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", + IsActive: true, + Issuer: "local", + EmailRemindersEnabled: true, + Created: testCreatedTime, + Updated: testUpdatedTime, } tests := []struct { name string @@ -217,12 +220,13 @@ func TestLabel_ReadOne(t *testing.T) { Title: "Label #4 - visible via other task", CreatedByID: 2, CreatedBy: &user.User{ - ID: 2, - Username: "user2", - Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", - Issuer: "local", - Created: testCreatedTime, - Updated: testUpdatedTime, + ID: 2, + Username: "user2", + Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", + Issuer: "local", + EmailRemindersEnabled: true, + Created: testCreatedTime, + Updated: testUpdatedTime, }, Created: testCreatedTime, Updated: testUpdatedTime, diff --git a/pkg/models/list_users_test.go b/pkg/models/list_users_test.go index 2d29efaed..9ff75abe1 100644 --- a/pkg/models/list_users_test.go +++ b/pkg/models/list_users_test.go @@ -172,24 +172,26 @@ func TestListUser_ReadAll(t *testing.T) { want: []*UserWithRight{ { User: user.User{ - ID: 1, - Username: "user1", - Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", - IsActive: true, - Issuer: "local", - Created: testCreatedTime, - Updated: testUpdatedTime, + ID: 1, + Username: "user1", + Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", + IsActive: true, + Issuer: "local", + EmailRemindersEnabled: true, + Created: testCreatedTime, + Updated: testUpdatedTime, }, Right: RightRead, }, { User: user.User{ - ID: 2, - Username: "user2", - Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", - Issuer: "local", - Created: testCreatedTime, - Updated: testUpdatedTime, + ID: 2, + Username: "user2", + Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", + Issuer: "local", + EmailRemindersEnabled: true, + Created: testCreatedTime, + Updated: testUpdatedTime, }, Right: RightRead, }, diff --git a/pkg/models/namespace_users_test.go b/pkg/models/namespace_users_test.go index 554bcae2a..7afcc41cc 100644 --- a/pkg/models/namespace_users_test.go +++ b/pkg/models/namespace_users_test.go @@ -171,24 +171,26 @@ func TestNamespaceUser_ReadAll(t *testing.T) { want: []*UserWithRight{ { User: user.User{ - ID: 1, - Username: "user1", - Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", - IsActive: true, - Issuer: "local", - Created: testCreatedTime, - Updated: testUpdatedTime, + ID: 1, + Username: "user1", + Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", + IsActive: true, + Issuer: "local", + EmailRemindersEnabled: true, + Created: testCreatedTime, + Updated: testUpdatedTime, }, Right: RightRead, }, { User: user.User{ - ID: 2, - Username: "user2", - Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", - Issuer: "local", - Created: testCreatedTime, - Updated: testUpdatedTime, + ID: 2, + Username: "user2", + Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", + Issuer: "local", + EmailRemindersEnabled: true, + Created: testCreatedTime, + Updated: testUpdatedTime, }, Right: RightRead, }, diff --git a/pkg/models/task_collection_test.go b/pkg/models/task_collection_test.go index a333dfdc9..6a06b6fe4 100644 --- a/pkg/models/task_collection_test.go +++ b/pkg/models/task_collection_test.go @@ -31,30 +31,33 @@ import ( func TestTaskCollection_ReadAll(t *testing.T) { // Dummy users user1 := &user.User{ - ID: 1, - Username: "user1", - Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", - IsActive: true, - Issuer: "local", - Created: testCreatedTime, - Updated: testUpdatedTime, + ID: 1, + Username: "user1", + Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", + IsActive: true, + Issuer: "local", + EmailRemindersEnabled: true, + Created: testCreatedTime, + Updated: testUpdatedTime, } user2 := &user.User{ - ID: 2, - Username: "user2", - Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", - Issuer: "local", - Created: testCreatedTime, - Updated: testUpdatedTime, + ID: 2, + Username: "user2", + Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", + Issuer: "local", + EmailRemindersEnabled: true, + Created: testCreatedTime, + Updated: testUpdatedTime, } user6 := &user.User{ - ID: 6, - Username: "user6", - Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", - Issuer: "local", - IsActive: true, - Created: testCreatedTime, - Updated: testUpdatedTime, + ID: 6, + Username: "user6", + Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", + Issuer: "local", + IsActive: true, + EmailRemindersEnabled: true, + Created: testCreatedTime, + Updated: testUpdatedTime, } loc := config.GetTimeZone() diff --git a/pkg/models/task_reminder.go b/pkg/models/task_reminder.go new file mode 100644 index 000000000..876d53d9f --- /dev/null +++ b/pkg/models/task_reminder.go @@ -0,0 +1,160 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-2020 Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program 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 General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package models + +import ( + "time" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/cron" + "code.vikunja.io/api/pkg/log" + "code.vikunja.io/api/pkg/mail" + "code.vikunja.io/api/pkg/user" +) + +// TaskReminder holds a reminder on a task +type TaskReminder struct { + ID int64 `xorm:"bigint autoincr not null unique pk"` + TaskID int64 `xorm:"bigint not null INDEX"` + Reminder time.Time `xorm:"DATETIME not null INDEX 'reminder'"` + Created time.Time `xorm:"created not null"` +} + +// TableName returns a pretty table name +func (TaskReminder) TableName() string { + return "task_reminders" +} + +type taskUser struct { + Task *Task `xorm:"extends"` + User *user.User `xorm:"extends"` +} + +func getTaskUsersForTasks(taskIDs []int64) (taskUsers []*taskUser, err error) { + // Get all creators of tasks + creators := make(map[int64]*user.User, len(taskIDs)) + err = x. + Select("users.id, users.username, users.email, users.name"). + Join("LEFT", "tasks", "tasks.created_by_id = users.id"). + In("tasks.id", taskIDs). + Where("users.email_reminders_enabled = true"). + GroupBy("tasks.id, users.id, users.username, users.email, users.name"). + Find(&creators) + if err != nil { + return + } + + assignees, err := getRawTaskAssigneesForTasks(taskIDs) + if err != nil { + return + } + + taskMap := make(map[int64]*Task, len(taskIDs)) + err = x.In("id", taskIDs).Find(&taskMap) + if err != nil { + return + } + + for _, taskID := range taskIDs { + taskUsers = append(taskUsers, &taskUser{ + Task: taskMap[taskID], + User: creators[taskMap[taskID].CreatedByID], + }) + } + + for _, assignee := range assignees { + if !assignee.EmailRemindersEnabled { // Can't filter that through a query directly since we're using another function + continue + } + taskUsers = append(taskUsers, &taskUser{ + Task: taskMap[assignee.TaskID], + User: &assignee.User, + }) + } + + return +} + +// RegisterReminderCron registers a cron function which runs every minute to check if any reminders are due the +// next minute to send emails. +func RegisterReminderCron() { + if !config.ServiceEnableEmailReminders.GetBool() { + return + } + + if !config.MailerEnabled.GetBool() { + log.Info("Mailer is disabled, not sending reminders per mail") + return + } + + tz := config.GetTimeZone() + const dbFormat = `2006-01-02 15:04:05` + + log.Debugf("[Task Reminder Cron] Timezone is %s", tz) + + err := cron.Schedule("* * * * *", func() { + // By default, time.Now() includes nanoseconds which we don't save. That results in getting the wrong dates, + // so we make sure the time we use to get the reminders don't contain nanoseconds. + now := time.Now() + now = time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), now.Minute(), 0, 0, now.Location()).In(tz) + nextMinute := now.Add(1 * time.Minute) + + log.Debugf("[Task Reminder Cron] Looking for reminders between %s and %s to send...", now, nextMinute) + + reminders := []*TaskReminder{} + err := x. + Where("reminder >= ? and reminder < ?", now.Format(dbFormat), nextMinute.Format(dbFormat)). + Find(&reminders) + if err != nil { + log.Errorf("[Task Reminder Cron] Could not get reminders for the next minute: %s", err) + return + } + + log.Debugf("[Task Reminder Cron] Found %d reminders", len(reminders)) + + if len(reminders) == 0 { + return + } + + // We're sending a reminder to everyone who is assigned to the task or has created it. + var taskIDs []int64 + for _, r := range reminders { + taskIDs = append(taskIDs, r.TaskID) + } + + users, err := getTaskUsersForTasks(taskIDs) + if err != nil { + log.Errorf("[Task Reminder Cron] Could not get task users to send them reminders: %s", err) + return + } + + log.Debugf("[Task Reminder Cron] Sending reminders to %d users", len(users)) + + for _, u := range users { + data := map[string]interface{}{ + "User": u.User, + "Task": u.Task, + } + + mail.SendMailWithTemplate(u.User.Email, `Reminder for "`+u.Task.Title+`"`, "reminder-email", data) + log.Debugf("[Task Reminder Cron] Sent reminder email for task %d to user %d", u.Task.ID, u.User.ID) + } + }) + if err != nil { + log.Fatalf("Could not register reminder cron: %s", err) + } +} diff --git a/pkg/models/tasks.go b/pkg/models/tasks.go index 44adbd551..97959ecbd 100644 --- a/pkg/models/tasks.go +++ b/pkg/models/tasks.go @@ -116,19 +116,6 @@ func (Task) TableName() string { return "tasks" } -// TaskReminder holds a reminder on a task -type TaskReminder struct { - ID int64 `xorm:"bigint autoincr not null unique pk"` - TaskID int64 `xorm:"bigint not null INDEX"` - Reminder time.Time `xorm:"DATETIME not null INDEX 'reminder'"` - Created time.Time `xorm:"created not null"` -} - -// TableName returns a pretty table name -func (TaskReminder) TableName() string { - return "task_reminders" -} - type taskFilterConcatinator string const ( diff --git a/pkg/models/users_list_test.go b/pkg/models/users_list_test.go index 264009ecc..16201aa0d 100644 --- a/pkg/models/users_list_test.go +++ b/pkg/models/users_list_test.go @@ -27,122 +27,135 @@ import ( func TestListUsersFromList(t *testing.T) { testuser1 := &user.User{ - ID: 1, - Username: "user1", - Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", - IsActive: true, - Issuer: "local", - Created: testCreatedTime, - Updated: testUpdatedTime, + ID: 1, + Username: "user1", + Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", + IsActive: true, + Issuer: "local", + EmailRemindersEnabled: true, + Created: testCreatedTime, + Updated: testUpdatedTime, } testuser2 := &user.User{ - ID: 2, - Username: "user2", - Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", - Issuer: "local", - Created: testCreatedTime, - Updated: testUpdatedTime, + ID: 2, + Username: "user2", + Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", + Issuer: "local", + EmailRemindersEnabled: true, + Created: testCreatedTime, + Updated: testUpdatedTime, } testuser3 := &user.User{ - ID: 3, - Username: "user3", - Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", - PasswordResetToken: "passwordresettesttoken", - Issuer: "local", - Created: testCreatedTime, - Updated: testUpdatedTime, + ID: 3, + Username: "user3", + Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", + PasswordResetToken: "passwordresettesttoken", + Issuer: "local", + EmailRemindersEnabled: true, + Created: testCreatedTime, + Updated: testUpdatedTime, } testuser4 := &user.User{ - ID: 4, - Username: "user4", - Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", - IsActive: false, - EmailConfirmToken: "tiepiQueed8ahc7zeeFe1eveiy4Ein8osooxegiephauph2Ael", - Issuer: "local", - Created: testCreatedTime, - Updated: testUpdatedTime, + ID: 4, + Username: "user4", + Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", + IsActive: false, + EmailConfirmToken: "tiepiQueed8ahc7zeeFe1eveiy4Ein8osooxegiephauph2Ael", + Issuer: "local", + EmailRemindersEnabled: true, + Created: testCreatedTime, + Updated: testUpdatedTime, } testuser5 := &user.User{ - ID: 5, - Username: "user5", - Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", - IsActive: false, - EmailConfirmToken: "tiepiQueed8ahc7zeeFe1eveiy4Ein8osooxegiephauph2Ael", - Issuer: "local", - Created: testCreatedTime, - Updated: testUpdatedTime, + ID: 5, + Username: "user5", + Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", + IsActive: false, + EmailConfirmToken: "tiepiQueed8ahc7zeeFe1eveiy4Ein8osooxegiephauph2Ael", + Issuer: "local", + EmailRemindersEnabled: true, + Created: testCreatedTime, + Updated: testUpdatedTime, } testuser6 := &user.User{ - ID: 6, - Username: "user6", - Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", - IsActive: true, - Issuer: "local", - Created: testCreatedTime, - Updated: testUpdatedTime, + ID: 6, + Username: "user6", + Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", + IsActive: true, + Issuer: "local", + EmailRemindersEnabled: true, + Created: testCreatedTime, + Updated: testUpdatedTime, } testuser7 := &user.User{ - ID: 7, - Username: "user7", - Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", - IsActive: true, - Issuer: "local", - Created: testCreatedTime, - Updated: testUpdatedTime, + ID: 7, + Username: "user7", + Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", + IsActive: true, + Issuer: "local", + EmailRemindersEnabled: true, + Created: testCreatedTime, + Updated: testUpdatedTime, } testuser8 := &user.User{ - ID: 8, - Username: "user8", - Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", - IsActive: true, - Issuer: "local", - Created: testCreatedTime, - Updated: testUpdatedTime, + ID: 8, + Username: "user8", + Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", + IsActive: true, + Issuer: "local", + EmailRemindersEnabled: true, + Created: testCreatedTime, + Updated: testUpdatedTime, } testuser9 := &user.User{ - ID: 9, - Username: "user9", - Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", - IsActive: true, - Issuer: "local", - Created: testCreatedTime, - Updated: testUpdatedTime, + ID: 9, + Username: "user9", + Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", + IsActive: true, + Issuer: "local", + EmailRemindersEnabled: true, + Created: testCreatedTime, + Updated: testUpdatedTime, } testuser10 := &user.User{ - ID: 10, - Username: "user10", - Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", - IsActive: true, - Issuer: "local", - Created: testCreatedTime, - Updated: testUpdatedTime, + ID: 10, + Username: "user10", + Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", + IsActive: true, + Issuer: "local", + EmailRemindersEnabled: true, + Created: testCreatedTime, + Updated: testUpdatedTime, } testuser11 := &user.User{ - ID: 11, - Username: "user11", - Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", - IsActive: true, - Issuer: "local", - Created: testCreatedTime, - Updated: testUpdatedTime, + ID: 11, + Username: "user11", + Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", + IsActive: true, + Issuer: "local", + EmailRemindersEnabled: true, + Created: testCreatedTime, + Updated: testUpdatedTime, } testuser12 := &user.User{ - ID: 12, - Username: "user12", - Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", - IsActive: true, - Issuer: "local", - Created: testCreatedTime, - Updated: testUpdatedTime, + ID: 12, + Username: "user12", + Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", + IsActive: true, + Issuer: "local", + EmailRemindersEnabled: true, + Created: testCreatedTime, + Updated: testUpdatedTime, } testuser13 := &user.User{ - ID: 13, - Username: "user13", - Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", - IsActive: true, - Issuer: "local", - Created: testCreatedTime, - Updated: testUpdatedTime, + ID: 13, + Username: "user13", + Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", + IsActive: true, + Issuer: "local", + EmailRemindersEnabled: true, + Created: testCreatedTime, + Updated: testUpdatedTime, } type args struct { diff --git a/pkg/modules/auth/auth.go b/pkg/modules/auth/auth.go index b3f77b040..0daa1a0e2 100644 --- a/pkg/modules/auth/auth.go +++ b/pkg/modules/auth/auth.go @@ -62,6 +62,7 @@ func NewUserJWTAuthtoken(user *user.User) (token string, err error) { claims["email"] = user.Email claims["exp"] = time.Now().Add(time.Hour * 72).Unix() claims["name"] = user.Name + claims["emailRemindersEnabled"] = user.EmailRemindersEnabled // Generate encoded token and send it as response. return t.SignedString([]byte(config.ServiceJWTSecret.GetString())) diff --git a/pkg/routes/api/v1/info.go b/pkg/routes/api/v1/info.go index 06ceea8dc..800e11687 100644 --- a/pkg/routes/api/v1/info.go +++ b/pkg/routes/api/v1/info.go @@ -47,6 +47,7 @@ type vikunjaInfos struct { Legal legalInfo `json:"legal"` CaldavEnabled bool `json:"caldav_enabled"` AuthInfo authInfo `json:"auth"` + EmailRemindersEnabled bool `json:"email_reminders_enabled"` } type authInfo struct { @@ -87,6 +88,7 @@ func Info(c echo.Context) error { TaskAttachmentsEnabled: config.ServiceEnableTaskAttachments.GetBool(), TotpEnabled: config.ServiceEnableTotp.GetBool(), CaldavEnabled: config.ServiceEnableCaldav.GetBool(), + EmailRemindersEnabled: config.ServiceEnableEmailReminders.GetBool(), Legal: legalInfo{ ImprintURL: config.LegalImprintURL.GetString(), PrivacyPolicyURL: config.LegalPrivacyURL.GetString(), diff --git a/pkg/routes/api/v1/user_settings.go b/pkg/routes/api/v1/user_settings.go index 5f530d83b..853c26128 100644 --- a/pkg/routes/api/v1/user_settings.go +++ b/pkg/routes/api/v1/user_settings.go @@ -31,10 +31,12 @@ type UserAvatarProvider struct { AvatarProvider string `json:"avatar_provider"` } -// UserName holds the user's name -type UserName struct { +// UserSettings holds all user settings +type UserSettings struct { // The new name of the current user. Name string `json:"name"` + // If enabled, sends email reminders of tasks to the user. + EmailRemindersEnabled bool `xorm:"bool default false" json:"email_reminders_enabled"` } // GetUserAvatarProvider returns the currently set user avatar @@ -104,21 +106,20 @@ func ChangeUserAvatarProvider(c echo.Context) error { return c.JSON(http.StatusOK, &models.Message{Message: "Avatar was changed successfully."}) } -// ChangeUserName is the handler to change the name of the current user -// @Summary Change the current user's name -// @Description Changes the current user's name. It is also possible to reset the name. +// UpdateGeneralUserSettings is the handler to change general user settings +// @Summary Change general user settings of the current user. // @tags user // @Accept json // @Produce json // @Security JWTKeyAuth -// @Param avatar body UserName true "The updated user name" +// @Param avatar body UserSettings true "The updated user settings" // @Success 200 {object} models.Message // @Failure 400 {object} web.HTTPError "Something's invalid." // @Failure 500 {object} models.Message "Internal server error." -// @Router /user/settings/name [post] -func UpdateUserName(c echo.Context) error { - un := &UserName{} - err := c.Bind(un) +// @Router /user/settings/general [post] +func UpdateGeneralUserSettings(c echo.Context) error { + us := &UserSettings{} + err := c.Bind(us) if err != nil { return echo.NewHTTPError(http.StatusBadRequest, "Bad user name provided.") } @@ -133,12 +134,13 @@ func UpdateUserName(c echo.Context) error { return handler.HandleHTTPError(err, c) } - user.Name = un.Name + user.Name = us.Name + user.EmailRemindersEnabled = us.EmailRemindersEnabled _, err = user2.UpdateUser(user) if err != nil { return handler.HandleHTTPError(err, c) } - return c.JSON(http.StatusOK, &models.Message{Message: "Name was changed successfully."}) + return c.JSON(http.StatusOK, &models.Message{Message: "The settings were updated successfully."}) } diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index 3fab5eb4b..c46ad3166 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -278,7 +278,7 @@ func registerAPIRoutes(a *echo.Group) { u.GET("/settings/avatar", apiv1.GetUserAvatarProvider) u.POST("/settings/avatar", apiv1.ChangeUserAvatarProvider) u.PUT("/settings/avatar/upload", apiv1.UploadAvatar) - u.POST("/settings/name", apiv1.UpdateUserName) + u.POST("/settings/general", apiv1.UpdateGeneralUserSettings) if config.ServiceEnableTotp.GetBool() { u.GET("/settings/totp", apiv1.UserTOTP) diff --git a/pkg/swagger/docs.go b/pkg/swagger/docs.go index 2625060d2..548fad20d 100644 --- a/pkg/swagger/docs.go +++ b/pkg/swagger/docs.go @@ -6296,14 +6296,13 @@ var doc = `{ } } }, - "/user/settings/name": { + "/user/settings/general": { "post": { "security": [ { "JWTKeyAuth": [] } ], - "description": "Changes the current user's name. It is also possible to reset the name.", "consumes": [ "application/json" ], @@ -6313,15 +6312,15 @@ var doc = `{ "tags": [ "user" ], - "summary": "Change the current user's name", + "summary": "Change general user settings of the current user.", "parameters": [ { - "description": "The updated user name", + "description": "The updated user settings", "name": "avatar", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/v1.UserName" + "$ref": "#/definitions/v1.UserSettings" } } ], @@ -8008,15 +8007,6 @@ var doc = `{ } } }, - "v1.UserName": { - "type": "object", - "properties": { - "name": { - "description": "The new name of the current user.", - "type": "string" - } - } - }, "v1.UserPassword": { "type": "object", "properties": { @@ -8028,6 +8018,19 @@ var doc = `{ } } }, + "v1.UserSettings": { + "type": "object", + "properties": { + "email_reminders_enabled": { + "description": "If enabled, sends email reminders of tasks to the user.", + "type": "boolean" + }, + "name": { + "description": "The new name of the current user.", + "type": "string" + } + } + }, "v1.authInfo": { "type": "object", "properties": { @@ -8090,6 +8093,9 @@ var doc = `{ "caldav_enabled": { "type": "boolean" }, + "email_reminders_enabled": { + "type": "boolean" + }, "enabled_background_providers": { "type": "array", "items": { diff --git a/pkg/swagger/swagger.json b/pkg/swagger/swagger.json index d307e6daf..d78f6b27c 100644 --- a/pkg/swagger/swagger.json +++ b/pkg/swagger/swagger.json @@ -6279,14 +6279,13 @@ } } }, - "/user/settings/name": { + "/user/settings/general": { "post": { "security": [ { "JWTKeyAuth": [] } ], - "description": "Changes the current user's name. It is also possible to reset the name.", "consumes": [ "application/json" ], @@ -6296,15 +6295,15 @@ "tags": [ "user" ], - "summary": "Change the current user's name", + "summary": "Change general user settings of the current user.", "parameters": [ { - "description": "The updated user name", + "description": "The updated user settings", "name": "avatar", "in": "body", "required": true, "schema": { - "$ref": "#/definitions/v1.UserName" + "$ref": "#/definitions/v1.UserSettings" } } ], @@ -7991,15 +7990,6 @@ } } }, - "v1.UserName": { - "type": "object", - "properties": { - "name": { - "description": "The new name of the current user.", - "type": "string" - } - } - }, "v1.UserPassword": { "type": "object", "properties": { @@ -8011,6 +8001,19 @@ } } }, + "v1.UserSettings": { + "type": "object", + "properties": { + "email_reminders_enabled": { + "description": "If enabled, sends email reminders of tasks to the user.", + "type": "boolean" + }, + "name": { + "description": "The new name of the current user.", + "type": "string" + } + } + }, "v1.authInfo": { "type": "object", "properties": { @@ -8073,6 +8076,9 @@ "caldav_enabled": { "type": "boolean" }, + "email_reminders_enabled": { + "type": "boolean" + }, "enabled_background_providers": { "type": "array", "items": { diff --git a/pkg/swagger/swagger.yaml b/pkg/swagger/swagger.yaml index a834f1318..0ab9ba81c 100644 --- a/pkg/swagger/swagger.yaml +++ b/pkg/swagger/swagger.yaml @@ -947,12 +947,6 @@ definitions: description: The avatar provider. Valid types are `gravatar` (uses the user email), `upload`, `initials`, `default`. type: string type: object - v1.UserName: - properties: - name: - description: The new name of the current user. - type: string - type: object v1.UserPassword: properties: new_password: @@ -960,6 +954,15 @@ definitions: old_password: type: string type: object + v1.UserSettings: + properties: + email_reminders_enabled: + description: If enabled, sends email reminders of tasks to the user. + type: boolean + name: + description: The new name of the current user. + type: string + type: object v1.authInfo: properties: local: @@ -1000,6 +1003,8 @@ definitions: type: array caldav_enabled: type: boolean + email_reminders_enabled: + type: boolean enabled_background_providers: items: type: string @@ -5100,18 +5105,17 @@ paths: summary: Update email address tags: - user - /user/settings/name: + /user/settings/general: post: consumes: - application/json - description: Changes the current user's name. It is also possible to reset the name. parameters: - - description: The updated user name + - description: The updated user settings in: body name: avatar required: true schema: - $ref: '#/definitions/v1.UserName' + $ref: '#/definitions/v1.UserSettings' produces: - application/json responses: @@ -5129,7 +5133,7 @@ paths: $ref: '#/definitions/models.Message' security: - JWTKeyAuth: [] - summary: Change the current user's name + summary: Change general user settings of the current user. tags: - user /user/settings/totp: diff --git a/pkg/user/user.go b/pkg/user/user.go index 394d5d18d..d74501913 100644 --- a/pkg/user/user.go +++ b/pkg/user/user.go @@ -62,6 +62,9 @@ type User struct { Issuer string `xorm:"text null" json:"-"` Subject string `xorm:"text null" json:"-"` + // If enabled, sends email reminders of tasks to the user. + EmailRemindersEnabled bool `xorm:"bool default true" json:"-"` + // A timestamp when this task was created. You cannot change this value. Created time.Time `xorm:"created not null" json:"created"` // A timestamp when this task was last updated. You cannot change this value. @@ -322,6 +325,7 @@ func UpdateUser(user *User) (updatedUser *User, err error) { "avatar_file_id", "is_active", "name", + "email_reminders_enabled", ). Update(user) if err != nil { diff --git a/templates/mail/confirm-email.html.tmpl b/templates/mail/confirm-email.html.tmpl index 691f7754d..8388085b1 100644 --- a/templates/mail/confirm-email.html.tmpl +++ b/templates/mail/confirm-email.html.tmpl @@ -8,7 +8,7 @@
To confirm your email address, click the link below:

- + Confirm your email address

diff --git a/templates/mail/mail-header.tmpl b/templates/mail/mail-header.tmpl index 7413a4058..97031a652 100644 --- a/templates/mail/mail-header.tmpl +++ b/templates/mail/mail-header.tmpl @@ -3,10 +3,10 @@ - +

Vikunja

-
\ No newline at end of file +
\ No newline at end of file diff --git a/templates/mail/reminder-email.html.tmpl b/templates/mail/reminder-email.html.tmpl new file mode 100644 index 000000000..44bc57d7e --- /dev/null +++ b/templates/mail/reminder-email.html.tmpl @@ -0,0 +1,17 @@ +{{template "mail-header.tmpl" .}} +

+ Hi {{if .User.Name}}{{.User.Name}}{{else}}{{.User.Username}}{{end}},
+
+ This is a friendly reminder of the task "{{.Task.Title}}".
+

+ + Open Task + +

+ If the button above doesn't work, copy the url below and paste it in your browsers address bar:
+ {{.FrontendURL}}tasks/{{.Task.ID}} +

+

+ Have a nice day! +

+{{template "mail-footer.tmpl"}} \ No newline at end of file diff --git a/templates/mail/reminder-email.plain.tmpl b/templates/mail/reminder-email.plain.tmpl new file mode 100644 index 000000000..b4f66e561 --- /dev/null +++ b/templates/mail/reminder-email.plain.tmpl @@ -0,0 +1,9 @@ +Hi {{if .User.Name}}{{.User.Name}}{{else}}{{.User.Username}}{{end}}, + +This is a friendly reminder of the task "{{.Task.Title}}". + +You can view the task at: + +{{.FrontendURL}}tasks/{{.Task.ID}} + +Have a nice day! diff --git a/templates/mail/reset-password.html.tmpl b/templates/mail/reset-password.html.tmpl index ecb2a1bd6..e0dea5a95 100644 --- a/templates/mail/reset-password.html.tmpl +++ b/templates/mail/reset-password.html.tmpl @@ -4,7 +4,7 @@
To reset your password, click the link below:

- + Reset your password