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:
parent
7dc2abb8e5
commit
b2e4fde63a
30 changed files with 568 additions and 239 deletions
|
@ -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.
|
||||||
|
|
|
@ -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
1
go.mod
|
@ -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
2
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/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=
|
||||||
|
|
|
@ -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()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
40
pkg/cron/cron.go
Normal 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()
|
||||||
|
}
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
43
pkg/migration/20201218220204.go
Normal file
43
pkg/migration/20201218220204.go
Normal 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
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
|
@ -53,12 +53,13 @@ func TestLabelTask_ReadAll(t *testing.T) {
|
||||||
Updated: testUpdatedTime,
|
Updated: testUpdatedTime,
|
||||||
CreatedByID: 2,
|
CreatedByID: 2,
|
||||||
CreatedBy: &user.User{
|
CreatedBy: &user.User{
|
||||||
ID: 2,
|
ID: 2,
|
||||||
Username: "user2",
|
Username: "user2",
|
||||||
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
|
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
|
||||||
Issuer: "local",
|
Issuer: "local",
|
||||||
Created: testCreatedTime,
|
EmailRemindersEnabled: true,
|
||||||
Updated: testUpdatedTime,
|
Created: testCreatedTime,
|
||||||
|
Updated: testUpdatedTime,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
@ -48,13 +48,14 @@ func TestLabel_ReadAll(t *testing.T) {
|
||||||
page int
|
page int
|
||||||
}
|
}
|
||||||
user1 := &user.User{
|
user1 := &user.User{
|
||||||
ID: 1,
|
ID: 1,
|
||||||
Username: "user1",
|
Username: "user1",
|
||||||
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
|
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
Issuer: "local",
|
Issuer: "local",
|
||||||
Created: testCreatedTime,
|
EmailRemindersEnabled: true,
|
||||||
Updated: testUpdatedTime,
|
Created: testCreatedTime,
|
||||||
|
Updated: testUpdatedTime,
|
||||||
}
|
}
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
@ -97,12 +98,13 @@ func TestLabel_ReadAll(t *testing.T) {
|
||||||
Updated: testUpdatedTime,
|
Updated: testUpdatedTime,
|
||||||
CreatedByID: 2,
|
CreatedByID: 2,
|
||||||
CreatedBy: &user.User{
|
CreatedBy: &user.User{
|
||||||
ID: 2,
|
ID: 2,
|
||||||
Username: "user2",
|
Username: "user2",
|
||||||
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
|
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
|
||||||
Issuer: "local",
|
Issuer: "local",
|
||||||
Created: testCreatedTime,
|
EmailRemindersEnabled: true,
|
||||||
Updated: testUpdatedTime,
|
Created: testCreatedTime,
|
||||||
|
Updated: testUpdatedTime,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -157,13 +159,14 @@ func TestLabel_ReadOne(t *testing.T) {
|
||||||
Rights web.Rights
|
Rights web.Rights
|
||||||
}
|
}
|
||||||
user1 := &user.User{
|
user1 := &user.User{
|
||||||
ID: 1,
|
ID: 1,
|
||||||
Username: "user1",
|
Username: "user1",
|
||||||
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
|
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
Issuer: "local",
|
Issuer: "local",
|
||||||
Created: testCreatedTime,
|
EmailRemindersEnabled: true,
|
||||||
Updated: testUpdatedTime,
|
Created: testCreatedTime,
|
||||||
|
Updated: testUpdatedTime,
|
||||||
}
|
}
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
@ -217,12 +220,13 @@ func TestLabel_ReadOne(t *testing.T) {
|
||||||
Title: "Label #4 - visible via other task",
|
Title: "Label #4 - visible via other task",
|
||||||
CreatedByID: 2,
|
CreatedByID: 2,
|
||||||
CreatedBy: &user.User{
|
CreatedBy: &user.User{
|
||||||
ID: 2,
|
ID: 2,
|
||||||
Username: "user2",
|
Username: "user2",
|
||||||
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
|
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
|
||||||
Issuer: "local",
|
Issuer: "local",
|
||||||
Created: testCreatedTime,
|
EmailRemindersEnabled: true,
|
||||||
Updated: testUpdatedTime,
|
Created: testCreatedTime,
|
||||||
|
Updated: testUpdatedTime,
|
||||||
},
|
},
|
||||||
Created: testCreatedTime,
|
Created: testCreatedTime,
|
||||||
Updated: testUpdatedTime,
|
Updated: testUpdatedTime,
|
||||||
|
|
|
@ -172,24 +172,26 @@ func TestListUser_ReadAll(t *testing.T) {
|
||||||
want: []*UserWithRight{
|
want: []*UserWithRight{
|
||||||
{
|
{
|
||||||
User: user.User{
|
User: user.User{
|
||||||
ID: 1,
|
ID: 1,
|
||||||
Username: "user1",
|
Username: "user1",
|
||||||
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
|
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
Issuer: "local",
|
Issuer: "local",
|
||||||
Created: testCreatedTime,
|
EmailRemindersEnabled: true,
|
||||||
Updated: testUpdatedTime,
|
Created: testCreatedTime,
|
||||||
|
Updated: testUpdatedTime,
|
||||||
},
|
},
|
||||||
Right: RightRead,
|
Right: RightRead,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
User: user.User{
|
User: user.User{
|
||||||
ID: 2,
|
ID: 2,
|
||||||
Username: "user2",
|
Username: "user2",
|
||||||
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
|
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
|
||||||
Issuer: "local",
|
Issuer: "local",
|
||||||
Created: testCreatedTime,
|
EmailRemindersEnabled: true,
|
||||||
Updated: testUpdatedTime,
|
Created: testCreatedTime,
|
||||||
|
Updated: testUpdatedTime,
|
||||||
},
|
},
|
||||||
Right: RightRead,
|
Right: RightRead,
|
||||||
},
|
},
|
||||||
|
|
|
@ -171,24 +171,26 @@ func TestNamespaceUser_ReadAll(t *testing.T) {
|
||||||
want: []*UserWithRight{
|
want: []*UserWithRight{
|
||||||
{
|
{
|
||||||
User: user.User{
|
User: user.User{
|
||||||
ID: 1,
|
ID: 1,
|
||||||
Username: "user1",
|
Username: "user1",
|
||||||
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
|
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
Issuer: "local",
|
Issuer: "local",
|
||||||
Created: testCreatedTime,
|
EmailRemindersEnabled: true,
|
||||||
Updated: testUpdatedTime,
|
Created: testCreatedTime,
|
||||||
|
Updated: testUpdatedTime,
|
||||||
},
|
},
|
||||||
Right: RightRead,
|
Right: RightRead,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
User: user.User{
|
User: user.User{
|
||||||
ID: 2,
|
ID: 2,
|
||||||
Username: "user2",
|
Username: "user2",
|
||||||
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
|
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
|
||||||
Issuer: "local",
|
Issuer: "local",
|
||||||
Created: testCreatedTime,
|
EmailRemindersEnabled: true,
|
||||||
Updated: testUpdatedTime,
|
Created: testCreatedTime,
|
||||||
|
Updated: testUpdatedTime,
|
||||||
},
|
},
|
||||||
Right: RightRead,
|
Right: RightRead,
|
||||||
},
|
},
|
||||||
|
|
|
@ -31,30 +31,33 @@ import (
|
||||||
func TestTaskCollection_ReadAll(t *testing.T) {
|
func TestTaskCollection_ReadAll(t *testing.T) {
|
||||||
// Dummy users
|
// Dummy users
|
||||||
user1 := &user.User{
|
user1 := &user.User{
|
||||||
ID: 1,
|
ID: 1,
|
||||||
Username: "user1",
|
Username: "user1",
|
||||||
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
|
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
Issuer: "local",
|
Issuer: "local",
|
||||||
Created: testCreatedTime,
|
EmailRemindersEnabled: true,
|
||||||
Updated: testUpdatedTime,
|
Created: testCreatedTime,
|
||||||
|
Updated: testUpdatedTime,
|
||||||
}
|
}
|
||||||
user2 := &user.User{
|
user2 := &user.User{
|
||||||
ID: 2,
|
ID: 2,
|
||||||
Username: "user2",
|
Username: "user2",
|
||||||
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
|
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
|
||||||
Issuer: "local",
|
Issuer: "local",
|
||||||
Created: testCreatedTime,
|
EmailRemindersEnabled: true,
|
||||||
Updated: testUpdatedTime,
|
Created: testCreatedTime,
|
||||||
|
Updated: testUpdatedTime,
|
||||||
}
|
}
|
||||||
user6 := &user.User{
|
user6 := &user.User{
|
||||||
ID: 6,
|
ID: 6,
|
||||||
Username: "user6",
|
Username: "user6",
|
||||||
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
|
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
|
||||||
Issuer: "local",
|
Issuer: "local",
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
Created: testCreatedTime,
|
EmailRemindersEnabled: true,
|
||||||
Updated: testUpdatedTime,
|
Created: testCreatedTime,
|
||||||
|
Updated: testUpdatedTime,
|
||||||
}
|
}
|
||||||
|
|
||||||
loc := config.GetTimeZone()
|
loc := config.GetTimeZone()
|
||||||
|
|
160
pkg/models/task_reminder.go
Normal file
160
pkg/models/task_reminder.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 (
|
||||||
|
|
|
@ -27,122 +27,135 @@ import (
|
||||||
|
|
||||||
func TestListUsersFromList(t *testing.T) {
|
func TestListUsersFromList(t *testing.T) {
|
||||||
testuser1 := &user.User{
|
testuser1 := &user.User{
|
||||||
ID: 1,
|
ID: 1,
|
||||||
Username: "user1",
|
Username: "user1",
|
||||||
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
|
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
Issuer: "local",
|
Issuer: "local",
|
||||||
Created: testCreatedTime,
|
EmailRemindersEnabled: true,
|
||||||
Updated: testUpdatedTime,
|
Created: testCreatedTime,
|
||||||
|
Updated: testUpdatedTime,
|
||||||
}
|
}
|
||||||
testuser2 := &user.User{
|
testuser2 := &user.User{
|
||||||
ID: 2,
|
ID: 2,
|
||||||
Username: "user2",
|
Username: "user2",
|
||||||
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
|
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
|
||||||
Issuer: "local",
|
Issuer: "local",
|
||||||
Created: testCreatedTime,
|
EmailRemindersEnabled: true,
|
||||||
Updated: testUpdatedTime,
|
Created: testCreatedTime,
|
||||||
|
Updated: testUpdatedTime,
|
||||||
}
|
}
|
||||||
testuser3 := &user.User{
|
testuser3 := &user.User{
|
||||||
ID: 3,
|
ID: 3,
|
||||||
Username: "user3",
|
Username: "user3",
|
||||||
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
|
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
|
||||||
PasswordResetToken: "passwordresettesttoken",
|
PasswordResetToken: "passwordresettesttoken",
|
||||||
Issuer: "local",
|
Issuer: "local",
|
||||||
Created: testCreatedTime,
|
EmailRemindersEnabled: true,
|
||||||
Updated: testUpdatedTime,
|
Created: testCreatedTime,
|
||||||
|
Updated: testUpdatedTime,
|
||||||
}
|
}
|
||||||
testuser4 := &user.User{
|
testuser4 := &user.User{
|
||||||
ID: 4,
|
ID: 4,
|
||||||
Username: "user4",
|
Username: "user4",
|
||||||
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
|
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
|
||||||
IsActive: false,
|
IsActive: false,
|
||||||
EmailConfirmToken: "tiepiQueed8ahc7zeeFe1eveiy4Ein8osooxegiephauph2Ael",
|
EmailConfirmToken: "tiepiQueed8ahc7zeeFe1eveiy4Ein8osooxegiephauph2Ael",
|
||||||
Issuer: "local",
|
Issuer: "local",
|
||||||
Created: testCreatedTime,
|
EmailRemindersEnabled: true,
|
||||||
Updated: testUpdatedTime,
|
Created: testCreatedTime,
|
||||||
|
Updated: testUpdatedTime,
|
||||||
}
|
}
|
||||||
testuser5 := &user.User{
|
testuser5 := &user.User{
|
||||||
ID: 5,
|
ID: 5,
|
||||||
Username: "user5",
|
Username: "user5",
|
||||||
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
|
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
|
||||||
IsActive: false,
|
IsActive: false,
|
||||||
EmailConfirmToken: "tiepiQueed8ahc7zeeFe1eveiy4Ein8osooxegiephauph2Ael",
|
EmailConfirmToken: "tiepiQueed8ahc7zeeFe1eveiy4Ein8osooxegiephauph2Ael",
|
||||||
Issuer: "local",
|
Issuer: "local",
|
||||||
Created: testCreatedTime,
|
EmailRemindersEnabled: true,
|
||||||
Updated: testUpdatedTime,
|
Created: testCreatedTime,
|
||||||
|
Updated: testUpdatedTime,
|
||||||
}
|
}
|
||||||
testuser6 := &user.User{
|
testuser6 := &user.User{
|
||||||
ID: 6,
|
ID: 6,
|
||||||
Username: "user6",
|
Username: "user6",
|
||||||
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
|
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
Issuer: "local",
|
Issuer: "local",
|
||||||
Created: testCreatedTime,
|
EmailRemindersEnabled: true,
|
||||||
Updated: testUpdatedTime,
|
Created: testCreatedTime,
|
||||||
|
Updated: testUpdatedTime,
|
||||||
}
|
}
|
||||||
testuser7 := &user.User{
|
testuser7 := &user.User{
|
||||||
ID: 7,
|
ID: 7,
|
||||||
Username: "user7",
|
Username: "user7",
|
||||||
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
|
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
Issuer: "local",
|
Issuer: "local",
|
||||||
Created: testCreatedTime,
|
EmailRemindersEnabled: true,
|
||||||
Updated: testUpdatedTime,
|
Created: testCreatedTime,
|
||||||
|
Updated: testUpdatedTime,
|
||||||
}
|
}
|
||||||
testuser8 := &user.User{
|
testuser8 := &user.User{
|
||||||
ID: 8,
|
ID: 8,
|
||||||
Username: "user8",
|
Username: "user8",
|
||||||
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
|
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
Issuer: "local",
|
Issuer: "local",
|
||||||
Created: testCreatedTime,
|
EmailRemindersEnabled: true,
|
||||||
Updated: testUpdatedTime,
|
Created: testCreatedTime,
|
||||||
|
Updated: testUpdatedTime,
|
||||||
}
|
}
|
||||||
testuser9 := &user.User{
|
testuser9 := &user.User{
|
||||||
ID: 9,
|
ID: 9,
|
||||||
Username: "user9",
|
Username: "user9",
|
||||||
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
|
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
Issuer: "local",
|
Issuer: "local",
|
||||||
Created: testCreatedTime,
|
EmailRemindersEnabled: true,
|
||||||
Updated: testUpdatedTime,
|
Created: testCreatedTime,
|
||||||
|
Updated: testUpdatedTime,
|
||||||
}
|
}
|
||||||
testuser10 := &user.User{
|
testuser10 := &user.User{
|
||||||
ID: 10,
|
ID: 10,
|
||||||
Username: "user10",
|
Username: "user10",
|
||||||
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
|
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
Issuer: "local",
|
Issuer: "local",
|
||||||
Created: testCreatedTime,
|
EmailRemindersEnabled: true,
|
||||||
Updated: testUpdatedTime,
|
Created: testCreatedTime,
|
||||||
|
Updated: testUpdatedTime,
|
||||||
}
|
}
|
||||||
testuser11 := &user.User{
|
testuser11 := &user.User{
|
||||||
ID: 11,
|
ID: 11,
|
||||||
Username: "user11",
|
Username: "user11",
|
||||||
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
|
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
Issuer: "local",
|
Issuer: "local",
|
||||||
Created: testCreatedTime,
|
EmailRemindersEnabled: true,
|
||||||
Updated: testUpdatedTime,
|
Created: testCreatedTime,
|
||||||
|
Updated: testUpdatedTime,
|
||||||
}
|
}
|
||||||
testuser12 := &user.User{
|
testuser12 := &user.User{
|
||||||
ID: 12,
|
ID: 12,
|
||||||
Username: "user12",
|
Username: "user12",
|
||||||
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
|
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
Issuer: "local",
|
Issuer: "local",
|
||||||
Created: testCreatedTime,
|
EmailRemindersEnabled: true,
|
||||||
Updated: testUpdatedTime,
|
Created: testCreatedTime,
|
||||||
|
Updated: testUpdatedTime,
|
||||||
}
|
}
|
||||||
testuser13 := &user.User{
|
testuser13 := &user.User{
|
||||||
ID: 13,
|
ID: 13,
|
||||||
Username: "user13",
|
Username: "user13",
|
||||||
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
|
Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.",
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
Issuer: "local",
|
Issuer: "local",
|
||||||
Created: testCreatedTime,
|
EmailRemindersEnabled: true,
|
||||||
Updated: testUpdatedTime,
|
Created: testCreatedTime,
|
||||||
|
Updated: testUpdatedTime,
|
||||||
}
|
}
|
||||||
|
|
||||||
type args struct {
|
type args struct {
|
||||||
|
|
|
@ -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()))
|
||||||
|
|
|
@ -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(),
|
||||||
|
|
|
@ -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."})
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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": {
|
||||||
|
|
|
@ -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": {
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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;">
|
17
templates/mail/reminder-email.html.tmpl
Normal file
17
templates/mail/reminder-email.html.tmpl
Normal 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"}}
|
9
templates/mail/reminder-email.plain.tmpl
Normal file
9
templates/mail/reminder-email.plain.tmpl
Normal 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!
|
|
@ -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>
|
||||||
|
|
Loading…
Reference in a new issue