From fa433c88a8940f136cf4d2ce1f96f3e1027af848 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Thu, 18 Feb 2021 14:58:53 +0000 Subject: [PATCH] add announcement emails After selecting users in the accounts tab, you can press 'Announce', then write a subject and message (with markdown), and an email will be sent to each selected user. --- api.go | 56 ++++++++++++++++++++++++++++++------ config.go | 3 ++ css/base.css | 1 + email.go | 64 +++++++++++++++++++++++++++++++----------- go.mod | 1 + go.sum | 4 +++ html/admin.html | 17 +++++++++++ lang/admin/en-us.json | 9 ++++++ mail/announcement.mjml | 35 +++++++++++++++++++++++ mail/announcement.txt | 3 ++ models.go | 6 ++++ package-lock.json | 6 ++-- package.json | 2 +- pwreset.go | 2 +- router.go | 1 + ts/admin.ts | 2 ++ ts/modules/accounts.ts | 43 ++++++++++++++++++++++++++++ ts/typings/d.ts | 1 + 18 files changed, 226 insertions(+), 30 deletions(-) create mode 100644 mail/announcement.mjml create mode 100644 mail/announcement.txt diff --git a/api.go b/api.go index cf051e2..a6d7950 100644 --- a/api.go +++ b/api.go @@ -130,7 +130,7 @@ func (app *appContext) checkInvites() { if err != nil { app.err.Printf("%s: Failed to construct expiry notification", code) app.debug.Printf("Error: %s", err) - } else if err := app.email.send(addr, msg); err != nil { + } else if err := app.email.send(msg, addr); err != nil { app.err.Printf("%s: Failed to send expiry notification", code) app.debug.Printf("Error: %s", err) } else { @@ -169,7 +169,7 @@ func (app *appContext) checkInvite(code string, used bool, username string) bool if err != nil { app.err.Printf("%s: Failed to construct expiry notification", code) app.debug.Printf("Error: %s", err) - } else if err := app.email.send(address, msg); err != nil { + } else if err := app.email.send(msg, address); err != nil { app.err.Printf("%s: Failed to send expiry notification", code) app.debug.Printf("Error: %s", err) } else { @@ -308,7 +308,7 @@ func (app *appContext) NewUserAdmin(gc *gin.Context) { app.err.Printf("%s: Failed to construct welcome email: %s", req.Username, err) respondUser(500, true, false, err.Error(), gc) return - } else if err := app.email.send(req.Email, msg); err != nil { + } else if err := app.email.send(msg, req.Email); err != nil { app.err.Printf("%s: Failed to send welcome email: %s", req.Username, err) respondUser(500, true, false, err.Error(), gc) return @@ -363,7 +363,7 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc if err != nil { app.err.Printf("%s: Failed to construct confirmation email", req.Code) app.debug.Printf("%s: Error: %s", req.Code, err) - } else if err := app.email.send(req.Email, msg); err != nil { + } else if err := app.email.send(msg, req.Email); err != nil { app.err.Printf("%s: Failed to send user confirmation email: %s", req.Code, err) } else { app.info.Printf("%s: Sent user confirmation email to %s", req.Code, req.Email) @@ -393,7 +393,7 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc if err != nil { app.err.Printf("%s: Failed to construct user creation notification", req.Code) app.debug.Printf("%s: Error: %s", req.Code, err) - } else if err := app.email.send(address, msg); err != nil { + } else if err := app.email.send(msg, address); err != nil { app.err.Printf("%s: Failed to send user creation notification", req.Code) app.debug.Printf("%s: Error: %s", req.Code, err) } else { @@ -455,7 +455,7 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc msg, err := app.email.constructWelcome(req.Username, app) if err != nil { app.err.Printf("%s: Failed to construct welcome email: %s", req.Username, err) - } else if err := app.email.send(req.Email, msg); err != nil { + } else if err := app.email.send(msg, req.Email); err != nil { app.err.Printf("%s: Failed to send welcome email: %s", req.Username, err) } else { app.info.Printf("%s: Sent welcome email to %s", req.Username, req.Email) @@ -508,6 +508,46 @@ func (app *appContext) NewUser(gc *gin.Context) { gc.JSON(code, validation) } +// @Summary Send an announcement via email to a given list of users. +// @Produce json +// @Param announcementDTO body announcementDTO true "Announcement request object" +// @Success 200 {object} boolResponse +// @Failure 400 {object} boolResponse +// @Failure 500 {object} boolResponse +// @Router /users/announce [post] +// @Security Bearer +// @tags Users +func (app *appContext) Announce(gc *gin.Context) { + var req announcementDTO + gc.BindJSON(&req) + if !emailEnabled { + respondBool(400, false, gc) + return + } + addresses := []string{} + for _, userID := range req.Users { + addr, ok := app.storage.emails[userID] + if !ok || addr == "" { + continue + } + addresses = append(addresses, addr.(string)) + } + msg, err := app.email.constructAnnouncement(req.Subject, req.Message, app) + if err != nil { + app.err.Println("Failed to construct announcement email") + app.debug.Printf("Error: %s", err) + respondBool(500, false, gc) + return + } else if err := app.email.send(msg, addresses...); err != nil { + app.err.Println("Failed to send announcement email") + app.debug.Printf("Error: %s", err) + respondBool(500, false, gc) + return + } + app.info.Println("Sent announcement email") + respondBool(200, true, gc) +} + // @Summary Delete a list of users, optionally notifying them why. // @Produce json // @Param deleteUserDTO body deleteUserDTO true "User deletion request object" @@ -552,7 +592,7 @@ func (app *appContext) DeleteUser(gc *gin.Context) { if err != nil { app.err.Printf("%s: Failed to construct account deletion email", userID) app.debug.Printf("%s: Error: %s", userID, err) - } else if err := app.email.send(address, msg); err != nil { + } else if err := app.email.send(msg, address); err != nil { app.err.Printf("%s: Failed to send to %s", userID, address) app.debug.Printf("%s: Error: %s", userID, err) } else { @@ -619,7 +659,7 @@ func (app *appContext) GenerateInvite(gc *gin.Context) { invite.Email = fmt.Sprintf("Failed to send to %s", req.Email) app.err.Printf("%s: Failed to construct invite email", inviteCode) app.debug.Printf("%s: Error: %s", inviteCode, err) - } else if err := app.email.send(req.Email, msg); err != nil { + } else if err := app.email.send(msg, req.Email); err != nil { invite.Email = fmt.Sprintf("Failed to send to %s", req.Email) app.err.Printf("%s: %s", inviteCode, invite.Email) app.debug.Printf("%s: Error: %s", inviteCode, err) diff --git a/config.go b/config.go index 0317f40..b1d7200 100644 --- a/config.go +++ b/config.go @@ -63,6 +63,9 @@ func (app *appContext) loadConfig() error { app.config.Section("welcome_email").Key("email_html").SetValue(app.config.Section("welcome_email").Key("email_html").MustString("jfa-go:" + "welcome.html")) app.config.Section("welcome_email").Key("email_text").SetValue(app.config.Section("welcome_email").Key("email_text").MustString("jfa-go:" + "welcome.txt")) + app.config.Section("announcement_email").Key("email_html").SetValue(app.config.Section("announcement_email").Key("email_html").MustString("jfa-go:" + "announcement.html")) + app.config.Section("announcement_email").Key("email_text").SetValue(app.config.Section("announcement_email").Key("email_text").MustString("jfa-go:" + "announcement.txt")) + app.config.Section("jellyfin").Key("version").SetValue(VERSION) app.config.Section("jellyfin").Key("device").SetValue("jfa-go") app.config.Section("jellyfin").Key("device_id").SetValue(fmt.Sprintf("jfa-go-%s-%s", VERSION, COMMIT)) diff --git a/css/base.css b/css/base.css index c37d2b1..2e6f656 100644 --- a/css/base.css +++ b/css/base.css @@ -164,6 +164,7 @@ div.card:contains(section.banner.footer) { .monospace { background-color: inherit; /* so we can use a17t code blocks */ + font-family: Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace; } sup.\~critical, .text-critical { diff --git a/email.go b/email.go index b75197c..73799db 100644 --- a/email.go +++ b/email.go @@ -8,8 +8,11 @@ import ( "html/template" "net/smtp" "strings" + "sync" "time" + "github.com/gomarkdown/markdown" + "github.com/gomarkdown/markdown/html" jEmail "github.com/jordan-wright/email" "github.com/knz/strtime" "github.com/mailgun/mailgun-go/v4" @@ -17,7 +20,7 @@ import ( // implements email sending, right now via smtp or mailgun. type emailClient interface { - send(address, fromName, fromAddr string, email *Email) error + send(fromName, fromAddr string, email *Email, address ...string) error } // Mailgun client implements emailClient. @@ -25,13 +28,16 @@ type Mailgun struct { client *mailgun.MailgunImpl } -func (mg *Mailgun) send(address, fromName, fromAddr string, email *Email) error { +func (mg *Mailgun) send(fromName, fromAddr string, email *Email, address ...string) error { message := mg.client.NewMessage( fmt.Sprintf("%s <%s>", fromName, fromAddr), email.subject, email.text, - address, ) + for _, a := range address { + // Adding variable tells mailgun to do a batch send, so users don't see other recipients. + message.AddRecipientAndVariables(a, map[string]interface{}{"unique_id": a}) + } message.SetHtml(email.html) ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) defer cancel() @@ -47,25 +53,33 @@ type SMTP struct { auth smtp.Auth } -func (sm *SMTP) send(address, fromName, fromAddr string, email *Email) error { - e := jEmail.NewEmail() - e.Subject = email.subject - e.From = fmt.Sprintf("%s <%s>", fromName, fromAddr) - e.To = []string{address} - e.Text = []byte(email.text) - e.HTML = []byte(email.html) +func (sm *SMTP) send(fromName, fromAddr string, email *Email, address ...string) error { server := fmt.Sprintf("%s:%d", sm.server, sm.port) tlsConfig := &tls.Config{ InsecureSkipVerify: false, ServerName: sm.server, } + from := fmt.Sprintf("%s <%s>", fromName, fromAddr) + var wg sync.WaitGroup var err error - // err = e.Send(server, sm.auth) - if sm.sslTLS { - err = e.SendWithTLS(server, sm.auth, tlsConfig) - } else { - err = e.SendWithStartTLS(server, sm.auth, tlsConfig) + for _, addr := range address { + wg.Add(1) + go func(addr string) { + defer wg.Done() + e := jEmail.NewEmail() + e.Subject = email.subject + e.From = from + e.Text = []byte(email.text) + e.HTML = []byte(email.html) + e.To = []string{addr} + if sm.sslTLS { + err = e.SendWithTLS(server, sm.auth, tlsConfig) + } else { + err = e.SendWithStartTLS(server, sm.auth, tlsConfig) + } + }(addr) } + wg.Wait() return err } @@ -197,6 +211,22 @@ func (emailer *Emailer) constructConfirmation(code, username, key string, app *a return email, nil } +func (emailer *Emailer) constructAnnouncement(subject, md string, app *appContext) (*Email, error) { + email := &Email{subject: subject} + renderer := html.NewRenderer(html.RendererOptions{Flags: html.Smartypants}) + html := markdown.ToHTML([]byte(md), nil, renderer) + message := app.config.Section("email").Key("message").String() + var err error + email.html, email.text, err = emailer.construct(app, "announcement_email", "email_", map[string]interface{}{ + "text": template.HTML(html), + "message": message, + }) + if err != nil { + return nil, err + } + return email, nil +} + func (emailer *Emailer) constructInvite(code string, invite Invite, app *appContext) (*Email, error) { email := &Email{ subject: app.config.Section("email_confirmation").Key("subject").MustString(emailer.lang.InviteEmail.get("title")), @@ -327,6 +357,6 @@ func (emailer *Emailer) constructWelcome(username string, app *appContext) (*Ema } // calls the send method in the underlying emailClient. -func (emailer *Emailer) send(address string, email *Email) error { - return emailer.sender.send(address, emailer.fromName, emailer.fromAddr, email) +func (emailer *Emailer) send(email *Email, address ...string) error { + return emailer.sender.send(emailer.fromName, emailer.fromAddr, email, address...) } diff --git a/go.mod b/go.mod index c0fe2a4..8f4c777 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,7 @@ require ( github.com/go-openapi/spec v0.20.3 // indirect github.com/go-playground/validator/v10 v10.4.1 // indirect github.com/golang/protobuf v1.4.3 // indirect + github.com/gomarkdown/markdown v0.0.0-20210208175418-bda154fe17d8 // indirect github.com/google/uuid v1.1.2 // indirect github.com/hrfee/jfa-go/common v0.0.0-20210105184019-fdc97b4e86cc github.com/hrfee/jfa-go/docs v0.0.0-20201112212552-b6f3cd7c1f71 diff --git a/go.sum b/go.sum index e79ad85..8b84f9c 100644 --- a/go.sum +++ b/go.sum @@ -109,6 +109,8 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= github.com/golang/protobuf v1.4.3 h1:JjCZWpVbqXDqFVmTfYWEVTMIYrL/NPdPSCHPJ0T/raM= github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/gomarkdown/markdown v0.0.0-20210208175418-bda154fe17d8 h1:nWU6p08f1VgIalT6iZyqXi4o5cZsz4X6qa87nusfcsc= +github.com/gomarkdown/markdown v0.0.0-20210208175418-bda154fe17d8/go.mod h1:aii0r/K0ZnHv7G0KF7xy1v0A7s2Ljrb5byB7MO5p6TU= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= @@ -226,6 +228,8 @@ github.com/urfave/cli/v2 v2.3.0 h1:qph92Y649prgesehzOrQjdWyxFOp/QVM+6imKHad91M= github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= github.com/yuin/goldmark v1.2.1 h1:ruQGxdhGHe7FWOJPT0mKs5+pD2Xs1Bm/kdGlHO04FmM= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +golang.org/dl v0.0.0-20190829154251-82a15e2f2ead h1:jeP6FgaSLNTMP+Yri3qjlACywQLye+huGLmNGhBzm6k= +golang.org/dl v0.0.0-20190829154251-82a15e2f2ead/go.mod h1:IUMfjQLJQd4UTqG1Z90tenwKoCX93Gn3MAQJMOSBsDQ= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= diff --git a/html/admin.html b/html/admin.html index c7c7836..b74e9cd 100644 --- a/html/admin.html +++ b/html/admin.html @@ -93,6 +93,22 @@ +