Add email reminders #743

Merged
konrad merged 19 commits from feature/email-reminders into master 2020-12-18 23:21:18 +00:00
30 changed files with 568 additions and 239 deletions

View File

@ -39,6 +39,9 @@ service:
# each request made to this endpoint neefs to provide an `Authorization: <token>` header with the token from below. <br/>
# **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.

View File

@ -170,6 +170,13 @@ each request made to this endpoint neefs to provide an `Authorization: <token>`
Default: `<empty>`
### 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

1
go.mod
View File

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

2
go.sum
View File

@ -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=

View File

@ -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()
},
}

View File

@ -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)

40
pkg/cron/cron.go Normal file
View File

@ -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 <https://www.gnu.org/licenses/>.
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()
}

View File

@ -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()
}

View File

@ -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 <https://www.gnu.org/licenses/>.
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
},
})
}

View File

@ -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,
},
},
},

View File

@ -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,

View File

@ -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,
},

View File

@ -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,
},

View File

@ -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()

160
pkg/models/task_reminder.go Normal file
View File

@ -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 <https://www.gnu.org/licenses/>.
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)
}
}

View File

@ -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 (

View File

@ -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 {

View File

@ -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()))

View File

@ -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(),

View File

@ -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."})
}

View File

@ -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)

View File

@ -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": {

View File

@ -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": {

View File

@ -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:

View File

@ -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 {

View File

@ -8,7 +8,7 @@
<br/>
To confirm your email address, click the link below:
</p>
<a href="{{.FrontendURL}}?userEmailConfirm={{.User.EmailConfirmToken}}" title="Confirm your email address" style="background: rgb(20, 131, 175); -webkit-border-radius: 4px; -moz-border-radius: 4px; border-radius: 4px; border: 1px solid rgb(16, 106, 140); border-bottom-width: 3px; color: rgb(255, 255, 255); font-weight: 700; font-size: 13px; margin: 10px auto; padding: 5px 10px; text-decoration: none; text-align: center; text-rendering: optimizelegibility; text-transform: uppercase; display: block; width: 200px;">
<a href="{{.FrontendURL}}?userEmailConfirm={{.User.EmailConfirmToken}}" title="Confirm your email address" style="-webkit-box-shadow: 0.3em 0.3em 1em #b2d0ff; box-shadow: 0.3em 0.3em 1em #b2d0ff; background-color: #1973ff; border-color: transparent; color: #fff; text-decoration: none; text-align: center; text-rendering: optimizelegibility; text-transform: uppercase; font-weight: bold; font-size: 14px; padding: 10px 14px; margin: 10px auto; border-radius: 4px; user-select: none; display: block; width: 280px;font-family:sans-serif">
Confirm your email address
</a>
<p>

View File

@ -3,10 +3,10 @@
<head>
<meta name="viewport" content="width: display-width;">
</head>
<body style="width: 100%; padding: 0; margin: 0; background: #fbfbfb">
<body style="width: 100%; padding: 0; margin: 0; background: #f1f5f8">
<div style="width: 100%; font-family: 'Open Sans', sans-serif; text-rendering: optimizeLegibility">
<div style="width: 600px; margin: 0 auto; text-align: justify;">
<h1 style="font-size: 30px; text-align: center;">
<img src="{{.FrontendURL}}images/logo-full.svg" style="height: 75px;" alt="Vikunja"/>
</h1>
<div style="border: 1px solid #ccc; box-shadow: 1px 1px 5px #eeeeee; padding: 5px 25px; border-radius: 3px; background: #fff;">
<div style="border: 1px solid #dbdbdb; -webkit-box-shadow: 0.3em 0.3em 0.8em #e6e6e6; box-shadow: 0.3em 0.3em 0.8em #e6e6e6; color: #4a4a4a; padding: 5px 25px; border-radius: 3px; background: #fff;">

View File

@ -0,0 +1,17 @@
{{template "mail-header.tmpl" .}}
<p>
Hi {{if .User.Name}}{{.User.Name}}{{else}}{{.User.Username}}{{end}},<br/>
<br/>
This is a friendly reminder of the task "{{.Task.Title}}".<br/>
</p>
<a href="{{.FrontendURL}}tasks/{{.Task.ID}}" title="Open Task" style="-webkit-box-shadow: 0.3em 0.3em 1em #b2d0ff; box-shadow: 0.3em 0.3em 1em #b2d0ff; background-color: #1973ff; border-color: transparent; color: #fff; text-decoration: none; text-align: center; text-rendering: optimizelegibility; text-transform: uppercase; font-weight: bold; font-size: 14px; padding: 10px 14px; margin: 10px auto; border-radius: 4px; user-select: none; display: block; width: 280px;font-family:sans-serif">
Open Task
</a>
<p>
If the button above doesn't work, copy the url below and paste it in your browsers address bar:<br/>
{{.FrontendURL}}tasks/{{.Task.ID}}
</p>
<p>
Have a nice day!
</p>
{{template "mail-footer.tmpl"}}

View File

@ -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!

View File

@ -4,7 +4,7 @@
<br>
To reset your password, click the link below:
</p>
<a href="{{.FrontendURL}}?userPasswordReset={{.User.PasswordResetToken}}" title="Reset your password" style="background: rgb(20, 131, 175); -webkit-border-radius: 4px; -moz-border-radius: 4px; border-radius: 4px; border: 1px solid rgb(16, 106, 140); border-bottom-width: 3px; color: rgb(255, 255, 255); font-weight: 700; font-size: 13px; margin: 10px auto; padding: 5px 10px; text-decoration: none; text-align: center; text-rendering: optimizelegibility; text-transform: uppercase; display: block; width: 200px;">
<a href="{{.FrontendURL}}?userPasswordReset={{.User.PasswordResetToken}}" title="Reset your password" style="-webkit-box-shadow: 0.3em 0.3em 1em #b2d0ff; box-shadow: 0.3em 0.3em 1em #b2d0ff; background-color: #1973ff; border-color: transparent; color: #fff; text-decoration: none; text-align: center; text-rendering: optimizelegibility; text-transform: uppercase; font-weight: bold; font-size: 14px; padding: 10px 14px; margin: 10px auto; border-radius: 4px; user-select: none; display: block; width: 280px;font-family:sans-serif">
Reset your password
</a>
<p>