Add email reminders (#743)

Fix tests

Expose email reminder setting through jwt

Set reminders on by default

Fix lint

Make user email configurable

Expose email reminder setting through /info

Don't try to send any reminders if none were found

More spacing for buttons

Fix db time format

Enable reminders by default

Make emails look more like the frontend

Add config to disable it

Add sending emaisl

Add getting all task users and reminding them

Add getting the next reminder in a cron

Move task reminder to separate file

Add cron

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: https://kolaente.dev/vikunja/api/pulls/743
Co-Authored-By: konrad <konrad@kola-entertainments.de>
Co-Committed-By: konrad <konrad@kola-entertainments.de>
This commit is contained in:
konrad 2020-12-18 23:21:17 +00:00
parent 7dc2abb8e5
commit b2e4fde63a
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/> # 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** # **You should never use this unless you know exactly what you're doing**
testingtoken: '' 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:
# Database type to use. Supported types are mysql, postgres and sqlite. # 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>` 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 ## database

1
go.mod
View file

@ -61,6 +61,7 @@ require (
github.com/pquerna/cachecontrol v0.0.0-20200921180117-858c6e7e6b7e // indirect github.com/pquerna/cachecontrol v0.0.0-20200921180117-858c6e7e6b7e // indirect
github.com/pquerna/otp v1.3.0 github.com/pquerna/otp v1.3.0
github.com/prometheus/client_golang v1.9.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/samedi/caldav-go v3.0.0+incompatible
github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749 github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749
github.com/shurcooL/vfsgen v0.0.0-20200824052919-0d455de96546 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/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/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/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/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/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=

View file

@ -22,6 +22,8 @@ import (
"os/signal" "os/signal"
"time" "time"
"code.vikunja.io/api/pkg/cron"
"code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/initialize" "code.vikunja.io/api/pkg/initialize"
"code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/log"
@ -70,5 +72,6 @@ var webCmd = &cobra.Command{
if err := e.Shutdown(ctx); err != nil { if err := e.Shutdown(ctx); err != nil {
e.Logger.Fatal(err) e.Logger.Fatal(err)
} }
cron.Stop()
}, },
} }

View file

@ -52,6 +52,7 @@ const (
ServiceEnableTotp Key = `service.enabletotp` ServiceEnableTotp Key = `service.enabletotp`
ServiceSentryDsn Key = `service.sentrydsn` ServiceSentryDsn Key = `service.sentrydsn`
ServiceTestingtoken Key = `service.testingtoken` ServiceTestingtoken Key = `service.testingtoken`
ServiceEnableEmailReminders Key = `service.enableemailreminders`
AuthLocalEnabled Key = `auth.local.enabled` AuthLocalEnabled Key = `auth.local.enabled`
AuthOpenIDEnabled Key = `auth.openid.enabled` AuthOpenIDEnabled Key = `auth.openid.enabled`
@ -233,6 +234,7 @@ func InitDefaultConfig() {
ServiceTimeZone.setDefault("GMT") ServiceTimeZone.setDefault("GMT")
ServiceEnableTaskComments.setDefault(true) ServiceEnableTaskComments.setDefault(true)
ServiceEnableTotp.setDefault(true) ServiceEnableTotp.setDefault(true)
ServiceEnableEmailReminders.setDefault(true)
// Auth // Auth
AuthLocalEnabled.setDefault(true) 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 ( import (
"code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/cron"
"code.vikunja.io/api/pkg/files" "code.vikunja.io/api/pkg/files"
"code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/mail" "code.vikunja.io/api/pkg/mail"
@ -80,4 +81,8 @@ func FullInit() {
// Start the mail daemon // Start the mail daemon
mail.StartMailDaemon() 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

@ -57,6 +57,7 @@ func TestLabelTask_ReadAll(t *testing.T) {
Username: "user2", Username: "user2",
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
Issuer: "local", Issuer: "local",
EmailRemindersEnabled: true,
Created: testCreatedTime, Created: testCreatedTime,
Updated: testUpdatedTime, Updated: testUpdatedTime,
}, },

View file

@ -53,6 +53,7 @@ func TestLabel_ReadAll(t *testing.T) {
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
IsActive: true, IsActive: true,
Issuer: "local", Issuer: "local",
EmailRemindersEnabled: true,
Created: testCreatedTime, Created: testCreatedTime,
Updated: testUpdatedTime, Updated: testUpdatedTime,
} }
@ -101,6 +102,7 @@ func TestLabel_ReadAll(t *testing.T) {
Username: "user2", Username: "user2",
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
Issuer: "local", Issuer: "local",
EmailRemindersEnabled: true,
Created: testCreatedTime, Created: testCreatedTime,
Updated: testUpdatedTime, Updated: testUpdatedTime,
}, },
@ -162,6 +164,7 @@ func TestLabel_ReadOne(t *testing.T) {
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
IsActive: true, IsActive: true,
Issuer: "local", Issuer: "local",
EmailRemindersEnabled: true,
Created: testCreatedTime, Created: testCreatedTime,
Updated: testUpdatedTime, Updated: testUpdatedTime,
} }
@ -221,6 +224,7 @@ func TestLabel_ReadOne(t *testing.T) {
Username: "user2", Username: "user2",
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
Issuer: "local", Issuer: "local",
EmailRemindersEnabled: true,
Created: testCreatedTime, Created: testCreatedTime,
Updated: testUpdatedTime, Updated: testUpdatedTime,
}, },

