diff --git a/config.yml.sample b/config.yml.sample index e8a26824..25061066 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 45a180f8..4850c9f2 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 2a4ef1b1..64adc59e 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 9675311f..e86d0368 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 19424155..3a9707c8 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 039d4d73..4b4220df 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 00000000..c1c65fc7 --- /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 e08a9fd9..21884a3e 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 00000000..ef5517b1 --- /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 a7f26558..66cc79c9 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 add57c28..3139fa64 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 2d29efae..9ff75abe 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 554bcae2..7afcc41c 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 a333dfdc..6a06b6fe 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 00000000..876d53d9 --- /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 44adbd55..97959ecb 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 264009ec..16201aa0 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 b3f77b04..0daa1a0e 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 06ceea8d..800e1168 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 5f530d83..853c2612 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 3fab5eb4..c46ad316 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 2625060d..548fad20 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 d307e6da..d78f6b27 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 a834f131..0ab9ba81 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 394d5d18..d7450191 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 691f7754..8388085b 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 7413a405..97031a65 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 00000000..44bc57d7 --- /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 00000000..b4f66e56 --- /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 ecb2a1bd..e0dea5a9 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