From ea262ca60b4218f5af0bcee84d5b1f7e2c7eb048 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Sun, 24 Jan 2021 15:19:58 +0000 Subject: [PATCH] add optional welcome email for new users When enabled, an email with the server URL and username will be sent to created users. Requested in #38. --- api.go | 42 +++++++++++++++++++++++++++++++++++--- config.go | 3 +++ config/config-base.json | 45 +++++++++++++++++++++++++++++++++++++++-- email.go | 36 +++++++++++++++++++++++++++++++-- lang.go | 1 + lang/admin/en-us.json | 4 +++- lang/email/en-us.json | 7 +++++++ mail/welcome.mjml | 38 ++++++++++++++++++++++++++++++++++ mail/welcome.txt | 9 +++++++++ models.go | 6 ++++++ storage.go | 4 ++++ ts/modules/accounts.ts | 14 +++++++++++-- 12 files changed, 199 insertions(+), 10 deletions(-) create mode 100644 mail/welcome.mjml create mode 100644 mail/welcome.txt diff --git a/api.go b/api.go index ecb47bb..9ef197c 100644 --- a/api.go +++ b/api.go @@ -233,19 +233,28 @@ func (app *appContext) getOmbiUser(jfID string) (map[string]interface{}, int, er // @Security Bearer // @tags Users func (app *appContext) NewUserAdmin(gc *gin.Context) { + respondUser := func(code int, user, email bool, msg string, gc *gin.Context) { + resp := newUserResponse{ + User: user, + Email: email, + Error: msg, + } + gc.JSON(code, resp) + gc.Abort() + } var req newUserDTO gc.BindJSON(&req) existingUser, _, _ := app.jf.UserByName(req.Username, false) if existingUser != nil { msg := fmt.Sprintf("User already exists named %s", req.Username) app.info.Printf("%s New user failed: %s", req.Username, msg) - respond(401, msg, gc) + respondUser(401, false, false, msg, gc) return } user, status, err := app.jf.NewUser(req.Username, req.Password) if !(status == 200 || status == 204) || err != nil { app.err.Printf("%s New user failed: Jellyfin responded with %d", req.Username, status) - respond(401, "Unknown error", gc) + respondUser(401, false, false, "Unknown error", gc) return } var id string @@ -268,6 +277,7 @@ func (app *appContext) NewUserAdmin(gc *gin.Context) { app.err.Printf("%s: Failed to set configuration template: Code %d", req.Username, status) } } + app.jf.CacheExpiry = time.Now() if app.config.Section("password_resets").Key("enabled").MustBool(false) { app.storage.emails[id] = req.Email app.storage.storeEmails() @@ -284,7 +294,22 @@ func (app *appContext) NewUserAdmin(gc *gin.Context) { } } } - app.jf.CacheExpiry = time.Now() + if app.config.Section("welcome_email").Key("enabled").MustBool(false) && req.Email != "" { + app.debug.Printf("%s: Sending welcome email to %s", req.Username, req.Email) + msg, err := app.email.constructWelcome(req.Username, app) + if err != nil { + 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 { + app.err.Printf("%s: Failed to send welcome email: %s", req.Username, err) + respondUser(500, true, false, err.Error(), gc) + return + } else { + app.info.Printf("%s: Sent welcome email to %s", req.Username, req.Email) + } + } + respondUser(200, true, true, "", gc) } // @Summary Creates a new Jellyfin user via invite code @@ -398,6 +423,17 @@ func (app *appContext) NewUser(gc *gin.Context) { } } } + if app.config.Section("welcome_email").Key("enabled").MustBool(false) && req.Email != "" { + app.debug.Printf("%s: Sending welcome email to %s", req.Username, req.Email) + 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 { + 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) + } + } code := 200 for _, val := range validation { if !val { diff --git a/config.go b/config.go index 279e7a0..f4dc955 100644 --- a/config.go +++ b/config.go @@ -44,6 +44,9 @@ func (app *appContext) loadConfig() error { app.config.Section("deletion").Key("email_html").SetValue(app.config.Section("deletion").Key("email_html").MustString(filepath.Join(app.localPath, "deleted.html"))) app.config.Section("deletion").Key("email_text").SetValue(app.config.Section("deletion").Key("email_text").MustString(filepath.Join(app.localPath, "deleted.txt"))) + app.config.Section("welcome_email").Key("email_html").SetValue(app.config.Section("welcome_email").Key("email_html").MustString(filepath.Join(app.localPath, "welcome.html"))) + app.config.Section("welcome_email").Key("email_text").SetValue(app.config.Section("welcome_email").Key("email_text").MustString(filepath.Join(app.localPath, "welcome.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/config/config-base.json b/config/config-base.json index 03143af..a33dee3 100644 --- a/config/config-base.json +++ b/config/config-base.json @@ -448,7 +448,7 @@ "requires_restart": false, "depends_true": "enabled", "type": "text", - "value": "Password Reset - Jellyfin", + "value": "", "description": "Subject of password reset emails." } } @@ -491,7 +491,7 @@ "requires_restart": false, "depends_true": "enabled", "type": "text", - "value": "Invite - Jellyfin", + "value": "", "description": "Subject of invite emails." }, "url_base": { @@ -667,6 +667,47 @@ } } }, + "welcome_email": { + "order": [], + "meta": { + "name": "Welcome Emails", + "description": "Optionally send a welcome email to new users with the Jellyfin URL and their username." + }, + "settings": { + "enabled": { + "name": "Enabled", + "required": false, + "requires_restart": true, + "type": "bool", + "value": false, + "description": "Enable to send welcome emails to new users." + }, + "subject": { + "name": "Email subject", + "required": false, + "requires_restart": false, + "type": "text", + "value": "", + "description": "Subject of welcome emails." + }, + "email_html": { + "name": "Custom email (HTML)", + "required": false, + "requires_restart": false, + "type": "text", + "value": "", + "description": "Path to custom email html" + }, + "email_text": { + "name": "Custom email (plaintext)", + "required": false, + "requires_restart": false, + "type": "text", + "value": "", + "description": "Path to custom email in plain text" + } + } + }, "deletion": { "order": [], "meta": { diff --git a/email.go b/email.go index a8879d9..2fbbce3 100644 --- a/email.go +++ b/email.go @@ -262,7 +262,7 @@ func (emailer *Emailer) constructCreated(code, username, address string, invite func (emailer *Emailer) constructReset(pwr PasswordReset, app *appContext) (*Email, error) { email := &Email{ - subject: emailer.lang.PasswordReset.get("title"), + subject: app.config.Section("password_resets").Key("subject").MustString(emailer.lang.PasswordReset.get("title")), } d, t, expiresIn := emailer.formatExpiry(pwr.Expiry, true, app.datePattern, app.timePattern) message := app.config.Section("email").Key("message").String() @@ -297,7 +297,7 @@ func (emailer *Emailer) constructReset(pwr PasswordReset, app *appContext) (*Ema func (emailer *Emailer) constructDeleted(reason string, app *appContext) (*Email, error) { email := &Email{ - subject: emailer.lang.UserDeleted.get("title"), + subject: app.config.Section("deletion").Key("subject").MustString(emailer.lang.UserDeleted.get("title")), } for _, key := range []string{"html", "text"} { fpath := app.config.Section("deletion").Key("email_" + key).String() @@ -323,6 +323,38 @@ func (emailer *Emailer) constructDeleted(reason string, app *appContext) (*Email return email, nil } +func (emailer *Emailer) constructWelcome(username string, app *appContext) (*Email, error) { + email := &Email{ + subject: app.config.Section("welcome_email").Key("subject").MustString(emailer.lang.WelcomeEmail.get("title")), + } + for _, key := range []string{"html", "text"} { + fpath := app.config.Section("welcome_email").Key("email_" + key).String() + tpl, err := template.ParseFiles(fpath) + if err != nil { + return nil, err + } + var tplData bytes.Buffer + err = tpl.Execute(&tplData, map[string]string{ + "welcome": emailer.lang.WelcomeEmail.get("welcome"), + "youCanLoginWith": emailer.lang.WelcomeEmail.get("youCanLoginWith"), + "jellyfinURL": emailer.lang.WelcomeEmail.get("jellyfinURL"), + "jellyfinURLVal": app.config.Section("jellyfin").Key("public_server").String(), + "username": emailer.lang.WelcomeEmail.get("username"), + "usernameVal": username, + "message": app.config.Section("email").Key("message").String(), + }) + if err != nil { + return nil, err + } + if key == "html" { + email.html = tplData.String() + } else { + email.text = tplData.String() + } + } + return email, nil +} + // 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) diff --git a/lang.go b/lang.go index 327fd36..27f6fb7 100644 --- a/lang.go +++ b/lang.go @@ -74,6 +74,7 @@ type emailLang struct { PasswordReset langSection `json:"passwordReset"` UserDeleted langSection `json:"userDeleted"` InviteEmail langSection `json:"inviteEmail"` + WelcomeEmail langSection `json:"welcomeEmail"` } type langSection map[string]string diff --git a/lang/admin/en-us.json b/lang/admin/en-us.json index f545fd0..f02c000 100644 --- a/lang/admin/en-us.json +++ b/lang/admin/en-us.json @@ -98,7 +98,9 @@ "errorLoadOmbiUsers": "Failed to load ombi users.", "errorChangedEmailAddress": "Couldn't change email address of {n}.", "errorFailureCheckLogs": "Failed (check console/logs)", - "errorPartialFailureCheckLogs": "Partial failure (check console/logs)" + "errorPartialFailureCheckLogs": "Partial failure (check console/logs)", + "errorUserCreated": "Failed to create user {n}.", + "errorSendWelcomeEmail": "Failed to send welcome email (check console/logs)" }, "quantityStrings": { diff --git a/lang/email/en-us.json b/lang/email/en-us.json index 5645229..574a02a 100644 --- a/lang/email/en-us.json +++ b/lang/email/en-us.json @@ -37,5 +37,12 @@ "toJoin": "To join, follow the below link.", "inviteExpiry": "This invite will expire on {n}, at {n}, which is in {n}, so act quick.", "linkButton": "Setup your account" + }, + "welcomeEmail": { + "title": "Welcome to Jellyfin", + "welcome": "Welcome to Jellyfin!", + "youCanLoginWith": "You can login with the details below", + "jellyfinURL": "URL", + "username": "Username" } } diff --git a/mail/welcome.mjml b/mail/welcome.mjml new file mode 100644 index 0000000..0009741 --- /dev/null +++ b/mail/welcome.mjml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + Jellyfin + + + + + +

