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 e8a26824cd4..2506106605c 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 45a180f8386..4850c9f202e 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 2a4ef1b1ac8..64adc59e87e 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 9675311f33a..e86d036887e 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 19424155562..3a9707c88af 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 039d4d737ea..4b4220dfba4 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 00000000000..c1c65fc7574 --- /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 e08a9fd98bf..21884a3e308 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 00000000000..ef5517b1735 --- /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 a7f26558ab3..66cc79c9994 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 add57c28b31..3139fa64d42 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 2d29efaedf1..9ff75abe106 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 554bcae2afd..7afcc41cc28 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 a333dfdc971..6a06b6fe4e2 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 00000000000..876d53d9f00 --- /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 44adbd5514c..97959ecbd08 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 264009eccde..16201aa0d39 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 b3f77b0407e..0daa1a0e2a9 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 06ceea8dccf..800e116873d 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 5f530d83b54..853c261282b 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 3fab5eb4bc7..c46ad31669f 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 2625060d28f..548fad20db3 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 d307e6daf24..d78f6b27c49 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 a834f1318f0..0ab9ba81cf3 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 394d5d18db8..d74501913f4 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 691f7754ddb..8388085b185 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 7413a405874..97031a652af 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 00000000000..44bc57d7ebd --- /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 00000000000..b4f66e561eb --- /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 ecb2a1bd6ce..e0dea5a9536 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