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

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

View file

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

View file

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

@ -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.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/ginkgo v1.14.2 h1:8mVmC9kjFFmA8H4pKMUhcblgifdkOIXPvbhN1T36q1M= 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.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/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 h1:RE1xgDvH7imwFD45h+u2SgIfERHlS2yNG4DObb5BSKU=
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= 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.3/go.mod h1:V9xEwhxec5O8UDM77eCW8vLymOMltsqPVYWrpDsH8xc=
github.com/onsi/gomega v1.10.4 h1:NiTx7EEvBzu9sFOD1zORteLSt3o8gnlvZZwSE9TnY9U= 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.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/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 h1:lDH9UUVJtmYCjyT0CI4q8xvlXPxeZ0gYCVvWbmPlp88=
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk= github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk=

View file

@ -68,6 +68,7 @@ var (
"dev:make-migration": Dev.MakeMigration, "dev:make-migration": Dev.MakeMigration,
"dev:make-event": Dev.MakeEvent, "dev:make-event": Dev.MakeEvent,
"dev:make-listener": Dev.MakeListener, "dev:make-listener": Dev.MakeListener,
"dev:make-notification": Dev.MakeNotification,
"generate-docs": GenerateDocs, "generate-docs": GenerateDocs,
"check:golangci-fix": Check.GolangciFix, "check:golangci-fix": Check.GolangciFix,
} }
@ -434,16 +435,9 @@ func (Build) Clean() error {
return nil 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 // Builds a vikunja binary, ready to run
func (Build) Build() { func (Build) Build() {
mg.Deps(initVars) mg.Deps(initVars)
mg.Deps(Build.Generate)
runAndStreamOutput("go", "build", Goflags[0], "-tags", Tags, "-ldflags", "-s -w "+Ldflags, "-o", Executable) 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 // Runs all steps in the right order to create release packages for various platforms
func (Release) Release(ctx context.Context) error { func (Release) Release(ctx context.Context) error {
mg.Deps(initVars) mg.Deps(initVars)
mg.Deps(Build.Generate, Release.Dirs) mg.Deps(Release.Dirs)
mg.Deps(Release.Windows, Release.Linux, Release.Darwin) mg.Deps(Release.Windows, Release.Linux, Release.Darwin)
// Run compiling in parallel to speed it up // Run compiling in parallel to speed it up
@ -894,6 +888,44 @@ func (s *` + name + `) Handle(payload message.Payload) (err error) {
return nil 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 { type configOption struct {
key string key string
description string description string

View file

@ -17,9 +17,11 @@
package cmd package cmd
import ( import (
"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"
"code.vikunja.io/api/pkg/mail" "code.vikunja.io/api/pkg/mail"
"code.vikunja.io/api/pkg/notifications"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@ -39,8 +41,20 @@ var testmailCmd = &cobra.Command{
}, },
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
log.Info("Sending testmail...") log.Info("Sending testmail...")
email := args[0] message := notifications.NewMail().
if err := mail.SendTestMail(email); err != nil { 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()) log.Errorf("Error sending test mail: %s", err.Error())
return return
} }

View file

@ -17,19 +17,14 @@
package mail package mail
import ( import (
"bytes"
"html/template"
"code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/log" "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" "gopkg.in/gomail.v2"
) )
// Opts holds infos for a mail // Opts holds infos for a mail
type Opts struct { type Opts struct {
From string
To string To string
Subject string Subject string
Message string Message string
@ -56,7 +51,7 @@ type header struct {
// SendTestMail sends a test mail to a receipient. // SendTestMail sends a test mail to a receipient.
// It works without a queue. // It works without a queue.
func SendTestMail(to string) error { func SendTestMail(opts *Opts) error {
if config.MailerHost.GetString() == "" { if config.MailerHost.GetString() == "" {
log.Warning("Mailer seems to be not configured! Please see the config docs for more details.") log.Warning("Mailer seems to be not configured! Please see the config docs for more details.")
return nil return nil
@ -69,19 +64,17 @@ func SendTestMail(to string) error {
} }
defer s.Close() defer s.Close()
m := gomail.NewMessage() m := sendMail(opts)
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.")
return gomail.Send(s, m) return gomail.Send(s, m)
} }
// SendMail puts a mail in the queue func sendMail(opts *Opts) *gomail.Message {
func SendMail(opts *Opts) {
m := gomail.NewMessage() 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("To", opts.To)
m.SetHeader("Subject", opts.Subject) m.SetHeader("Subject", opts.Subject)
for _, h := range opts.Headers { for _, h := range opts.Headers {
@ -97,49 +90,16 @@ func SendMail(opts *Opts) {
m.SetBody("text/plain", opts.Message) m.SetBody("text/plain", opts.Message)
m.AddAlternative("text/html", opts.HTMLMessage) 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 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 // 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/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
// +build ignore package mail
package main
import ( import (
"log" "reflect"
"net/http" "testing"
"github.com/shurcooL/vfsgen" "github.com/stretchr/testify/assert"
) )
func main() { var (
err := vfsgen.Generate(http.Dir(`../../templates/mail`), vfsgen.Options{ isUnderTest bool
PackageName: "static", sentMails []*Opts
BuildTags: "!dev", )
VariableName: "Templates",
}) // Fake stops any mails from being sent and instead allows for recording and querying them.
if err != nil { func Fake() {
log.Fatalln(err) 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 // 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/>. // 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 ( import (
"time" "time"
"code.vikunja.io/api/pkg/notifications"
"code.vikunja.io/api/pkg/db" "code.vikunja.io/api/pkg/db"
"xorm.io/xorm" "xorm.io/xorm"
"code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/cron" "code.vikunja.io/api/pkg/cron"
"code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/mail"
"code.vikunja.io/api/pkg/user" "code.vikunja.io/api/pkg/user"
) )
@ -61,11 +62,6 @@ func getTaskUsersForTasks(s *xorm.Session, taskIDs []int64) (taskUsers []*taskUs
return return
} }
assignees, err := getRawTaskAssigneesForTasks(s, taskIDs)
if err != nil {
return
}
taskMap := make(map[int64]*Task, len(taskIDs)) taskMap := make(map[int64]*Task, len(taskIDs))
err = s.In("id", taskIDs).Find(&taskMap) err = s.In("id", taskIDs).Find(&taskMap)
if err != nil { if err != nil {
@ -73,12 +69,22 @@ func getTaskUsersForTasks(s *xorm.Session, taskIDs []int64) (taskUsers []*taskUs
} }
for _, taskID := range taskIDs { for _, taskID := range taskIDs {
u, exists := creators[taskMap[taskID].CreatedByID]
if !exists {
continue
}
taskUsers = append(taskUsers, &taskUser{ taskUsers = append(taskUsers, &taskUser{
Task: taskMap[taskID], Task: taskMap[taskID],
User: creators[taskMap[taskID].CreatedByID], User: u,
}) })
} }
assignees, err := getRawTaskAssigneesForTasks(s, taskIDs)
if err != nil {
return
}
for _, assignee := range assignees { for _, assignee := range assignees {
if !assignee.EmailRemindersEnabled { // Can't filter that through a query directly since we're using another function if !assignee.EmailRemindersEnabled { // Can't filter that through a query directly since we're using another function
continue continue
@ -168,12 +174,17 @@ func RegisterReminderCron() {
log.Debugf("[Task Reminder Cron] Sending reminders to %d users", len(users)) log.Debugf("[Task Reminder Cron] Sending reminders to %d users", len(users))
for _, u := range users { for _, u := range users {
data := map[string]interface{}{ n := &ReminderDueNotification{
"User": u.User, User: u.User,
"Task": u.Task, 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) 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 // 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/>. // 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 ( import (
"code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/mail" "code.vikunja.io/api/pkg/notifications"
"code.vikunja.io/api/pkg/utils" "code.vikunja.io/api/pkg/utils"
"xorm.io/xorm" "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 // Send the user a mail with a link to confirm the mail
data := map[string]interface{}{ n := &EmailConfirmNotification{
"User": update.User, User: update.User,
"IsNew": false, 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 return
} }

View file

@ -74,6 +74,16 @@ type User struct {
web.Auth `xorm:"-" json:"-"` 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 // GetID implements the Auth interface
func (u *User) GetID() int64 { func (u *User) GetID() int64 {
return u.ID return u.ID
@ -84,6 +94,15 @@ func (User) TableName() string {
return "users" 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 // GetFromAuth returns a user object from a web.Auth object and returns an error if the underlying type
// is not a user object // is not a user object
func GetFromAuth(a web.Auth) (*User, error) { func GetFromAuth(a web.Auth) (*User, error) {

View file

@ -19,7 +19,7 @@ package user
import ( import (
"code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/events" "code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/mail" "code.vikunja.io/api/pkg/notifications"
"code.vikunja.io/api/pkg/utils" "code.vikunja.io/api/pkg/utils"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"xorm.io/xorm" "xorm.io/xorm"
@ -83,8 +83,17 @@ func CreateUser(s *xorm.Session, user *User) (newUser *User, err error) {
return nil, err 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 return newUserOut, err
} }
@ -145,18 +154,3 @@ func checkIfUserExists(s *xorm.Session, user *User) (err error) {
return nil 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 ( import (
"code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/mail" "code.vikunja.io/api/pkg/notifications"
"code.vikunja.io/api/pkg/utils" "code.vikunja.io/api/pkg/utils"
"xorm.io/xorm" "xorm.io/xorm"
) )
@ -44,10 +44,10 @@ func ResetPassword(s *xorm.Session, reset *PasswordReset) (err error) {
} }
// Check if we have a token // Check if we have a token
var user User user := &User{}
exists, err := s. exists, err := s.
Where("password_reset_token = ?", reset.Token). Where("password_reset_token = ?", reset.Token).
Get(&user) Get(user)
if err != nil { if err != nil {
return return
} }
@ -67,7 +67,7 @@ func ResetPassword(s *xorm.Session, reset *PasswordReset) (err error) {
_, err = s. _, err = s.
Cols("password", "password_reset_token"). Cols("password", "password_reset_token").
Where("id = ?", user.ID). Where("id = ?", user.ID).
Update(&user) Update(user)
if err != nil { if err != nil {
return 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. // Send a mail to the user to notify it his password was changed.
data := map[string]interface{}{ n := &PasswordChangedNotification{
"User": user, User: user,
} }
mail.SendMailWithTemplate(user.Email, "Your password on Vikunja was changed", "password-changed", data) err = notifications.Notify(user, n)
return return
} }
@ -125,11 +124,10 @@ func RequestUserPasswordResetToken(s *xorm.Session, user *User) (err error) {
return return
} }
data := map[string]interface{}{ n := &ResetPasswordNotification{
"User": user, User: user,
} }
// Send the user a mail with the reset token err = notifications.Notify(user, n)
mail.SendMailWithTemplate(user.Email, "Reset your password on Vikunja", "reset-password", data)
return return
} }

View file

@ -1,18 +0,0 @@
{{template "mail-header.tmpl" .}}
<p>
Hi {{.User.Username}},<br>
{{if .IsNew}}
<br>
Welcome to Vikunja!
{{end}}
<br/>
To confirm your email address, click the link below:
</p>
<a href="{{.FrontendURL}}?userEmailConfirm={{.User.EmailConfirmToken}}" title="Confirm your email address" style="-webkit-box-shadow: 0.3em 0.3em 1em #b2d0ff; box-shadow: 0.3em 0.3em 1em #b2d0ff; background-color: #1973ff; border-color: transparent; color: #fff; text-decoration: none; text-align: center; text-rendering: optimizelegibility; text-transform: uppercase; font-weight: bold; font-size: 14px; padding: 10px 14px; margin: 10px auto; border-radius: 4px; user-select: none; display: block; width: 280px;font-family:sans-serif">
Confirm your email address
</a>
<p>
If the button above doesn't work, copy the url below and paste it in your browsers address bar:<br/>
{{.FrontendURL}}?userEmailConfirm={{.User.EmailConfirmToken}}
</p>
{{template "mail-footer.tmpl"}}

View file

@ -1,9 +0,0 @@
Hi {{.User.Username}},
{{if .IsNew}}
Welcome to Vikunja!
{{end}}
To confirm your email address, copy the link below and paste it in your browser:
{{.FrontendURL}}?userEmailConfirm={{.User.EmailConfirmToken}}

View file

@ -1,5 +0,0 @@
</div>
</div>
</div>
</body>
</html>

View file

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

View file

@ -1,9 +0,0 @@
{{template "mail-header.tmpl" .}}
<p>
Hi {{.User.Username}},<br/>
<br/>
Your account password was successfully changed.
<br/>
If this wasn't you, it could mean someone compromised your account. In this case contact your server's administrator.
</p>
{{template "mail-footer.tmpl"}}

View file

@ -1,5 +0,0 @@
Hi {{.User.Username}},
Your account password was successfully changed.
If this wasn't you, it could mean someone compromised your account. In this case contact your server's administrator.

View file

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

View file

@ -1,9 +0,0 @@
Hi {{if .User.Name}}{{.User.Name}}{{else}}{{.User.Username}}{{end}},
This is a friendly reminder of the task "{{.Task.Title}}".
You can view the task at:
{{.FrontendURL}}tasks/{{.Task.ID}}
Have a nice day!

View file

@ -1,14 +0,0 @@
{{template "mail-header.tmpl" .}}
<p>
Hi {{.User.Username}},<br>
<br>
To reset your password, click the link below:
</p>
<a href="{{.FrontendURL}}?userPasswordReset={{.User.PasswordResetToken}}" title="Reset your password" style="-webkit-box-shadow: 0.3em 0.3em 1em #b2d0ff; box-shadow: 0.3em 0.3em 1em #b2d0ff; background-color: #1973ff; border-color: transparent; color: #fff; text-decoration: none; text-align: center; text-rendering: optimizelegibility; text-transform: uppercase; font-weight: bold; font-size: 14px; padding: 10px 14px; margin: 10px auto; border-radius: 4px; user-select: none; display: block; width: 280px;font-family:sans-serif">
Reset your password
</a>
<p>
If the button above doesn't work, copy the url below and paste it in your browsers address bar:<br/>
{{.FrontendURL}}?userPasswordReset={{.User.PasswordResetToken}}
</p>
{{template "mail-footer.tmpl"}}

View file

@ -1,3 +0,0 @@
Hi {{.User.Username}},
Use the following link to reset your password: {{.FrontendURL}}?userPasswordReset={{.User.PasswordResetToken}}