forked from vikunja/vikunja
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:
parent
9fe46f9a61
commit
015ca310e9
46
.drone1.yml
46
.drone1.yml
@ -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
|
||||
|
110
docs/content/doc/development/notifications.md
Normal file
110
docs/content/doc/development/notifications.md
Normal 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
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=
|
||||
|
66
magefile.go
66
magefile.go
@ -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
|
||||
},
|
||||
})
|
||||
}
|
47
pkg/models/notifications.go
Normal file
47
pkg/models/notifications.go
Normal 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
|
||||
}
|
@ -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
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
|
||||
}
|
137
pkg/notifications/mail_render.go
Normal file
137
pkg/notifications/mail_render.go
Normal 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
|
||||
}
|
173
pkg/notifications/mail_test.go
Normal file
173
pkg/notifications/mail_test.go
Normal 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)
|
||||
}
|
@ -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())
|
||||
}
|
106
pkg/notifications/notification.go
Normal file
106
pkg/notifications/notification.go
Normal 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()
|
||||
}
|
86
pkg/notifications/notification_test.go
Normal file
86
pkg/notifications/notification_test.go
Normal 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
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 |