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

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: vikunja/api#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

@ -82,7 +82,6 @@ steps:
GOPROXY: 'https://goproxy.kolaente.de'
depends_on: [ build ]
commands:
- ./mage-static build:generate
- ./mage-static check:got-swag
- wget -O - -q https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.31.0
- ./mage-static check:golangci
@ -158,7 +157,6 @@ steps:
environment:
GOPROXY: 'https://goproxy.kolaente.de'
commands:
- ./mage-static build:generate
- ./mage-static test:unit
depends_on: [ fetch-tags, mage ]
when:
@ -172,7 +170,6 @@ steps:
VIKUNJA_TESTS_USE_CONFIG: 1
VIKUNJA_DATABASE_TYPE: sqlite
commands:
- ./mage-static build:generate
- ./mage-static test:unit
depends_on: [ fetch-tags, mage ]
when:
@ -190,7 +187,6 @@ steps:
VIKUNJA_DATABASE_PASSWORD: vikunjatest
VIKUNJA_DATABASE_DATABASE: vikunjatest
commands:
- ./mage-static build:generate
- ./mage-static test:unit
depends_on: [ fetch-tags, mage ]
when:
@ -209,7 +205,6 @@ steps:
VIKUNJA_DATABASE_DATABASE: vikunjatest
VIKUNJA_DATABASE_SSLMODE: disable
commands:
- ./mage-static build:generate
- ./mage-static test:unit
depends_on: [ fetch-tags, mage ]
when:
@ -221,7 +216,6 @@ steps:
environment:
GOPROXY: 'https://goproxy.kolaente.de'
commands:
- ./mage-static build:generate
- ./mage-static test:integration
depends_on: [ fetch-tags, mage ]
when:
@ -235,7 +229,6 @@ steps:
VIKUNJA_TESTS_USE_CONFIG: 1
VIKUNJA_DATABASE_TYPE: sqlite
commands:
- ./mage-static build:generate
- ./mage-static test:integration
depends_on: [ fetch-tags, mage ]
when:
@ -253,7 +246,6 @@ steps:
VIKUNJA_DATABASE_PASSWORD: vikunjatest
VIKUNJA_DATABASE_DATABASE: vikunjatest
commands:
- ./mage-static build:generate
- ./mage-static test:integration
depends_on: [ fetch-tags, mage ]
when:
@ -272,7 +264,6 @@ steps:
VIKUNJA_DATABASE_DATABASE: vikunjatest
VIKUNJA_DATABASE_SSLMODE: disable
commands:
- ./mage-static build:generate
- ./mage-static test:integration
depends_on: [ fetch-tags, mage ]
when:
@ -323,7 +314,6 @@ steps:
commands:
- export PATH=$PATH:$GOPATH/bin
- go install github.com/magefile/mage
- ./mage-static build:generate
- ./mage-static release:dirs
depends_on: [ fetch-tags, mage ]
@ -496,24 +486,24 @@ steps:
- tag
depends_on: [ build-os-packages ]
### Broken, disabled until we figure out how to fix it
# - name: deb-structure
# image: kolaente/reprepro
# pull: true
# environment:
# GPG_PRIVATE_KEY:
# from_secret: gpg_privatekey
# commands:
# - export GPG_TTY=$(tty)
# - gpg -qk
# - echo "use-agent" >> ~/.gnupg/gpg.conf
# - gpgconf --kill gpg-agent
# - echo $GPG_PRIVATE_KEY > ~/frederik.gpg
# - gpg --import ~/frederik.gpg
# - mkdir debian/conf -p
# - cp build/reprepro-dist-conf debian/conf/distributions
# - ./mage-static release:reprepro
# depends_on: [ build-os-packages ]
### Broken, disabled until we figure out how to fix it
# - name: deb-structure
# image: kolaente/reprepro
# pull: true
# environment:
# GPG_PRIVATE_KEY:
# from_secret: gpg_privatekey
# commands:
# - export GPG_TTY=$(tty)
# - gpg -qk
# - echo "use-agent" >> ~/.gnupg/gpg.conf
# - gpgconf --kill gpg-agent
# - echo $GPG_PRIVATE_KEY > ~/frederik.gpg
# - gpg --import ~/frederik.gpg
# - mkdir debian/conf -p
# - cp build/reprepro-dist-conf debian/conf/distributions
# - ./mage-static release:reprepro
# depends_on: [ build-os-packages ]
# Push the releases to our pseudo-s3-bucket
- name: release-deb

