1
0
mirror of https://github.com/hrfee/jfa-go.git synced 2024-12-22 09:00:10 +00:00

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.
This commit is contained in:
Harvey Tindall 2021-01-24 15:19:58 +00:00
parent 406fef6595
commit ea262ca60b
Signed by: hrfee
GPG Key ID: BBC65952848FB1A2
12 changed files with 199 additions and 10 deletions

42
api.go
View File

@ -233,19 +233,28 @@ func (app *appContext) getOmbiUser(jfID string) (map[string]interface{}, int, er
// @Security Bearer // @Security Bearer
// @tags Users // @tags Users
func (app *appContext) NewUserAdmin(gc *gin.Context) { 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 var req newUserDTO
gc.BindJSON(&req) gc.BindJSON(&req)
existingUser, _, _ := app.jf.UserByName(req.Username, false) existingUser, _, _ := app.jf.UserByName(req.Username, false)
if existingUser != nil { if existingUser != nil {
msg := fmt.Sprintf("User already exists named %s", req.Username) msg := fmt.Sprintf("User already exists named %s", req.Username)
app.info.Printf("%s New user failed: %s", req.Username, msg) app.info.Printf("%s New user failed: %s", req.Username, msg)
respond(401, msg, gc) respondUser(401, false, false, msg, gc)
return return
} }
user, status, err := app.jf.NewUser(req.Username, req.Password) user, status, err := app.jf.NewUser(req.Username, req.Password)
if !(status == 200 || status == 204) || err != nil { if !(status == 200 || status == 204) || err != nil {
app.err.Printf("%s New user failed: Jellyfin responded with %d", req.Username, status) 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 return
} }
var id string 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.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) { if app.config.Section("password_resets").Key("enabled").MustBool(false) {
app.storage.emails[id] = req.Email app.storage.emails[id] = req.Email
app.storage.storeEmails() 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 // @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 code := 200
for _, val := range validation { for _, val := range validation {
if !val { if !val {

View File

@ -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_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("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("version").SetValue(VERSION)
app.config.Section("jellyfin").Key("device").SetValue("jfa-go") 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)) app.config.Section("jellyfin").Key("device_id").SetValue(fmt.Sprintf("jfa-go-%s-%s", VERSION, COMMIT))

View File

@ -448,7 +448,7 @@
"requires_restart": false, "requires_restart": false,
"depends_true": "enabled", "depends_true": "enabled",
"type": "text", "type": "text",
"value": "Password Reset - Jellyfin", "value": "",
"description": "Subject of password reset emails." "description": "Subject of password reset emails."
} }
} }
@ -491,7 +491,7 @@
"requires_restart": false, "requires_restart": false,
"depends_true": "enabled", "depends_true": "enabled",
"type": "text", "type": "text",
"value": "Invite - Jellyfin", "value": "",
"description": "Subject of invite emails." "description": "Subject of invite emails."
}, },
"url_base": { "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": { "deletion": {
"order": [], "order": [],
"meta": { "meta": {

View File

@ -262,7 +262,7 @@ func (emailer *Emailer) constructCreated(code, username, address string, invite
func (emailer *Emailer) constructReset(pwr PasswordReset, app *appContext) (*Email, error) { func (emailer *Emailer) constructReset(pwr PasswordReset, app *appContext) (*Email, error) {
email := &Email{ 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) d, t, expiresIn := emailer.formatExpiry(pwr.Expiry, true, app.datePattern, app.timePattern)
message := app.config.Section("email").Key("message").String() 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) { func (emailer *Emailer) constructDeleted(reason string, app *appContext) (*Email, error) {
email := &Email{ 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"} { for _, key := range []string{"html", "text"} {
fpath := app.config.Section("deletion").Key("email_" + key).String() 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 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. // calls the send method in the underlying emailClient.
func (emailer *Emailer) send(address string, email *Email) error { func (emailer *Emailer) send(address string, email *Email) error {
return emailer.sender.send(address, emailer.fromName, emailer.fromAddr, email) return emailer.sender.send(address, emailer.fromName, emailer.fromAddr, email)

View File

@ -74,6 +74,7 @@ type emailLang struct {
PasswordReset langSection `json:"passwordReset"` PasswordReset langSection `json:"passwordReset"`
UserDeleted langSection `json:"userDeleted"` UserDeleted langSection `json:"userDeleted"`
InviteEmail langSection `json:"inviteEmail"` InviteEmail langSection `json:"inviteEmail"`
WelcomeEmail langSection `json:"welcomeEmail"`
} }
type langSection map[string]string type langSection map[string]string

View File

@ -98,7 +98,9 @@
"errorLoadOmbiUsers": "Failed to load ombi users.", "errorLoadOmbiUsers": "Failed to load ombi users.",
"errorChangedEmailAddress": "Couldn't change email address of {n}.", "errorChangedEmailAddress": "Couldn't change email address of {n}.",
"errorFailureCheckLogs": "Failed (check console/logs)", "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": { "quantityStrings": {

View File

@ -37,5 +37,12 @@
"toJoin": "To join, follow the below link.", "toJoin": "To join, follow the below link.",
"inviteExpiry": "This invite will expire on {n}, at {n}, which is in {n}, so act quick.", "inviteExpiry": "This invite will expire on {n}, at {n}, which is in {n}, so act quick.",
"linkButton": "Setup your account" "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"
} }
} }

38
mail/welcome.mjml Normal file
View File

@ -0,0 +1,38 @@
<mjml>
<mj-head>
<mj-attributes>
<mj-class name="bg" background-color="#101010" />
<mj-class name="bg2" background-color="#242424" />
<mj-class name="text" color="rgba(255,255,255,0.8)" />
<mj-class name="bold" color="rgba(255,255,255,0.87)" />
<mj-class name="secondary" color="rgb(153,153,153)" />
<mj-class name="blue" background-color="rgb(0,164,220)" />
</mj-attributes>
<mj-font name="Quicksand" href="https://fonts.googleapis.com/css2?family=Quicksand" />
<mj-font name="Noto Sans" href="https://fonts.googleapis.com/css2?family=Noto+Sans" />
</mj-head>
<mj-body>
<mj-section mj-class="bg2">
<mj-column>
<mj-text mj-class="bold" font-size="25px" font-family="Quicksand, Noto Sans, Helvetica, Arial, sans-serif"> Jellyfin </mj-text>
</mj-column>
</mj-section>
<mj-section mj-class="bg">
<mj-column>
<mj-text mj-class="text" font-size="16px" font-family="Noto Sans, Helvetica, Arial, sans-serif">
<h3>{{ .welcome }}</h3>
<p>{{ .youCanLoginWith }}:</p>
{{ .jellyfinURL }}: <a href="{{ .jellyfinURLVal }}">{{ .jellyfinURLVal }}</a>
<p>{{ .username }}: <i>{{ .usernameVal }}</i></p>
</mj-text>
</mj-column>
</mj-section>
<mj-section mj-class="bg2">
<mj-column>
<mj-text mj-class="secondary" font-style="italic" font-size="14px">
{{ .message }}
</mj-text>
</mj-column>
</mj-section>
</body>
</mjml>

9
mail/welcome.txt Normal file
View File

@ -0,0 +1,9 @@
{{ .welcome }}
{{ .youCanLoginWith }}:
{{ .jellyfinURL }}: {{ .jellyfinURLVal }}
{{ .username }}: {{ .usernameVal }}
{{ .message }}

View File

@ -17,6 +17,12 @@ type newUserDTO struct {
Code string `json:"code" example:"abc0933jncjkcjj"` // Invite code (required on /newUser) 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 { type deleteUserDTO struct {
Users []string `json:"users" binding:"required"` // List of usernames to delete Users []string `json:"users" binding:"required"` // List of usernames to delete
Notify bool `json:"notify"` // Whether to notify users of deletion Notify bool `json:"notify"` // Whether to notify users of deletion

View File

@ -71,6 +71,9 @@ func (st *Storage) loadLang() (err error) {
// If a given language has missing values, fill it in with the english value. // If a given language has missing values, fill it in with the english value.
func patchLang(english, other *langSection) { func patchLang(english, other *langSection) {
if *other == nil {
*other = langSection{}
}
for n, ev := range *english { for n, ev := range *english {
if v, ok := (*other)[n]; !ok || v == "" { if v, ok := (*other)[n]; !ok || v == "" {
(*other)[n] = ev (*other)[n] = ev
@ -215,6 +218,7 @@ func (st *Storage) loadLangEmail() error {
patchLang(&english.PasswordReset, &lang.PasswordReset) patchLang(&english.PasswordReset, &lang.PasswordReset)
patchLang(&english.UserDeleted, &lang.UserDeleted) patchLang(&english.UserDeleted, &lang.UserDeleted)
patchLang(&english.InviteEmail, &lang.InviteEmail) patchLang(&english.InviteEmail, &lang.InviteEmail)
patchLang(&english.WelcomeEmail, &lang.WelcomeEmail)
} }
st.lang.Email[index] = lang st.lang.Email[index] = lang
return nil return nil

View File

@ -214,13 +214,23 @@ export class accountsList {
_post("/users", send, (req: XMLHttpRequest) => { _post("/users", send, (req: XMLHttpRequest) => {
if (req.readyState == 4) { if (req.readyState == 4) {
toggleLoader(button); 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']}"`)); 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(); this.reload();
window.modals.addUser.close(); window.modals.addUser.close();
} }
}); }, true);
} }
deleteUsers = () => { deleteUsers = () => {