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 e8a26824cd..2506106605 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 45a180f838..4850c9f202 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 2a4ef1b1ac..64adc59e87 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 9675311f33..e86d036887 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 1942415556..3a9707c88a 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 039d4d737e..4b4220dfba 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 0000000000..c1c65fc757 --- /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 e08a9fd98b..21884a3e30 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 0000000000..ef5517b173 --- /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 a7f26558ab..66cc79c999 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 add57c28b3..3139fa64d4 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 2d29efaedf..9ff75abe10 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 554bcae2af..7afcc41cc2 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 a333dfdc97..6a06b6fe4e 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 0000000000..876d53d9f0 --- /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 44adbd5514..97959ecbd0 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 264009eccd..16201aa0d3 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 b3f77b0407..0daa1a0e2a 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 06ceea8dcc..800e116873 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 5f530d83b5..853c261282 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 3fab5eb4bc..c46ad31669 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 2625060d28..548fad20db 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 d307e6daf2..d78f6b27c4 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 a834f1318f..0ab9ba81cf 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 394d5d18db..d74501913f 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 691f7754dd..8388085b18 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 7413a40587..97031a652a 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 0000000000..44bc57d7eb --- /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 0000000000..b4f66e561e --- /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 ecb2a1bd6c..e0dea5a953 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