View file

@ -177,6 +177,7 @@ func TestListUser_ReadAll(t *testing.T) {
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
IsActive: true, IsActive: true,
Issuer: "local", Issuer: "local",
EmailRemindersEnabled: true,
Created: testCreatedTime, Created: testCreatedTime,
Updated: testUpdatedTime, Updated: testUpdatedTime,
}, },
@ -188,6 +189,7 @@ func TestListUser_ReadAll(t *testing.T) {
Username: "user2", Username: "user2",
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
Issuer: "local", Issuer: "local",
EmailRemindersEnabled: true,
Created: testCreatedTime, Created: testCreatedTime,
Updated: testUpdatedTime, Updated: testUpdatedTime,
}, },

View file

@ -176,6 +176,7 @@ func TestNamespaceUser_ReadAll(t *testing.T) {
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
IsActive: true, IsActive: true,
Issuer: "local", Issuer: "local",
EmailRemindersEnabled: true,
Created: testCreatedTime, Created: testCreatedTime,
Updated: testUpdatedTime, Updated: testUpdatedTime,
}, },
@ -187,6 +188,7 @@ func TestNamespaceUser_ReadAll(t *testing.T) {
Username: "user2", Username: "user2",
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
Issuer: "local", Issuer: "local",
EmailRemindersEnabled: true,
Created: testCreatedTime, Created: testCreatedTime,
Updated: testUpdatedTime, Updated: testUpdatedTime,
}, },

View file