@ -17,7 +17,7 @@ WORKDIR ${GOPATH}/src/code.vikunja.io/api
# Checkout version if set
RUN if [ -n "${VIKUNJA_VERSION}" ]; then git checkout "${VIKUNJA_VERSION}"; fi \
&& go install github.com/magefile/mage \
&& mage build:clean build:build
&& mage build:clean build
###################
# The actual image

@ -0,0 +1,110 @@
---
date: 2021-02-07T19:26:34+02:00
title: "Notifications"
toc: true
draft: false
menu:
sidebar:
parent: "development"
---
# Notifications
Vikunjs provides a simple abstraction to send notifications per mail and in the database.
{{< table_of_contents >}}
## Definition
Each notification has to implement this interface:
```golang
type Notification interface {
ToMail() *Mail
ToDB() interface{}
}
```
Both functions return the formatted messages for mail and database.
A notification will only be sent or recorded for those of the two methods which don't return `nil`.
For example, if your notification should not be recorded in the database but only sent out per mail, it is enough to let the `ToDB` function return `nil`.
### Mail notifications
A list of chainable functions is available to compose a mail:
```golang
mail := NewMail().
// The optional sender of the mail message.
From("test@example.com").
// The optional receipient of the mail message. Uses the mail address of the notifiable if omitted.
To("test@otherdomain.com").
// The subject of the mail to send.
Subject("Testmail").
// The greeting, or "intro" line of the mail.
Greeting("Hi there,").
// A line of text
Line("This is a line of text").
// An action can contain a title and a url. It gets rendered as a big button in the mail.
// Note that you can have only one action per mail.
// All lines added before an action will appearr in the mail before the button, all lines
// added afterwards will appear after it.
Action("The Action", "https://example.com").
// Another line of text.
Line("This should be an outro line").
```
If not provided, the `from` field of the mail contains the value configured in [`mailer.fromemail`](https://vikunja.io/docs/config-options/#fromemail).
### Database notifications
All data returned from the `ToDB()` method is serialized to json and saved into the database, along with the id of the notifiable and a time stamp.
## Creating a new notification
The easiest way to generate a mail is by using the `mage dev:make-notification` command.
It takes the name of the notification and the package where the notification will be created.
## Notifiables
Notifiables can receive a notification.
A notifiable is defined with this interface:
```golang
type Notifiable interface {
// Should return the email address this notifiable has.
RouteForMail() string
// Should return the id of the notifiable entity
RouteForDB() int64
}
```
The `User` type from the `user` package implements this interface.
## Sending a notification
Sending a notification is done with the `Notify` method from the `notifications` package.
It takes a notifiable and a notification as input.
For example, the email confirm notification when a new user registers is sent like this:
```golang
n := &EmailConfirmNotification{
User: update.User,
IsNew: false,
}
err = notifications.Notify(update.User, n)
return
```
## Testing
The `mail` package provides a `Fake()` method which you should call in the `MainTest` functions of your package.
If it was called, no mails are being sent and you can instead assert they have been sent with the `AssertSent` method.
## Example
Take a look at the [pkg/user/notifications.go](https://code.vikunja.io/api/src/branch/master/pkg/user/notifications.go) file for a good example.

2
go.sum

@ -608,6 +608,7 @@ github.com/onsi/ginkgo v1.10.3/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/ginkgo v1.14.2 h1:8mVmC9kjFFmA8H4pKMUhcblgifdkOIXPvbhN1T36q1M=
github.com/onsi/ginkgo v1.14.2/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY=
github.com/onsi/ginkgo v1.15.0 h1:1V1NfVQR87RtWAgp1lv9JZJ5Jap+XFGKPi00andXGi4=
github.com/onsi/ginkgo v1.15.0/go.mod h1:hF8qUzuuC8DJGygJH3726JnCZX4MYbRB8yFfISqnKUg=
github.com/onsi/gomega v1.4.3 h1:RE1xgDvH7imwFD45h+u2SgIfERHlS2yNG4DObb5BSKU=
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
@ -619,6 +620,7 @@ github.com/onsi/gomega v1.10.3 h1:gph6h/qe9GSUw1NhH1gp+qb+h8rXD8Cy60Z32Qw3ELA=
github.com/onsi/gomega v1.10.3/go.mod h1:V9xEwhxec5O8UDM77eCW8vLymOMltsqPVYWrpDsH8xc=
github.com/onsi/gomega v1.10.4 h1:NiTx7EEvBzu9sFOD1zORteLSt3o8gnlvZZwSE9TnY9U=
github.com/onsi/gomega v1.10.4/go.mod h1:g/HbgYopi++010VEqkFgJHKC09uJiW9UkXvMUuKHUCQ=
github.com/onsi/gomega v1.10.5 h1:7n6FEkpFmfCoo2t+YYqXH0evK+a9ICQz0xcAy9dYcaQ=
github.com/onsi/gomega v1.10.5/go.mod h1:gza4q3jKQJijlu05nKWRCW/GavJumGt8aNRxWg7mt48=
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 h1:lDH9UUVJtmYCjyT0CI4q8xvlXPxeZ0gYCVvWbmPlp88=
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk=

@ -61,15 +61,16 @@ var (
// Aliases are mage aliases of targets
Aliases = map[string]interface{}{
"build": Build.Build,
"do-the-swag": DoTheSwag,
"check:got-swag": Check.GotSwag,
"release:os-package": Release.OsPackage,
"dev:make-migration": Dev.MakeMigration,
"dev:make-event": Dev.MakeEvent,
"dev:make-listener": Dev.MakeListener,
"generate-docs": GenerateDocs,
"check:golangci-fix": Check.GolangciFix,
"build": Build.Build,
"do-the-swag": DoTheSwag,
"check:got-swag": Check.GotSwag,
"release:os-package": Release.OsPackage,
"dev:make-migration": Dev.MakeMigration,
"dev:make-event": Dev.MakeEvent,
"dev:make-listener": Dev.MakeListener,
"dev:make-notification": Dev.MakeNotification,
"generate-docs": GenerateDocs,
"check:golangci-fix": Check.GolangciFix,
}
)
@ -434,16 +435,9 @@ func (Build) Clean() error {
return nil
}
// Generates static content into the final binary
func (Build) Generate() {
mg.Deps(initVars)
runAndStreamOutput("go", "generate", PACKAGE+"/pkg/static")
}
// Builds a vikunja binary, ready to run
func (Build) Build() {
mg.Deps(initVars)
mg.Deps(Build.Generate)
runAndStreamOutput("go", "build", Goflags[0], "-tags", Tags, "-ldflags", "-s -w "+Ldflags, "-o", Executable)
}
@ -452,7 +446,7 @@ type Release mg.Namespace
// Runs all steps in the right order to create release packages for various platforms
func (Release) Release(ctx context.Context) error {
mg.Deps(initVars)
mg.Deps(Build.Generate, Release.Dirs)
mg.Deps(Release.Dirs)
mg.Deps(Release.Windows, Release.Linux, Release.Darwin)
// Run compiling in parallel to speed it up
@ -894,6 +888,44 @@ func (s *` + name + `) Handle(payload message.Payload) (err error) {
return nil
}
// Create a new notification. Takes the name of the notification as the first argument and the module where the notification should be created as the second argument. Notifications will be appended to the pkg/<module>/notifications.go file.
func (Dev) MakeNotification(name, module string) error {
name = strcase.ToCamel(name)
if !strings.HasSuffix(name, "Notification") {
name += "Notification"
}
newNotificationCode := `
// ` + name + ` represents a ` + name + ` notification
type ` + name + ` struct {
}
// ToMail returns the mail notification for ` + name + `
func (n *` + name + `) ToMail() *notifications.Mail {
return notifications.NewMail().
Subject("").
Greeting("Hi ").
Line("").
Action("", "")
}
// ToDB returns the ` + name + ` notification in a format which can be saved in the db
func (n *` + name + `) ToDB() interface{} {
return nil
}
`
filename := "./pkg/" + module + "/notifications.go"
if err := appendToFile(filename, newNotificationCode); err != nil {
return err
}
printSuccess("The new notification has been created successfully! Head over to %s and adjust its content.", filename)
return nil
}
type configOption struct {
key string
description string

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

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

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

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

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

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

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

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

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

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

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

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

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