From 015ca310e9c9a573d9af7ebebae58d5ea14a679c Mon Sep 17 00:00:00 2001 From: konrad Date: Sun, 7 Feb 2021 21:05:09 +0000 Subject: [PATCH] Add notifications package for easy sending of notifications (#779) Co-authored-by: kolaente Reviewed-on: https://kolaente.dev/vikunja/api/pulls/779 Co-authored-by: konrad Co-committed-by: konrad --- .drone1.yml | 46 ++--- Dockerfile | 2 +- docs/content/doc/development/notifications.md | 110 +++++++++++ go.sum | 2 + magefile.go | 66 +++++-- pkg/cmd/testmail.go | 18 +- pkg/mail/send_mail.go | 76 ++------ .../templates_generate.go => mail/testing.go} | 41 +++-- .../static.go => migration/20210207192805.go} | 33 +++- pkg/models/notifications.go | 47 +++++ pkg/models/task_reminder.go | 33 ++-- pkg/notifications/mail.go | 93 ++++++++++ pkg/notifications/mail_render.go | 137 ++++++++++++++ pkg/notifications/mail_test.go | 173 ++++++++++++++++++ .../main_test.go} | 40 +++- pkg/notifications/notification.go | 106 +++++++++++ pkg/notifications/notification_test.go | 86 +++++++++ pkg/user/notifications.go | 94 ++++++++++ pkg/user/update_email.go | 11 +- pkg/user/user.go | 19 ++ pkg/user/user_create.go | 28 ++- pkg/user/user_password_reset.go | 22 +-- templates/mail/confirm-email.html.tmpl | 18 -- templates/mail/confirm-email.plain.tmpl | 9 - templates/mail/mail-footer.tmpl | 5 - templates/mail/mail-header.tmpl | 12 -- templates/mail/password-changed.html.tmpl | 9 - templates/mail/password-changed.plain.tmpl | 5 - templates/mail/reminder-email.html.tmpl | 17 -- templates/mail/reminder-email.plain.tmpl | 9 - templates/mail/reset-password.html.tmpl | 14 -- templates/mail/reset-password.plain.tmpl | 3 - 32 files changed, 1109 insertions(+), 275 deletions(-) create mode 100644 docs/content/doc/development/notifications.md rename pkg/{static/templates_generate.go => mail/testing.go} (59%) rename pkg/{static/static.go => migration/20210207192805.go} (50%) create mode 100644 pkg/models/notifications.go create mode 100644 pkg/notifications/mail.go create mode 100644 pkg/notifications/mail_render.go create mode 100644 pkg/notifications/mail_test.go rename pkg/{static/templates.go => notifications/main_test.go} (50%) create mode 100644 pkg/notifications/notification.go create mode 100644 pkg/notifications/notification_test.go create mode 100644 pkg/user/notifications.go delete mode 100644 templates/mail/confirm-email.html.tmpl delete mode 100644 templates/mail/confirm-email.plain.tmpl delete mode 100644 templates/mail/mail-footer.tmpl delete mode 100644 templates/mail/mail-header.tmpl delete mode 100644 templates/mail/password-changed.html.tmpl delete mode 100644 templates/mail/password-changed.plain.tmpl delete mode 100644 templates/mail/reminder-email.html.tmpl delete mode 100644 templates/mail/reminder-email.plain.tmpl delete mode 100644 templates/mail/reset-password.html.tmpl delete mode 100644 templates/mail/reset-password.plain.tmpl diff --git a/.drone1.yml b/.drone1.yml index 4d401e705..a4921cb8b 100644 --- a/.drone1.yml +++ b/.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 diff --git a/Dockerfile b/Dockerfile index d09470111..2c4b0596a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/docs/content/doc/development/notifications.md b/docs/content/doc/development/notifications.md new file mode 100644 index 000000000..d93f52072 --- /dev/null +++ b/docs/content/doc/development/notifications.md @@ -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. diff --git a/go.sum b/go.sum index f36da376f..0f92e0e13 100644 --- a/go.sum +++ b/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= diff --git a/magefile.go b/magefile.go index 31176ad2a..3b62a535b 100644 --- a/magefile.go +++ b/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//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 diff --git a/pkg/cmd/testmail.go b/pkg/cmd/testmail.go index de7d791ef..e8dac3441 100644 --- a/pkg/cmd/testmail.go +++ b/pkg/cmd/testmail.go @@ -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 } diff --git a/pkg/mail/send_mail.go b/pkg/mail/send_mail.go index 51b4c541e..b962c3a0f 100644 --- a/pkg/mail/send_mail.go +++ b/pkg/mail/send_mail.go @@ -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) -} diff --git a/pkg/static/templates_generate.go b/pkg/mail/testing.go similarity index 59% rename from pkg/static/templates_generate.go rename to pkg/mail/testing.go index 3cd21e662..737150b2e 100644 --- a/pkg/static/templates_generate.go +++ b/pkg/mail/testing.go @@ -14,24 +14,35 @@ // You should have received a copy of the GNU Affero General Public Licensee // along with this program. If not, see . -// +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) } diff --git a/pkg/static/static.go b/pkg/migration/20210207192805.go similarity index 50% rename from pkg/static/static.go rename to pkg/migration/20210207192805.go index 1f36c8dfd..8acd3ff82 100644 --- a/pkg/static/static.go +++ b/pkg/migration/20210207192805.go @@ -14,8 +14,35 @@ // You should have received a copy of the GNU Affero General Public Licensee // along with this program. If not, see . -//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 + }, + }) +} diff --git a/pkg/models/notifications.go b/pkg/models/notifications.go new file mode 100644 index 000000000..af655aaae --- /dev/null +++ b/pkg/models/notifications.go @@ -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 . + +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 +} diff --git a/pkg/models/task_reminder.go b/pkg/models/task_reminder.go index 78979721f..4746fc797 100644 --- a/pkg/models/task_reminder.go +++ b/pkg/models/task_reminder.go @@ -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) } }) diff --git a/pkg/notifications/mail.go b/pkg/notifications/mail.go new file mode 100644 index 000000000..b7080ebcb --- /dev/null +++ b/pkg/notifications/mail.go @@ -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 . + +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 +} diff --git a/pkg/notifications/mail_render.go b/pkg/notifications/mail_render.go new file mode 100644 index 000000000..b45f7b510 --- /dev/null +++ b/pkg/notifications/mail_render.go @@ -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 . + +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 = ` + + + + + + +
+
+

+ Vikunja +

+
+

+ {{ .Greeting }} +

+ +{{ range $line := .IntroLines}} +

+ {{ $line }} +

+{{ end }} + +{{ if .ActionURL }} + + {{ .ActionText }} + +{{end}} + +{{ range $line := .OutroLines}} +

+ {{ $line }} +

+{{ end }} + +{{ if .ActionURL }} +

+ If the button above doesn't work, copy the url below and paste it in your browsers address bar:
+ {{ .ActionURL }} +

+{{ end }} +
+
+
+ + +` + +// 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 +} diff --git a/pkg/notifications/mail_test.go b/pkg/notifications/mail_test.go new file mode 100644 index 000000000..fddeb306d --- /dev/null +++ b/pkg/notifications/mail_test.go @@ -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 . + +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, ` + + + + + + +
+
+

+ Vikunja +

+
+

+ Hi there, +

+ + +

+ This is a line +

+ +

+ And another one +

+ + + + + The action + + + + +

+ This should be an outro line +

+ +

+ And one more, because why not? +

+ + + +

+ If the button above doesn't work, copy the url below and paste it in your browsers address bar:
+ https://example.com +

+ +
+
+
+ + +`, mailopts.HTMLMessage) +} diff --git a/pkg/static/templates.go b/pkg/notifications/main_test.go similarity index 50% rename from pkg/static/templates.go rename to pkg/notifications/main_test.go index bef4179cf..36050157d 100644 --- a/pkg/static/templates.go +++ b/pkg/notifications/main_test.go @@ -14,10 +14,42 @@ // You should have received a copy of the GNU Affero General Public Licensee // along with this program. If not, see . -// +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()) +} diff --git a/pkg/notifications/notification.go b/pkg/notifications/notification.go new file mode 100644 index 000000000..da3898c09 --- /dev/null +++ b/pkg/notifications/notification.go @@ -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 . + +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() +} diff --git a/pkg/notifications/notification_test.go b/pkg/notifications/notification_test.go new file mode 100644 index 000000000..f0baa0e8a --- /dev/null +++ b/pkg/notifications/notification_test.go @@ -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 . + +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) +} diff --git a/pkg/user/notifications.go b/pkg/user/notifications.go new file mode 100644 index 000000000..b3c2b4c46 --- /dev/null +++ b/pkg/user/notifications.go @@ -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 . + +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 +} diff --git a/pkg/user/update_email.go b/pkg/user/update_email.go index 544342599..2e3b9ceef 100644 --- a/pkg/user/update_email.go +++ b/pkg/user/update_email.go @@ -18,7 +18,7 @@ package user import ( "code.vikunja.io/api/pkg/config" - "code.vikunja.io/api/pkg/mail" + "code.vikunja.io/api/pkg/notifications" "code.vikunja.io/api/pkg/utils" "xorm.io/xorm" ) @@ -69,12 +69,11 @@ func UpdateEmail(s *xorm.Session, update *EmailUpdate) (err error) { } // Send the user a mail with a link to confirm the mail - data := map[string]interface{}{ - "User": update.User, - "IsNew": false, + n := &EmailConfirmNotification{ + User: update.User, + IsNew: false, } - mail.SendMailWithTemplate(update.User.Email, update.User.Username+", please confirm your email address at Vikunja", "confirm-email", data) - + err = notifications.Notify(update.User, n) return } diff --git a/pkg/user/user.go b/pkg/user/user.go index 69b044247..d2077efe3 100644 --- a/pkg/user/user.go +++ b/pkg/user/user.go @@ -74,6 +74,16 @@ type User struct { web.Auth `xorm:"-" json:"-"` } +// RouteForMail routes all notifications for a user to its email address +func (u *User) RouteForMail() string { + return u.Email +} + +// RouteForDB routes all notifications for a user to their id +func (u *User) RouteForDB() int64 { + return u.ID +} + // GetID implements the Auth interface func (u *User) GetID() int64 { return u.ID @@ -84,6 +94,15 @@ func (User) TableName() string { return "users" } +// GetName returns the name if the user has one and the username otherwise. +func (u *User) GetName() string { + if u.Name != "" { + return u.Name + } + + return u.Username +} + // GetFromAuth returns a user object from a web.Auth object and returns an error if the underlying type // is not a user object func GetFromAuth(a web.Auth) (*User, error) { diff --git a/pkg/user/user_create.go b/pkg/user/user_create.go index 7163feb05..f3472b271 100644 --- a/pkg/user/user_create.go +++ b/pkg/user/user_create.go @@ -19,7 +19,7 @@ package user import ( "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/events" - "code.vikunja.io/api/pkg/mail" + "code.vikunja.io/api/pkg/notifications" "code.vikunja.io/api/pkg/utils" "golang.org/x/crypto/bcrypt" "xorm.io/xorm" @@ -83,8 +83,17 @@ func CreateUser(s *xorm.Session, user *User) (newUser *User, err error) { return nil, err } - sendConfirmEmail(user) + // Dont send a mail if no mailer is configured + if !config.MailerEnabled.GetBool() { + return newUserOut, err + } + n := &EmailConfirmNotification{ + User: user, + IsNew: false, + } + + err = notifications.Notify(user, n) return newUserOut, err } @@ -145,18 +154,3 @@ func checkIfUserExists(s *xorm.Session, user *User) (err error) { return nil } - -func sendConfirmEmail(user *User) { - // Dont send a mail if no mailer is configured - if !config.MailerEnabled.GetBool() { - return - } - - // Send the user a mail with a link to confirm the mail - data := map[string]interface{}{ - "User": user, - "IsNew": true, - } - - mail.SendMailWithTemplate(user.Email, user.Username+" + Vikunja = <3", "confirm-email", data) -} diff --git a/pkg/user/user_password_reset.go b/pkg/user/user_password_reset.go index 96b2cf4e1..4860e549a 100644 --- a/pkg/user/user_password_reset.go +++ b/pkg/user/user_password_reset.go @@ -18,7 +18,7 @@ package user import ( "code.vikunja.io/api/pkg/config" - "code.vikunja.io/api/pkg/mail" + "code.vikunja.io/api/pkg/notifications" "code.vikunja.io/api/pkg/utils" "xorm.io/xorm" ) @@ -44,10 +44,10 @@ func ResetPassword(s *xorm.Session, reset *PasswordReset) (err error) { } // Check if we have a token - var user User + user := &User{} exists, err := s. Where("password_reset_token = ?", reset.Token). - Get(&user) + Get(user) if err != nil { return } @@ -67,7 +67,7 @@ func ResetPassword(s *xorm.Session, reset *PasswordReset) (err error) { _, err = s. Cols("password", "password_reset_token"). Where("id = ?", user.ID). - Update(&user) + Update(user) if err != nil { return } @@ -78,12 +78,11 @@ func ResetPassword(s *xorm.Session, reset *PasswordReset) (err error) { } // Send a mail to the user to notify it his password was changed. - data := map[string]interface{}{ - "User": user, + n := &PasswordChangedNotification{ + User: user, } - mail.SendMailWithTemplate(user.Email, "Your password on Vikunja was changed", "password-changed", data) - + err = notifications.Notify(user, n) return } @@ -125,11 +124,10 @@ func RequestUserPasswordResetToken(s *xorm.Session, user *User) (err error) { return } - data := map[string]interface{}{ - "User": user, + n := &ResetPasswordNotification{ + User: user, } - // Send the user a mail with the reset token - mail.SendMailWithTemplate(user.Email, "Reset your password on Vikunja", "reset-password", data) + err = notifications.Notify(user, n) return } diff --git a/templates/mail/confirm-email.html.tmpl b/templates/mail/confirm-email.html.tmpl deleted file mode 100644 index 8388085b1..000000000 --- a/templates/mail/confirm-email.html.tmpl +++ /dev/null @@ -1,18 +0,0 @@ -{{template "mail-header.tmpl" .}} -

- Hi {{.User.Username}},
- {{if .IsNew}} -
- Welcome to Vikunja! - {{end}} -
- To confirm your email address, click the link below: -

- - Confirm your email address - -

- If the button above doesn't work, copy the url below and paste it in your browsers address bar:
- {{.FrontendURL}}?userEmailConfirm={{.User.EmailConfirmToken}} -

-{{template "mail-footer.tmpl"}} \ No newline at end of file diff --git a/templates/mail/confirm-email.plain.tmpl b/templates/mail/confirm-email.plain.tmpl deleted file mode 100644 index 9db1ee766..000000000 --- a/templates/mail/confirm-email.plain.tmpl +++ /dev/null @@ -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}} \ No newline at end of file diff --git a/templates/mail/mail-footer.tmpl b/templates/mail/mail-footer.tmpl deleted file mode 100644 index 51acb488e..000000000 --- a/templates/mail/mail-footer.tmpl +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/templates/mail/mail-header.tmpl b/templates/mail/mail-header.tmpl deleted file mode 100644 index 97031a652..000000000 --- a/templates/mail/mail-header.tmpl +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - -
-
-

- Vikunja -

-
\ No newline at end of file diff --git a/templates/mail/password-changed.html.tmpl b/templates/mail/password-changed.html.tmpl deleted file mode 100644 index 50cf66fba..000000000 --- a/templates/mail/password-changed.html.tmpl +++ /dev/null @@ -1,9 +0,0 @@ -{{template "mail-header.tmpl" .}} -

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

-{{template "mail-footer.tmpl"}} \ No newline at end of file diff --git a/templates/mail/password-changed.plain.tmpl b/templates/mail/password-changed.plain.tmpl deleted file mode 100644 index 0e82c336c..000000000 --- a/templates/mail/password-changed.plain.tmpl +++ /dev/null @@ -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. \ No newline at end of file diff --git a/templates/mail/reminder-email.html.tmpl b/templates/mail/reminder-email.html.tmpl deleted file mode 100644 index 44bc57d7e..000000000 --- a/templates/mail/reminder-email.html.tmpl +++ /dev/null @@ -1,17 +0,0 @@ -{{template "mail-header.tmpl" .}} -

- Hi {{if .User.Name}}{{.User.Name}}{{else}}{{.User.Username}}{{end}},
-
- This is a friendly reminder of the task "{{.Task.Title}}".
-

- - Open Task - -

- If the button above doesn't work, copy the url below and paste it in your browsers address bar:
- {{.FrontendURL}}tasks/{{.Task.ID}} -

-

- Have a nice day! -

-{{template "mail-footer.tmpl"}} \ No newline at end of file diff --git a/templates/mail/reminder-email.plain.tmpl b/templates/mail/reminder-email.plain.tmpl deleted file mode 100644 index b4f66e561..000000000 --- a/templates/mail/reminder-email.plain.tmpl +++ /dev/null @@ -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! diff --git a/templates/mail/reset-password.html.tmpl b/templates/mail/reset-password.html.tmpl deleted file mode 100644 index e0dea5a95..000000000 --- a/templates/mail/reset-password.html.tmpl +++ /dev/null @@ -1,14 +0,0 @@ -{{template "mail-header.tmpl" .}} -

- Hi {{.User.Username}},
-
- To reset your password, click the link below: -

- - Reset your password - -

- If the button above doesn't work, copy the url below and paste it in your browsers address bar:
- {{.FrontendURL}}?userPasswordReset={{.User.PasswordResetToken}} -

-{{template "mail-footer.tmpl"}} \ No newline at end of file diff --git a/templates/mail/reset-password.plain.tmpl b/templates/mail/reset-password.plain.tmpl deleted file mode 100644 index 83a46c40a..000000000 --- a/templates/mail/reset-password.plain.tmpl +++ /dev/null @@ -1,3 +0,0 @@ -Hi {{.User.Username}}, - -Use the following link to reset your password: {{.FrontendURL}}?userPasswordReset={{.User.PasswordResetToken}} \ No newline at end of file