@ -36,6 +36,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
IsActive: true, IsActive: true,
Issuer: "local", Issuer: "local",
EmailRemindersEnabled: true,
Created: testCreatedTime, Created: testCreatedTime,
Updated: testUpdatedTime, Updated: testUpdatedTime,
} }
@ -44,6 +45,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
Username: "user2", Username: "user2",
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
Issuer: "local", Issuer: "local",
EmailRemindersEnabled: true,
Created: testCreatedTime, Created: testCreatedTime,
Updated: testUpdatedTime, Updated: testUpdatedTime,
} }
@ -53,6 +55,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
Issuer: "local", Issuer: "local",
IsActive: true, IsActive: true,
EmailRemindersEnabled: true,
Created: testCreatedTime, Created: testCreatedTime,
Updated: testUpdatedTime, Updated: testUpdatedTime,
} }

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" 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 type taskFilterConcatinator string
const ( const (

View file

@ -32,6 +32,7 @@ func TestListUsersFromList(t *testing.T) {
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
IsActive: true, IsActive: true,
Issuer: "local", Issuer: "local",
EmailRemindersEnabled: true,
Created: testCreatedTime, Created: testCreatedTime,
Updated: testUpdatedTime, Updated: testUpdatedTime,
} }
@ -40,6 +41,7 @@ func TestListUsersFromList(t *testing.T) {
Username: "user2", Username: "user2",
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
Issuer: "local", Issuer: "local",
EmailRemindersEnabled: true,
Created: testCreatedTime, Created: testCreatedTime,
Updated: testUpdatedTime, Updated: testUpdatedTime,
} }
@ -49,6 +51,7 @@ func TestListUsersFromList(t *testing.T) {
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
PasswordResetToken: "passwordresettesttoken", PasswordResetToken: "passwordresettesttoken",
Issuer: "local", Issuer: "local",
EmailRemindersEnabled: true,
Created: testCreatedTime, Created: testCreatedTime,
Updated: testUpdatedTime, Updated: testUpdatedTime,
} }
@ -59,6 +62,7 @@ func TestListUsersFromList(t *testing.T) {
IsActive: false, IsActive: false,
EmailConfirmToken: "tiepiQueed8ahc7zeeFe1eveiy4Ein8osooxegiephauph2Ael", EmailConfirmToken: "tiepiQueed8ahc7zeeFe1eveiy4Ein8osooxegiephauph2Ael",
Issuer: "local", Issuer: "local",
EmailRemindersEnabled: true,
Created: testCreatedTime, Created: testCreatedTime,
Updated: testUpdatedTime, Updated: testUpdatedTime,
} }
@ -69,6 +73,7 @@ func TestListUsersFromList(t *testing.T) {
IsActive: false, IsActive: false,
EmailConfirmToken: "tiepiQueed8ahc7zeeFe1eveiy4Ein8osooxegiephauph2Ael", EmailConfirmToken: "tiepiQueed8ahc7zeeFe1eveiy4Ein8osooxegiephauph2Ael",
Issuer: "local", Issuer: "local",
EmailRemindersEnabled: true,
Created: testCreatedTime, Created: testCreatedTime,
Updated: testUpdatedTime, Updated: testUpdatedTime,
} }
@ -78,6 +83,7 @@ func TestListUsersFromList(t *testing.T) {
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
IsActive: true, IsActive: true,
Issuer: "local", Issuer: "local",
EmailRemindersEnabled: true,
Created: testCreatedTime, Created: testCreatedTime,
Updated: testUpdatedTime, Updated: testUpdatedTime,
} }
@ -87,6 +93,7 @@ func TestListUsersFromList(t *testing.T) {
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
IsActive: true, IsActive: true,
Issuer: "local", Issuer: "local",
EmailRemindersEnabled: true,
Created: testCreatedTime, Created: testCreatedTime,
Updated: testUpdatedTime, Updated: testUpdatedTime,
} }
@ -96,6 +103,7 @@ func TestListUsersFromList(t *testing.T) {
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
IsActive: true, IsActive: true,
Issuer: "local", Issuer: "local",
EmailRemindersEnabled: true,
Created: testCreatedTime, Created: testCreatedTime,
Updated: testUpdatedTime, Updated: testUpdatedTime,
} }
@ -105,6 +113,7 @@ func TestListUsersFromList(t *testing.T) {
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
IsActive: true, IsActive: true,
Issuer: "local", Issuer: "local",
EmailRemindersEnabled: true,
Created: testCreatedTime, Created: testCreatedTime,
Updated: testUpdatedTime, Updated: testUpdatedTime,
} }
@ -114,6 +123,7 @@ func TestListUsersFromList(t *testing.T) {
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
IsActive: true, IsActive: true,
Issuer: "local", Issuer: "local",
EmailRemindersEnabled: true,
Created: testCreatedTime, Created: testCreatedTime,
Updated: testUpdatedTime, Updated: testUpdatedTime,
} }
@ -123,6 +133,7 @@ func TestListUsersFromList(t *testing.T) {
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
IsActive: true, IsActive: true,
Issuer: "local", Issuer: "local",
EmailRemindersEnabled: true,
Created: testCreatedTime, Created: testCreatedTime,
Updated: testUpdatedTime, Updated: testUpdatedTime,
} }
@ -132,6 +143,7 @@ func TestListUsersFromList(t *testing.T) {
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
IsActive: true, IsActive: true,
Issuer: "local", Issuer: "local",
EmailRemindersEnabled: true,
Created: testCreatedTime, Created: testCreatedTime,
Updated: testUpdatedTime, Updated: testUpdatedTime,
} }
@ -141,6 +153,7 @@ func TestListUsersFromList(t *testing.T) {
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
IsActive: true, IsActive: true,
Issuer: "local", Issuer: "local",
EmailRemindersEnabled: true,
Created: testCreatedTime, Created: testCreatedTime,
Updated: testUpdatedTime, Updated: testUpdatedTime,
} }