{{ .welcome }}

+

{{ .youCanLoginWith }}:

+ {{ .jellyfinURL }}: {{ .jellyfinURLVal }} +

{{ .username }}: {{ .usernameVal }}

+
+
+
+ + + + {{ .message }} + + + + +
diff --git a/mail/welcome.txt b/mail/welcome.txt new file mode 100644 index 0000000..f11fde4 --- /dev/null +++ b/mail/welcome.txt @@ -0,0 +1,9 @@ +{{ .welcome }} + +{{ .youCanLoginWith }}: + +{{ .jellyfinURL }}: {{ .jellyfinURLVal }} +{{ .username }}: {{ .usernameVal }} + + +{{ .message }} diff --git a/models.go b/models.go index 8c2b5de..f9fed6a 100644 --- a/models.go +++ b/models.go @@ -17,6 +17,12 @@ type newUserDTO struct { Code string `json:"code" example:"abc0933jncjkcjj"` // Invite code (required on /newUser) } +type newUserResponse struct { + User bool `json:"user" binding:"required"` // Whether user was created successfully + Email bool `json:"email"` // Whether welcome email was successfully sent (always true if feature is disabled + Error string `json:"error"` // Optional error message. +} + type deleteUserDTO struct { Users []string `json:"users" binding:"required"` // List of usernames to delete Notify bool `json:"notify"` // Whether to notify users of deletion diff --git a/storage.go b/storage.go index aa64cc3..68cfb91 100644 --- a/storage.go +++ b/storage.go @@ -71,6 +71,9 @@ func (st *Storage) loadLang() (err error) { // If a given language has missing values, fill it in with the english value. func patchLang(english, other *langSection) { + if *other == nil { + *other = langSection{} + } for n, ev := range *english { if v, ok := (*other)[n]; !ok || v == "" { (*other)[n] = ev @@ -215,6 +218,7 @@ func (st *Storage) loadLangEmail() error { patchLang(&english.PasswordReset, &lang.PasswordReset) patchLang(&english.UserDeleted, &lang.UserDeleted) patchLang(&english.InviteEmail, &lang.InviteEmail) + patchLang(&english.WelcomeEmail, &lang.WelcomeEmail) } st.lang.Email[index] = lang return nil diff --git a/ts/modules/accounts.ts b/ts/modules/accounts.ts index 1b78129..3025d62 100644 --- a/ts/modules/accounts.ts +++ b/ts/modules/accounts.ts @@ -214,13 +214,23 @@ export class accountsList { _post("/users", send, (req: XMLHttpRequest) => { if (req.readyState == 4) { toggleLoader(button); - if (req.status == 200) { + if (req.status == 200 || (req.response["user"] as boolean)) { window.notifications.customSuccess("addUser", window.lang.var("notifications", "userCreated", `"${send['username']}"`)); + if (!req.response["email"]) { + window.notifications.customError("sendWelcome", window.lang.notif("errorSendWelcomeEmail")); + console.log("User created, but welcome email failed"); + } + } else { + window.notifications.customError("addUser", window.lang.var("notifications", "errorUserCreated", `"${send['username']}"`)); } + if (req.response["error"] as String) { + console.log(req.response["error"]); + } + this.reload(); window.modals.addUser.close(); } - }); + }, true); } deleteUsers = () => {