1
0

Add notifications package for easy sending of notifications (#779)

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: https://kolaente.dev/vikunja/api/pulls/779
Co-authored-by: konrad <konrad@kola-entertainments.de>
Co-committed-by: konrad <konrad@kola-entertainments.de>
This commit is contained in:
konrad
2021-02-07 21:05:09 +00:00
parent 9fe46f9a61
commit 015ca310e9
32 changed files with 1109 additions and 275 deletions

View File

@ -17,9 +17,11 @@
package cmd
import (
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/initialize"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/mail"
"code.vikunja.io/api/pkg/notifications"
"github.com/spf13/cobra"
)
@ -39,8 +41,20 @@ var testmailCmd = &cobra.Command{
},
Run: func(cmd *cobra.Command, args []string) {
log.Info("Sending testmail...")
email := args[0]
if err := mail.SendTestMail(email); err != nil {
message := notifications.NewMail().
From(config.MailerFromEmail.GetString()).
To(args[0]).
Subject("Test from Vikunja").
Line("This is a test mail!").
Line("If you received this, Vikunja is correctly set up to send emails.").
Action("Go to your instance", config.ServiceFrontendurl.GetString())
opts, err := notifications.RenderMail(message)
if err != nil {
log.Errorf("Error sending test mail: %s", err.Error())
return
}
if err := mail.SendTestMail(opts); err != nil {
log.Errorf("Error sending test mail: %s", err.Error())
return
}

View File

@ -17,19 +17,14 @@
package mail
import (
"bytes"
"html/template"
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/static"
"code.vikunja.io/api/pkg/utils"
"github.com/shurcooL/httpfs/html/vfstemplate"
"gopkg.in/gomail.v2"
)
// Opts holds infos for a mail
type Opts struct {
From string
To string
Subject string
Message string
@ -56,7 +51,7 @@ type header struct {
// SendTestMail sends a test mail to a receipient.
// It works without a queue.
func SendTestMail(to string) error {
func SendTestMail(opts *Opts) error {
if config.MailerHost.GetString() == "" {
log.Warning("Mailer seems to be not configured! Please see the config docs for more details.")
return nil
@ -69,19 +64,17 @@ func SendTestMail(to string) error {
}
defer s.Close()
m := gomail.NewMessage()
m.SetHeader("From", config.MailerFromEmail.GetString())
m.SetHeader("To", to)
m.SetHeader("Subject", "Test from Vikunja")
m.SetBody("text/plain", "This is a test mail! If you got this, Vikunja is correctly set up to send emails.")
m := sendMail(opts)
return gomail.Send(s, m)
}
// SendMail puts a mail in the queue
func SendMail(opts *Opts) {
func sendMail(opts *Opts) *gomail.Message {
m := gomail.NewMessage()
m.SetHeader("From", config.MailerFromEmail.GetString())
if opts.From == "" {
opts.From = config.MailerFromEmail.GetString()
}
m.SetHeader("From", opts.From)
m.SetHeader("To", opts.To)
m.SetHeader("Subject", opts.Subject)
for _, h := range opts.Headers {
@ -97,49 +90,16 @@ func SendMail(opts *Opts) {
m.SetBody("text/plain", opts.Message)
m.AddAlternative("text/html", opts.HTMLMessage)
}
return m
}
// SendMail puts a mail in the queue
func SendMail(opts *Opts) {
if isUnderTest {
sentMails = append(sentMails, opts)
return
}
m := sendMail(opts)
Queue <- m
}
// Template holds a pointer about a template
type Template struct {
Templates *template.Template
}
// SendMailWithTemplate parses a template and sends it via mail
func SendMailWithTemplate(to, subject, tpl string, data map[string]interface{}) {
var htmlContent bytes.Buffer
var plainContent bytes.Buffer
t, err := vfstemplate.ParseGlob(static.Templates, nil, "*.tmpl")
if err != nil {
log.Errorf("SendMailWithTemplate: ParseGlob: %v", err)
return
}
boundary := "np" + utils.MakeRandomString(13)
data["Boundary"] = boundary
data["FrontendURL"] = config.ServiceFrontendurl.GetString()
if err := t.ExecuteTemplate(&htmlContent, tpl+".html.tmpl", data); err != nil {
log.Errorf("ExecuteTemplate: %v", err)
return
}
if err := t.ExecuteTemplate(&plainContent, tpl+".plain.tmpl", data); err != nil {
log.Errorf("ExecuteTemplate: %v", err)
return
}
opts := &Opts{
To: to,
Subject: subject,
Message: plainContent.String(),
HTMLMessage: htmlContent.String(),
ContentType: ContentTypeMultipart,
Boundary: boundary,
}
SendMail(opts)
}

View File

@ -14,24 +14,35 @@
// You should have received a copy of the GNU Affero General Public Licensee
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// +build ignore
package main
package mail
import (
"log"
"net/http"
"reflect"
"testing"
"github.com/shurcooL/vfsgen"
"github.com/stretchr/testify/assert"
)
func main() {
err := vfsgen.Generate(http.Dir(`../../templates/mail`), vfsgen.Options{
PackageName: "static",
BuildTags: "!dev",
VariableName: "Templates",
})
if err != nil {
log.Fatalln(err)
}
var (
isUnderTest bool
sentMails []*Opts
)
// Fake stops any mails from being sent and instead allows for recording and querying them.
func Fake() {
isUnderTest = true
sentMails = nil
}
// AssertSent asserts if a mail has been sent
func AssertSent(t *testing.T, opts *Opts) {
var found bool
for _, testMail := range sentMails {
if reflect.DeepEqual(testMail, opts) {
found = true
break
}
}
assert.True(t, found, "Failed to assert mail '%v' has been sent.", opts)
}

View File

@ -14,8 +14,35 @@
// You should have received a copy of the GNU Affero General Public Licensee
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//go:generate go run -tags=dev templates_generate.go
package migration
package static
import (
"time"
// The single purpose of this file is to invoke the generation of static files
"src.techknowlogick.com/xormigrate"
"xorm.io/xorm"
)
type notifications20210207192805 struct {
ID int64 `xorm:"bigint autoincr not null unique pk" json:"id"`
NotifiableID int64 `xorm:"bigint not null" json:"-"`
Notification interface{} `xorm:"json not null" json:"notification"`
Created time.Time `xorm:"created not null" json:"created"`
}
func (notifications20210207192805) TableName() string {
return "notifications"
}
func init() {
migrations = append(migrations, &xormigrate.Migration{
ID: "20210207192805",
Description: "Add notifications table",
Migrate: func(tx *xorm.Engine) error {
return tx.Sync2(notifications20210207192805{})
},
Rollback: func(tx *xorm.Engine) error {
return nil
},
})
}

View File

@ -0,0 +1,47 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-2021 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 Affero General Public Licensee 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 Affero General Public Licensee for more details.
//
// You should have received a copy of the GNU Affero General Public Licensee
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package models
import (
"strconv"
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/notifications"
"code.vikunja.io/api/pkg/user"
)
// ReminderDueNotification represents a ReminderDueNotification notification
type ReminderDueNotification struct {
User *user.User
Task *Task
}
// ToMail returns the mail notification for ReminderDueNotification
func (n *ReminderDueNotification) ToMail() *notifications.Mail {
return notifications.NewMail().
To(n.User.Email).
Subject(`Reminder for "`+n.Task.Title+`"`).
Greeting("Hi "+n.User.GetName()+",").
Line(`This is a friendly reminder of the task "`+n.Task.Title+`".`).
Action("Open Task", config.ServiceFrontendurl.GetString()+"tasks/"+strconv.FormatInt(n.Task.ID, 10)).
Line("Have a nice day!")
}
// ToDB returns the ReminderDueNotification notification in a format which can be saved in the db
func (n *ReminderDueNotification) ToDB() interface{} {
return nil
}

View File

@ -19,13 +19,14 @@ package models
import (
"time"
"code.vikunja.io/api/pkg/notifications"
"code.vikunja.io/api/pkg/db"
"xorm.io/xorm"
"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"
)
@ -61,11 +62,6 @@ func getTaskUsersForTasks(s *xorm.Session, taskIDs []int64) (taskUsers []*taskUs
return
}
assignees, err := getRawTaskAssigneesForTasks(s, taskIDs)
if err != nil {
return
}
taskMap := make(map[int64]*Task, len(taskIDs))
err = s.In("id", taskIDs).Find(&taskMap)
if err != nil {
@ -73,12 +69,22 @@ func getTaskUsersForTasks(s *xorm.Session, taskIDs []int64) (taskUsers []*taskUs
}
for _, taskID := range taskIDs {
u, exists := creators[taskMap[taskID].CreatedByID]
if !exists {
continue
}
taskUsers = append(taskUsers, &taskUser{
Task: taskMap[taskID],
User: creators[taskMap[taskID].CreatedByID],
User: u,
})
}
assignees, err := getRawTaskAssigneesForTasks(s, taskIDs)
if err != nil {
return
}
for _, assignee := range assignees {
if !assignee.EmailRemindersEnabled { // Can't filter that through a query directly since we're using another function
continue
@ -168,12 +174,17 @@ func RegisterReminderCron() {
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,
n := &ReminderDueNotification{
User: u.User,
Task: u.Task,
}
err = notifications.Notify(u.User, n)
if err != nil {
log.Errorf("[Task Reminder Cron] Could not notify user %d: %s", u.User.ID, err)
return
}
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)
}
})

93
pkg/notifications/mail.go Normal file
View File

@ -0,0 +1,93 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-2021 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 Affero General Public Licensee 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 Affero General Public Licensee for more details.
//
// You should have received a copy of the GNU Affero General Public Licensee
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package notifications
import "code.vikunja.io/api/pkg/mail"
// Mail is a mail message
type Mail struct {
from string
to string
subject string
actionText string
actionURL string
greeting string
introLines []string
outroLines []string
}
// NewMail creates a new mail object with a default greeting
func NewMail() *Mail {
return &Mail{
greeting: "Hi,",
}
}
// From sets the from name and email address
func (m *Mail) From(from string) *Mail {
m.from = from
return m
}
// To sets the recipient of the mail message
func (m *Mail) To(to string) *Mail {
m.to = to
return m
}
// Subject sets the subject of the mail message
func (m *Mail) Subject(subject string) *Mail {
m.subject = subject
return m
}
// Greeting sets the greeting of the mail message
func (m *Mail) Greeting(greeting string) *Mail {
m.greeting = greeting
return m
}
// Action sets any action a mail might have
func (m *Mail) Action(text, url string) *Mail {
m.actionText = text
m.actionURL = url
return m
}
// Line adds a line of text to the mail
func (m *Mail) Line(line string) *Mail {
if m.actionURL == "" {
m.introLines = append(m.introLines, line)
return m
}
m.outroLines = append(m.outroLines, line)
return m
}
// SendMail passes the notification to the mailing queue for sending
func SendMail(m *Mail) error {
opts, err := RenderMail(m)
if err != nil {
return err
}
mail.SendMail(opts)
return nil
}

View File

@ -0,0 +1,137 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-2021 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 Affero General Public Licensee 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 Affero General Public Licensee for more details.
//
// You should have received a copy of the GNU Affero General Public Licensee
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package notifications
import (
"bytes"
templatehtml "html/template"
templatetext "text/template"
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/mail"
"code.vikunja.io/api/pkg/utils"
)
const mailTemplatePlain = `
{{ .Greeting }}
{{ range $line := .IntroLines}}
{{ $line }}
{{ end }}
{{ if .ActionURL }}{{ .ActionText }}:
{{ .ActionURL }}{{end}}
{{ range $line := .OutroLines}}
{{ $line }}
{{ end }}`
const mailTemplateHTML = `
<!doctype html>
<html style="width: 100%; height: 100%; padding: 0; margin: 0;">
<head>
<meta name="viewport" content="width: display-width;">
</head>
<body style="width: 100%; padding: 0; margin: 0; background: #f3f4f6">
<div style="width: 100%; font-family: 'Open Sans', sans-serif; text-rendering: optimizeLegibility">
<div style="width: 600px; margin: 0 auto; text-align: justify;">
<h1 style="font-size: 30px; text-align: center;">
<img src="{{.FrontendURL}}images/logo-full.svg" style="height: 75px;" alt="Vikunja"/>
</h1>
<div style="border: 1px solid #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;">
<p>
{{ .Greeting }}
</p>
{{ range $line := .IntroLines}}
<p>
{{ $line }}
</p>
{{ end }}
{{ if .ActionURL }}
<a href="{{ .ActionURL }}" title="{{ .ActionText }}"
style="position: relative;text-decoration:none;display: block;border-radius: 4px;cursor: pointer;padding-bottom: 8px;padding-left: 14px;padding-right: 14px;padding-top: 8px;width:280px;margin:10px auto;text-align: center;white-space: nowrap;border: 0;text-transform: uppercase;font-size: 14px;font-weight: 700;-webkit-box-shadow: 0 3px 6px rgba(107,114,128,.12),0 2px 4px rgba(107,114,128,.1);box-shadow: 0 3px 6px rgba(107,114,128,.12),0 2px 4px rgba(107,114,128,.1);background-color: #1973ff;border-color: transparent;color: #fff;">
{{ .ActionText }}
</a>
{{end}}
{{ range $line := .OutroLines}}
<p>
{{ $line }}
</p>
{{ end }}
{{ if .ActionURL }}
<p style="color: #9CA3AF;font-size:12px;border-top: 1px solid #dbdbdb;margin-top:20px;padding-top:20px;">
If the button above doesn't work, copy the url below and paste it in your browsers address bar:<br/>
{{ .ActionURL }}
</p>
{{ end }}
</div>
</div>
</div>
</body>
</html>
`
// RenderMail takes a precomposed mail message and renders it into a ready to send mail.Opts object
func RenderMail(m *Mail) (mailOpts *mail.Opts, err error) {
var htmlContent bytes.Buffer
var plainContent bytes.Buffer
plain, err := templatetext.New("mail-plain").Parse(mailTemplatePlain)
if err != nil {
return nil, err
}
html, err := templatehtml.New("mail-plain").Parse(mailTemplateHTML)
if err != nil {
return nil, err
}
boundary := "np" + utils.MakeRandomString(13)
data := make(map[string]interface{})
data["Greeting"] = m.greeting
data["IntroLines"] = m.introLines
data["OutroLines"] = m.outroLines
data["ActionText"] = m.actionText
data["ActionURL"] = m.actionURL
data["Boundary"] = boundary
data["FrontendURL"] = config.ServiceFrontendurl.GetString()
err = plain.Execute(&plainContent, data)
if err != nil {
return nil, err
}
err = html.Execute(&htmlContent, data)
if err != nil {
return nil, err
}
mailOpts = &mail.Opts{
From: m.from,
To: m.to,
Subject: m.subject,
ContentType: mail.ContentTypeMultipart,
Message: plainContent.String(),
HTMLMessage: htmlContent.String(),
Boundary: boundary,
}
return mailOpts, nil
}

View File

@ -0,0 +1,173 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-2021 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 Affero General Public Licensee 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 Affero General Public Licensee for more details.
//
// You should have received a copy of the GNU Affero General Public Licensee
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package notifications
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestNewMail(t *testing.T) {
t.Run("Full mail", func(t *testing.T) {
mail := NewMail().
From("test@example.com").
To("test@otherdomain.com").
Subject("Testmail").
Greeting("Hi there,").
Line("This is a line").
Line("And another one").
Action("the actiopn", "https://example.com").
Line("This should be an outro line").
Line("And one more, because why not?")
assert.Equal(t, "test@example.com", mail.from)
assert.Equal(t, "test@otherdomain.com", mail.to)
assert.Equal(t, "Testmail", mail.subject)
assert.Equal(t, "Hi there,", mail.greeting)
assert.Len(t, mail.introLines, 2)
assert.Equal(t, "This is a line", mail.introLines[0])
assert.Equal(t, "And another one", mail.introLines[1])
assert.Len(t, mail.outroLines, 2)
assert.Equal(t, "This should be an outro line", mail.outroLines[0])
assert.Equal(t, "And one more, because why not?", mail.outroLines[1])
})
t.Run("No greeting", func(t *testing.T) {
mail := NewMail().
From("test@example.com").
To("test@otherdomain.com").
Subject("Testmail").
Line("This is a line").
Line("And another one")
assert.Equal(t, "test@example.com", mail.from)
assert.Equal(t, "test@otherdomain.com", mail.to)
assert.Equal(t, "Testmail", mail.subject)
assert.Equal(t, "Hi,", mail.greeting) // Default greeting
assert.Len(t, mail.introLines, 2)
assert.Equal(t, "This is a line", mail.introLines[0])
assert.Equal(t, "And another one", mail.introLines[1])
})
t.Run("No action", func(t *testing.T) {
mail := NewMail().
From("test@example.com").
To("test@otherdomain.com").
Subject("Testmail").
Line("This is a line").
Line("And another one").
Line("This should be an outro line").
Line("And one more, because why not?")
assert.Equal(t, "test@example.com", mail.from)
assert.Equal(t, "test@otherdomain.com", mail.to)
assert.Equal(t, "Testmail", mail.subject)
assert.Len(t, mail.introLines, 4)
assert.Equal(t, "This is a line", mail.introLines[0])
assert.Equal(t, "And another one", mail.introLines[1])
assert.Equal(t, "This should be an outro line", mail.introLines[2])
assert.Equal(t, "And one more, because why not?", mail.introLines[3])
})
}
func TestRenderMail(t *testing.T) {
mail := NewMail().
From("test@example.com").
To("test@otherdomain.com").
Subject("Testmail").
Greeting("Hi there,").
Line("This is a line").
Line("And another one").
Action("The action", "https://example.com").
Line("This should be an outro line").
Line("And one more, because why not?")
mailopts, err := RenderMail(mail)
assert.NoError(t, err)
assert.Equal(t, mail.from, mailopts.From)
assert.Equal(t, mail.to, mailopts.To)
assert.Equal(t, `
Hi there,
This is a line
And another one
The action:
https://example.com
This should be an outro line
And one more, because why not?
`, mailopts.Message)
assert.Equal(t, `
<!doctype html>
<html style="width: 100%; height: 100%; padding: 0; margin: 0;">
<head>
<meta name="viewport" content="width: display-width;">
</head>
<body style="width: 100%; padding: 0; margin: 0; background: #f3f4f6">
<div style="width: 100%; font-family: 'Open Sans', sans-serif; text-rendering: optimizeLegibility">
<div style="width: 600px; margin: 0 auto; text-align: justify;">
<h1 style="font-size: 30px; text-align: center;">
<img src="images/logo-full.svg" style="height: 75px;" alt="Vikunja"/>
</h1>
<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;">
<p>
Hi there,
</p>
<p>
This is a line
</p>
<p>
And another one
</p>
<a href="https://example.com" title="The action"
style="position: relative;text-decoration:none;display: block;border-radius: 4px;cursor: pointer;padding-bottom: 8px;padding-left: 14px;padding-right: 14px;padding-top: 8px;width:280px;margin:10px auto;text-align: center;white-space: nowrap;border: 0;text-transform: uppercase;font-size: 14px;font-weight: 700;-webkit-box-shadow: 0 3px 6px rgba(107,114,128,.12),0 2px 4px rgba(107,114,128,.1);box-shadow: 0 3px 6px rgba(107,114,128,.12),0 2px 4px rgba(107,114,128,.1);background-color: #1973ff;border-color: transparent;color: #fff;">
The action
</a>
<p>
This should be an outro line
</p>
<p>
And one more, because why not?
</p>
<p style="color: #9CA3AF;font-size:12px;border-top: 1px solid #dbdbdb;margin-top:20px;padding-top:20px;">
If the button above doesn't work, copy the url below and paste it in your browsers address bar:<br/>
https://example.com
</p>
</div>
</div>
</div>
</body>
</html>
`, mailopts.HTMLMessage)
}

View File

@ -14,10 +14,42 @@
// You should have received a copy of the GNU Affero General Public Licensee
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// +build dev
package notifications
package static
import (
"os"
"testing"
import "net/http"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/mail"
var Templates http.FileSystem = http.Dir(`templates/mail`)
"code.vikunja.io/api/pkg/config"
)
// SetupTests initializes all db tests
func SetupTests() {
var err error
x, err := db.CreateTestEngine()
if err != nil {
log.Fatal(err)
}
err = x.Sync2(&DatabaseNotification{})
if err != nil {
log.Fatal(err)
}
}
// TestMain is the main test function used to bootstrap the test env
func TestMain(m *testing.M) {
// Set default config
config.InitDefaultConfig()
// We need to set the root path even if we're not using the config, otherwise fixtures are not loaded correctly
config.ServiceRootpath.Set(os.Getenv("VIKUNJA_SERVICE_ROOTPATH"))
SetupTests()
mail.Fake()
os.Exit(m.Run())
}

View File

@ -0,0 +1,106 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-2021 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 Affero General Public Licensee 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 Affero General Public Licensee for more details.
//
// You should have received a copy of the GNU Affero General Public Licensee
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package notifications
import (
"encoding/json"
"time"
"code.vikunja.io/api/pkg/db"
)
// Notification is a notification which can be sent via mail or db.
type Notification interface {
ToMail() *Mail
ToDB() interface{}
}
// Notifiable is an entity which can be notified. Usually a user.
type Notifiable interface {
// Should return the email address this notifiable has.
RouteForMail() string
// Should return the id of the notifiable entity
RouteForDB() int64
}
// DatabaseNotification represents a notification that was saved to the database
type DatabaseNotification struct {
// The unique, numeric id of this notification.
ID int64 `xorm:"bigint autoincr not null unique pk" json:"id"`
// The ID of the notifiable this notification is associated with.
NotifiableID int64 `xorm:"bigint not null" json:"-"`
// The actual content of the notification.
Notification interface{} `xorm:"json not null" json:"notification"`
// A timestamp when this notification was created. You cannot change this value.
Created time.Time `xorm:"created not null" json:"created"`
}
// TableName resolves to a better table name for notifications
func (d *DatabaseNotification) TableName() string {
return "notifications"
}
// Notify notifies a notifiable of a notification
func Notify(notifiable Notifiable, notification Notification) (err error) {
err = notifyMail(notifiable, notification)
if err != nil {
return
}
return notifyDB(notifiable, notification)
}
func notifyMail(notifiable Notifiable, notification Notification) error {
mail := notification.ToMail()
if mail == nil {
return nil
}
mail.To(notifiable.RouteForMail())
return SendMail(mail)
}
func notifyDB(notifiable Notifiable, notification Notification) (err error) {
dbContent := notification.ToDB()
if dbContent == nil {
return nil
}
content, err := json.Marshal(dbContent)
if err != nil {
return err
}
s := db.NewSession()
dbNotification := &DatabaseNotification{
NotifiableID: notifiable.RouteForDB(),
Notification: content,
}
_, err = s.Insert(dbNotification)
if err != nil {
_ = s.Rollback()
return err
}
return s.Commit()
}

View File

@ -0,0 +1,86 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-2021 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 Affero General Public Licensee 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 Affero General Public Licensee for more details.
//
// You should have received a copy of the GNU Affero General Public Licensee
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package notifications
import (
"testing"
"code.vikunja.io/api/pkg/db"
"github.com/stretchr/testify/assert"
"xorm.io/xorm/schemas"
)
type testNotification struct {
Test string
OtherValue int64
}
// ToMail returns the mail notification for testNotification
func (n *testNotification) ToMail() *Mail {
return NewMail().
Subject("Test Notification").
Line(n.Test)
}
// ToDB returns the testNotification notification in a format which can be saved in the db
func (n *testNotification) ToDB() interface{} {
data := make(map[string]interface{}, 2)
data["test"] = n.Test
data["other_value"] = n.OtherValue
return data
}
type testNotifiable struct {
}
// RouteForMail routes a test notification for mail
func (t *testNotifiable) RouteForMail() string {
return "some@email.com"
}
// RouteForDB routes a test notification for db
func (t *testNotifiable) RouteForDB() int64 {
return 42
}
func TestNotify(t *testing.T) {
tn := &testNotification{
Test: "somethingsomething",
OtherValue: 42,
}
tnf := &testNotifiable{}
err := Notify(tnf, tn)
assert.NoError(t, err)
vals := map[string]interface{}{
"notifiable_id": 42,
"notification": "'{\"other_value\":42,\"test\":\"somethingsomething\"}'",
}
if db.Type() == schemas.POSTGRES {
vals["notification::jsonb"] = vals["notification"].(string) + "::jsonb"
delete(vals, "notification")
}
if db.Type() == schemas.SQLITE {
vals["CAST(notification AS BLOB)"] = "CAST(" + vals["notification"].(string) + " AS BLOB)"
delete(vals, "notification")
}
db.AssertExists(t, "notifications", vals, true)
}

94
pkg/user/notifications.go Normal file
View File

@ -0,0 +1,94 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-2021 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 Affero General Public Licensee 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 Affero General Public Licensee for more details.
//
// You should have received a copy of the GNU Affero General Public Licensee
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package user
import (
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/notifications"
)
// EmailConfirmNotification represents a EmailConfirmNotification notification
type EmailConfirmNotification struct {
User *User
IsNew bool
}
// ToMail returns the mail notification for EmailConfirmNotification
func (n *EmailConfirmNotification) ToMail() *notifications.Mail {
subject := n.User.GetName() + ", please confirm your email address at Vikunja"
if n.IsNew {
subject = n.User.GetName() + " + Vikunja = <3"
}
nn := notifications.NewMail().
Subject(subject).
Greeting("Hi " + n.User.GetName() + ",")
if n.IsNew {
nn.Line("Welcome to Vikunja!")
}
return nn.
Line("To confirm your email address, click the link below:").
Action("Confirm your email address", config.ServiceFrontendurl.GetString()+"?userEmailConfirm="+n.User.EmailConfirmToken).
Line("Have a nice day!")
}
// ToDB returns the EmailConfirmNotification notification in a format which can be saved in the db
func (n *EmailConfirmNotification) ToDB() interface{} {
return nil
}
// PasswordChangedNotification represents a PasswordChangedNotification notification
type PasswordChangedNotification struct {
User *User
}
// ToMail returns the mail notification for PasswordChangedNotification
func (n *PasswordChangedNotification) ToMail() *notifications.Mail {
return notifications.NewMail().
Subject("Your Password on Vikunja was changed").
Greeting("Hi " + n.User.GetName() + ",").
Line("Your account password was successfully changed.").
Line("If this wasn't you, it could mean someone compromised your account. In this case contact your server's administrator.")
}
// ToDB returns the PasswordChangedNotification notification in a format which can be saved in the db
func (n *PasswordChangedNotification) ToDB() interface{} {
return nil
}
// ResetPasswordNotification represents a ResetPasswordNotification notification
type ResetPasswordNotification struct {
User *User
}
// ToMail returns the mail notification for ResetPasswordNotification
func (n *ResetPasswordNotification) ToMail() *notifications.Mail {
return notifications.NewMail().
Subject("Reset your password on Vikunja").
Greeting("Hi "+n.User.GetName()+",").
Line("To reset your password, click the link below:").
Action("Reset your password", config.ServiceFrontendurl.GetString()+"?userPasswordReset="+n.User.PasswordResetToken).
Line("Have a nice day!")
}
// ToDB returns the ResetPasswordNotification notification in a format which can be saved in the db
func (n *ResetPasswordNotification) ToDB() interface{} {
return nil
}

View File

@ -18,7 +18,7 @@ package user
import (
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/mail"
"code.vikunja.io/api/pkg/notifications"
"code.vikunja.io/api/pkg/utils"
"xorm.io/xorm"
)
@ -69,12 +69,11 @@ func UpdateEmail(s *xorm.Session, update *EmailUpdate) (err error) {
}
// Send the user a mail with a link to confirm the mail
data := map[string]interface{}{
"User": update.User,
"IsNew": false,
n := &EmailConfirmNotification{
User: update.User,
IsNew: false,
}
mail.SendMailWithTemplate(update.User.Email, update.User.Username+", please confirm your email address at Vikunja", "confirm-email", data)
err = notifications.Notify(update.User, n)
return
}

View File

@ -74,6 +74,16 @@ type User struct {
web.Auth `xorm:"-" json:"-"`
}
// RouteForMail routes all notifications for a user to its email address
func (u *User) RouteForMail() string {
return u.Email
}
// RouteForDB routes all notifications for a user to their id
func (u *User) RouteForDB() int64 {
return u.ID
}
// GetID implements the Auth interface
func (u *User) GetID() int64 {
return u.ID
@ -84,6 +94,15 @@ func (User) TableName() string {
return "users"
}
// GetName returns the name if the user has one and the username otherwise.
func (u *User) GetName() string {
if u.Name != "" {
return u.Name
}
return u.Username
}
// GetFromAuth returns a user object from a web.Auth object and returns an error if the underlying type
// is not a user object
func GetFromAuth(a web.Auth) (*User, error) {

View File

@ -19,7 +19,7 @@ package user
import (
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/mail"
"code.vikunja.io/api/pkg/notifications"
"code.vikunja.io/api/pkg/utils"
"golang.org/x/crypto/bcrypt"
"xorm.io/xorm"
@ -83,8 +83,17 @@ func CreateUser(s *xorm.Session, user *User) (newUser *User, err error) {
return nil, err
}
sendConfirmEmail(user)
// Dont send a mail if no mailer is configured
if !config.MailerEnabled.GetBool() {
return newUserOut, err
}
n := &EmailConfirmNotification{
User: user,
IsNew: false,
}
err = notifications.Notify(user, n)
return newUserOut, err
}
@ -145,18 +154,3 @@ func checkIfUserExists(s *xorm.Session, user *User) (err error) {
return nil
}
func sendConfirmEmail(user *User) {
// Dont send a mail if no mailer is configured
if !config.MailerEnabled.GetBool() {
return
}
// Send the user a mail with a link to confirm the mail
data := map[string]interface{}{
"User": user,
"IsNew": true,
}
mail.SendMailWithTemplate(user.Email, user.Username+" + Vikunja = <3", "confirm-email", data)
}

View File

@ -18,7 +18,7 @@ package user
import (
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/mail"
"code.vikunja.io/api/pkg/notifications"
"code.vikunja.io/api/pkg/utils"
"xorm.io/xorm"
)
@ -44,10 +44,10 @@ func ResetPassword(s *xorm.Session, reset *PasswordReset) (err error) {
}
// Check if we have a token
var user User
user := &User{}
exists, err := s.
Where("password_reset_token = ?", reset.Token).
Get(&user)
Get(user)
if err != nil {
return
}
@ -67,7 +67,7 @@ func ResetPassword(s *xorm.Session, reset *PasswordReset) (err error) {
_, err = s.
Cols("password", "password_reset_token").
Where("id = ?", user.ID).
Update(&user)
Update(user)
if err != nil {
return
}
@ -78,12 +78,11 @@ func ResetPassword(s *xorm.Session, reset *PasswordReset) (err error) {
}
// Send a mail to the user to notify it his password was changed.
data := map[string]interface{}{
"User": user,
n := &PasswordChangedNotification{
User: user,
}
mail.SendMailWithTemplate(user.Email, "Your password on Vikunja was changed", "password-changed", data)
err = notifications.Notify(user, n)
return
}
@ -125,11 +124,10 @@ func RequestUserPasswordResetToken(s *xorm.Session, user *User) (err error) {
return
}
data := map[string]interface{}{
"User": user,
n := &ResetPasswordNotification{
User: user,
}
// Send the user a mail with the reset token
mail.SendMailWithTemplate(user.Email, "Reset your password on Vikunja", "reset-password", data)
err = notifications.Notify(user, n)
return
}