View file

@ -62,6 +62,7 @@ func NewUserJWTAuthtoken(user *user.User) (token string, err error) {
claims["email"] = user.Email claims["email"] = user.Email
claims["exp"] = time.Now().Add(time.Hour * 72).Unix() claims["exp"] = time.Now().Add(time.Hour * 72).Unix()
claims["name"] = user.Name claims["name"] = user.Name
claims["emailRemindersEnabled"] = user.EmailRemindersEnabled
// Generate encoded token and send it as response. // Generate encoded token and send it as response.
return t.SignedString([]byte(config.ServiceJWTSecret.GetString())) return t.SignedString([]byte(config.ServiceJWTSecret.GetString()))

View file

@ -47,6 +47,7 @@ type vikunjaInfos struct {
Legal legalInfo `json:"legal"` Legal legalInfo `json:"legal"`
CaldavEnabled bool `json:"caldav_enabled"` CaldavEnabled bool `json:"caldav_enabled"`
AuthInfo authInfo `json:"auth"` AuthInfo authInfo `json:"auth"`
EmailRemindersEnabled bool `json:"email_reminders_enabled"`
} }
type authInfo struct { type authInfo struct {
@ -87,6 +88,7 @@ func Info(c echo.Context) error {
TaskAttachmentsEnabled: config.ServiceEnableTaskAttachments.GetBool(), TaskAttachmentsEnabled: config.ServiceEnableTaskAttachments.GetBool(),
TotpEnabled: config.ServiceEnableTotp.GetBool(), TotpEnabled: config.ServiceEnableTotp.GetBool(),
CaldavEnabled: config.ServiceEnableCaldav.GetBool(), CaldavEnabled: config.ServiceEnableCaldav.GetBool(),
EmailRemindersEnabled: config.ServiceEnableEmailReminders.GetBool(),
Legal: legalInfo{ Legal: legalInfo{
ImprintURL: config.LegalImprintURL.GetString(), ImprintURL: config.LegalImprintURL.GetString(),
PrivacyPolicyURL: config.LegalPrivacyURL.GetString(), PrivacyPolicyURL: config.LegalPrivacyURL.GetString(),

View file

@ -31,10 +31,12 @@ type UserAvatarProvider struct {
AvatarProvider string `json:"avatar_provider"` AvatarProvider string `json:"avatar_provider"`
} }
// UserName holds the user's name // UserSettings holds all user settings
type UserName struct { type UserSettings struct {
// The new name of the current user. // The new name of the current user.
Name string `json:"name"` 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 // 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."}) return c.JSON(http.StatusOK, &models.Message{Message: "Avatar was changed successfully."})
} }
// ChangeUserName is the handler to change the name of the current user // UpdateGeneralUserSettings is the handler to change general user settings
// @Summary Change the current user's name // @Summary Change general user settings of the current user.
// @Description Changes the current user's name. It is also possible to reset the name.
// @tags user // @tags user
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Security JWTKeyAuth // @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 // @Success 200 {object} models.Message
// @Failure 400 {object} web.HTTPError "Something's invalid." // @Failure 400 {object} web.HTTPError "Something's invalid."
// @Failure 500 {object} models.Message "Internal server error." // @Failure 500 {object} models.Message "Internal server error."
// @Router /user/settings/name [post] // @Router /user/settings/general [post]
func UpdateUserName(c echo.Context) error { func UpdateGeneralUserSettings(c echo.Context) error {
un := &UserName{} us := &UserSettings{}
err := c.Bind(un) err := c.Bind(us)
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Bad user name provided.") return echo.NewHTTPError(http.StatusBadRequest, "Bad user name provided.")
} }
@ -133,12 +134,13 @@ func UpdateUserName(c echo.Context) error {
return handler.HandleHTTPError(err, c) return handler.HandleHTTPError(err, c)
} }
user.Name = un.Name user.Name = us.Name
user.EmailRemindersEnabled = us.EmailRemindersEnabled
_, err = user2.UpdateUser(user) _, err = user2.UpdateUser(user)
if err != nil { if err != nil {
return handler.HandleHTTPError(err, c) 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.GET("/settings/avatar", apiv1.GetUserAvatarProvider)
u.POST("/settings/avatar", apiv1.ChangeUserAvatarProvider) u.POST("/settings/avatar", apiv1.ChangeUserAvatarProvider)
u.PUT("/settings/avatar/upload", apiv1.UploadAvatar) u.PUT("/settings/avatar/upload", apiv1.UploadAvatar)
u.POST("/settings/name", apiv1.UpdateUserName) u.POST("/settings/general", apiv1.UpdateGeneralUserSettings)
if config.ServiceEnableTotp.GetBool() { if config.ServiceEnableTotp.GetBool() {
u.GET("/settings/totp", apiv1.UserTOTP) u.GET("/settings/totp", apiv1.UserTOTP)

View file

@ -6296,14 +6296,13 @@ var doc = `{
} }
} }
}, },
"/user/settings/name": { "/user/settings/general": {
"post": { "post": {
"security": [ "security": [
{ {
"JWTKeyAuth": [] "JWTKeyAuth": []
} }
], ],
"description": "Changes the current user's name. It is also possible to reset the name.",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@ -6313,15 +6312,15 @@ var doc = `{
"tags": [ "tags": [
"user" "user"
], ],
"summary": "Change the current user's name", "summary": "Change general user settings of the current user.",
"parameters": [ "parameters": [
{ {
"description": "The updated user name", "description": "The updated user settings",
"name": "avatar", "name": "avatar",
"in": "body", "in": "body",
"required": true, "required": true,
"schema": { "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": { "v1.UserPassword": {
"type": "object", "type": "object",
"properties": { "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": { "v1.authInfo": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -8090,6 +8093,9 @@ var doc = `{
"caldav_enabled": { "caldav_enabled": {
"type": "boolean" "type": "boolean"
}, },
"email_reminders_enabled": {
"type": "boolean"
},
"enabled_background_providers": { "enabled_background_providers": {
"type": "array", "type": "array",
"items": { "items": {

View file

@ -6279,14 +6279,13 @@
} }
} }
}, },
"/user/settings/name": { "/user/settings/general": {
"post": { "post": {
"security": [ "security": [
{ {
"JWTKeyAuth": [] "JWTKeyAuth": []
} }
], ],
"description": "Changes the current user's name. It is also possible to reset the name.",
"consumes": [ "consumes": [
"application/json" "application/json"
], ],
@ -6296,15 +6295,15 @@
"tags": [ "tags": [
"user" "user"
], ],
"summary": "Change the current user's name", "summary": "Change general user settings of the current user.",
"parameters": [ "parameters": [
{ {
"description": "The updated user name", "description": "The updated user settings",
"name": "avatar", "name": "avatar",
"in": "body", "in": "body",
"required": true, "required": true,
"schema": { "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": { "v1.UserPassword": {
"type": "object", "type": "object",
"properties": { "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": { "v1.authInfo": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -8073,6 +8076,9 @@
"caldav_enabled": { "caldav_enabled": {
"type": "boolean" "type": "boolean"
}, },
"email_reminders_enabled": {
"type": "boolean"
},
"enabled_background_providers": { "enabled_background_providers": {
"type": "array", "type": "array",
"items": { "items": {

View file

@ -947,12 +947,6 @@ definitions:
description: The avatar provider. Valid types are `gravatar` (uses the user email), `upload`, `initials`, `default`. description: The avatar provider. Valid types are `gravatar` (uses the user email), `upload`, `initials`, `default`.
type: string type: string
type: object type: object
v1.UserName:
properties:
name:
description: The new name of the current user.
type: string
type: object
v1.UserPassword: v1.UserPassword:
properties: properties:
new_password: new_password:
@ -960,6 +954,15 @@ definitions:
old_password: old_password:
type: string type: string
type: object 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: v1.authInfo:
properties: properties:
local: local:
@ -1000,6 +1003,8 @@ definitions:
type: array type: array
caldav_enabled: caldav_enabled:
type: boolean type: boolean
email_reminders_enabled:
type: boolean
enabled_background_providers: enabled_background_providers:
items: items:
type: string type: string
@ -5100,18 +5105,17 @@ paths:
summary: Update email address summary: Update email address
tags: tags:
- user - user
/user/settings/name: /user/settings/general:
post: post:
consumes: consumes:
- application/json - application/json
description: Changes the current user's name. It is also possible to reset the name.
parameters: parameters:
- description: The updated user name - description: The updated user settings
in: body in: body
name: avatar name: avatar
required: true required: true
schema: schema:
$ref: '#/definitions/v1.UserName' $ref: '#/definitions/v1.UserSettings'
produces: produces:
- application/json - application/json
responses: responses:
@ -5129,7 +5133,7 @@ paths:
$ref: '#/definitions/models.Message' $ref: '#/definitions/models.Message'
security: security:
- JWTKeyAuth: [] - JWTKeyAuth: []
summary: Change the current user's name summary: Change general user settings of the current user.
tags: tags:
- user - user
/user/settings/totp: /user/settings/totp:

View file

@ -62,6 +62,9 @@ type User struct {
Issuer string `xorm:"text null" json:"-"` Issuer string `xorm:"text null" json:"-"`
Subject 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. // A timestamp when this task was created. You cannot change this value.
Created time.Time `xorm:"created not null" json:"created"` Created time.Time `xorm:"created not null" json:"created"`
// A timestamp when this task was last updated. You cannot change this value. // 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", "avatar_file_id",
"is_active", "is_active",
"name", "name",
"email_reminders_enabled",
). ).
Update(user) Update(user)
if err != nil { if err != nil {

View file

@ -8,7 +8,7 @@
<br/> <br/>
To confirm your email address, click the link below: To confirm your email address, click the link below:
</p> </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 Confirm your email address
</a> </a>
<p> <p>

View file

@ -3,10 +3,10 @@
<head> <head>
<meta name="viewport" content="width: display-width;"> <meta name="viewport" content="width: display-width;">
</head> </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: 100%; font-family: 'Open Sans', sans-serif; text-rendering: optimizeLegibility">
<div style="width: 600px; margin: 0 auto; text-align: justify;"> <div style="width: 600px; margin: 0 auto; text-align: justify;">
<h1 style="font-size: 30px; text-align: center;"> <h1 style="font-size: 30px; text-align: center;">
<img src="{{.FrontendURL}}images/logo-full.svg" style="height: 75px;" alt="Vikunja"/> <img src="{{.FrontendURL}}images/logo-full.svg" style="height: 75px;" alt="Vikunja"/>
</h1> </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> <br>
To reset your password, click the link below: To reset your password, click the link below:
</p> </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 Reset your password
</a> </a>
<p> <p>