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 @@
-
+
-
\ 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