diff --git a/.gitignore b/.gitignore index 6208b48..16f6fc2 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ server.pem server.crt instructions-debian.txt cl.md +./telegram/ diff --git a/README.md b/README.md index bf86ccf..de10d97 100644 --- a/README.md +++ b/README.md @@ -17,11 +17,12 @@ jfa-go is a user management app for [Jellyfin](https://github.com/jellyfin/jelly * ⌛ User expiry: Specify a validity period, and new users accounts will be disabled/deleted after it. The period can be manually extended too. * 🔗 Ombi Integration: Automatically creates Ombi accounts for new users using their email address and login details, and your own defined set of permissions. * Account management: Apply settings to your users individually or en masse, and delete users, optionally sending them an email notification with a reason. +* Telegram Integration: Verify users via telegram, and send Password Resets, Announcements, etc. through it. * 📨 Email storage: Add your existing users email addresses through the UI, and jfa-go will ask new users for them on account creation. * Email addresses can optionally be used instead of usernames -* 🔑 Password resets: When users forget their passwords and request a change in Jellyfin, jfa-go reads the PIN from the created file and sends it straight to the user via email. +* 🔑 Password resets: When users forget their passwords and request a change in Jellyfin, jfa-go reads the PIN from the created file and sends it straight to the user via email/telegram. * Notifications: Get notified when someone creates an account, or an invite expires. -* 📣 Announcements: Bulk email your users with announcements about your server. +* 📣 Announcements: Bulk message your users with announcements about your server. * Authentication via Jellyfin: Instead of using separate credentials for jfa-go and Jellyfin, jfa-go can use it as the authentication provider. * Enables the usage of jfa-go by multiple people * 🌓 Customizations diff --git a/api.go b/api.go index 574dccb..f56267f 100644 --- a/api.go +++ b/api.go @@ -39,9 +39,9 @@ func respondBool(code int, val bool, gc *gin.Context) { } func (app *appContext) loadStrftime() { - app.datePattern = app.config.Section("email").Key("date_format").String() + app.datePattern = app.config.Section("messages").Key("date_format").String() app.timePattern = `%H:%M` - if val, _ := app.config.Section("email").Key("use_24h").Bool(); !val { + if val, _ := app.config.Section("messages").Key("use_24h").Bool(); !val { app.timePattern = `%I:%M %p` } return @@ -282,7 +282,7 @@ func (app *appContext) NewUserAdmin(gc *gin.Context) { } } app.jf.CacheExpiry = time.Now() - if app.config.Section("password_resets").Key("enabled").MustBool(false) { + if emailEnabled { app.storage.emails[id] = req.Email app.storage.storeEmails() } @@ -330,15 +330,44 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc success = false return } + telegramTokenIndex := -1 + if telegramEnabled { + if req.TelegramPIN == "" { + if app.config.Section("telegram").Key("required").MustBool(false) { + f = func(gc *gin.Context) { + app.debug.Printf("%s: New user failed: Telegram verification not completed", req.Code) + respond(401, "errorTelegramVerification", gc) + } + success = false + return + } + } else { + for i, v := range app.telegram.verifiedTokens { + if v.Token == req.TelegramPIN { + telegramTokenIndex = i + break + } + } + if telegramTokenIndex == -1 { + f = func(gc *gin.Context) { + app.debug.Printf("%s: New user failed: Telegram PIN was invalid", req.Code) + respond(401, "errorInvalidPIN", gc) + } + success = false + return + } + } + } if emailEnabled && app.config.Section("email_confirmation").Key("enabled").MustBool(false) && !confirmed { claims := jwt.MapClaims{ - "valid": true, - "invite": req.Code, - "email": req.Email, - "username": req.Username, - "password": req.Password, - "exp": strconv.FormatInt(time.Now().Add(time.Hour*12).Unix(), 10), - "type": "confirmation", + "valid": true, + "invite": req.Code, + "email": req.Email, + "username": req.Username, + "password": req.Password, + "telegramPIN": req.TelegramPIN, + "exp": strconv.FormatInt(time.Now().Add(time.Hour*12).Unix(), 10), + "type": "confirmation", } tk := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) key, err := tk.SignedString([]byte(os.Getenv("JFA_SECRET"))) @@ -450,15 +479,40 @@ func (app *appContext) newUser(req newUserDTO, confirmed bool) (f errorFunc, suc app.err.Printf("Failed to store user duration: %v", err) } } - if emailEnabled && app.config.Section("welcome_email").Key("enabled").MustBool(false) && req.Email != "" { - app.debug.Printf("%s: Sending welcome email to %s", req.Username, req.Email) + + if telegramEnabled && telegramTokenIndex != -1 { + tgToken := app.telegram.verifiedTokens[telegramTokenIndex] + tgUser := TelegramUser{ + ChatID: tgToken.ChatID, + Username: tgToken.Username, + Contact: req.TelegramContact, + } + if lang, ok := app.telegram.languages[tgToken.ChatID]; ok { + tgUser.Lang = lang + } + if app.storage.telegram == nil { + app.storage.telegram = map[string]TelegramUser{} + } + app.storage.telegram[user.ID] = tgUser + err := app.storage.storeTelegramUsers() + if err != nil { + app.err.Printf("Failed to store Telegram users: %v", err) + } else { + app.telegram.verifiedTokens[len(app.telegram.verifiedTokens)-1], app.telegram.verifiedTokens[telegramTokenIndex] = app.telegram.verifiedTokens[telegramTokenIndex], app.telegram.verifiedTokens[len(app.telegram.verifiedTokens)-1] + app.telegram.verifiedTokens = app.telegram.verifiedTokens[:len(app.telegram.verifiedTokens)-1] + } + } + + if (emailEnabled && app.config.Section("welcome_email").Key("enabled").MustBool(false) && req.Email != "") || telegramTokenIndex != -1 { + name := app.getAddressOrName(user.ID) + app.debug.Printf("%s: Sending welcome message to %s", req.Username, name) msg, err := app.email.constructWelcome(req.Username, expiry, app, false) if err != nil { - app.err.Printf("%s: Failed to construct welcome email: %v", req.Username, err) - } else if err := app.email.send(msg, req.Email); err != nil { - app.err.Printf("%s: Failed to send welcome email: %v", req.Username, err) + app.err.Printf("%s: Failed to construct welcome message: %v", req.Username, err) + } else if err := app.sendByID(msg, user.ID); err != nil { + app.err.Printf("%s: Failed to send welcome message: %v", req.Username, err) } else { - app.info.Printf("%s: Sent welcome email to \"%s\"", req.Username, req.Email) + app.info.Printf("%s: Sent welcome message to \"%s\"", req.Username, name) } } app.jf.CacheExpiry = time.Now() @@ -525,7 +579,20 @@ func (app *appContext) EnableDisableUsers(gc *gin.Context) { "GetUser": map[string]string{}, "SetPolicy": map[string]string{}, } - var addresses []string + sendMail := messagesEnabled + var msg *Message + var err error + if sendMail { + if req.Enabled { + msg, err = app.email.constructEnabled(req.Reason, app, false) + } else { + msg, err = app.email.constructDisabled(req.Reason, app, false) + } + if err != nil { + app.err.Printf("Failed to construct account enabled/disabled emails: %v", err) + sendMail = false + } + } for _, userID := range req.Users { user, status, err := app.jf.UserByID(userID, false) if status != 200 || err != nil { @@ -540,31 +607,13 @@ func (app *appContext) EnableDisableUsers(gc *gin.Context) { app.err.Printf("Failed to set policy for user \"%s\" (%d): %v", userID, status, err) continue } - if emailEnabled && req.Notify { - addr, ok := app.storage.emails[userID] - if addr != nil && ok { - addresses = append(addresses, addr.(string)) + if sendMail && req.Notify { + if err := app.sendByID(msg, userID); err != nil { + app.err.Printf("Failed to send account enabled/disabled email: %v", err) + continue } } } - if len(addresses) != 0 { - go func(reason string, addresses []string) { - var msg *Email - var err error - if req.Enabled { - msg, err = app.email.constructEnabled(reason, app, false) - } else { - msg, err = app.email.constructDisabled(reason, app, false) - } - if err != nil { - app.err.Printf("Failed to construct account enabled/disabled emails: %v", err) - } else if err := app.email.send(msg, addresses...); err != nil { - app.err.Printf("Failed to send account enabled/disabled emails: %v", err) - } else { - app.info.Println("Sent account enabled/disabled emails") - } - }(req.Reason, addresses) - } app.jf.CacheExpiry = time.Now() if len(errors["GetUser"]) != 0 || len(errors["SetPolicy"]) != 0 { gc.JSON(500, errors) @@ -584,10 +633,19 @@ func (app *appContext) EnableDisableUsers(gc *gin.Context) { // @tags Users func (app *appContext) DeleteUsers(gc *gin.Context) { var req deleteUserDTO - var addresses []string gc.BindJSON(&req) errors := map[string]string{} ombiEnabled := app.config.Section("ombi").Key("enabled").MustBool(false) + sendMail := messagesEnabled + var msg *Message + var err error + if sendMail { + msg, err = app.email.constructDeleted(req.Reason, app, false) + if err != nil { + app.err.Printf("Failed to construct account deletion emails: %v", err) + sendMail = false + } + } for _, userID := range req.Users { if ombiEnabled { ombiUser, code, err := app.getOmbiUser(userID) @@ -610,25 +668,12 @@ func (app *appContext) DeleteUsers(gc *gin.Context) { errors[userID] += msg } } - if emailEnabled && req.Notify { - addr, ok := app.storage.emails[userID] - if addr != nil && ok { - addresses = append(addresses, addr.(string)) + if sendMail && req.Notify { + if err := app.sendByID(msg, userID); err != nil { + app.err.Printf("Failed to send account deletion email: %v", err) } } } - if len(addresses) != 0 { - go func(reason string, addresses []string) { - msg, err := app.email.constructDeleted(reason, app, false) - if err != nil { - app.err.Printf("Failed to construct account deletion emails: %v", err) - } else if err := app.email.send(msg, addresses...); err != nil { - app.err.Printf("Failed to send account deletion emails: %v", err) - } else { - app.info.Println("Sent account deletion emails") - } - }(req.Reason, addresses) - } app.jf.CacheExpiry = time.Now() if len(errors) == len(req.Users) { respondBool(500, false, gc) @@ -685,29 +730,21 @@ func (app *appContext) ExtendExpiry(gc *gin.Context) { func (app *appContext) Announce(gc *gin.Context) { var req announcementDTO gc.BindJSON(&req) - if !emailEnabled { + if !messagesEnabled { 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.constructTemplate(req.Subject, req.Message, app) if err != nil { - app.err.Printf("Failed to construct announcement emails: %v", err) + app.err.Printf("Failed to construct announcement messages: %v", err) respondBool(500, false, gc) return - } else if err := app.email.send(msg, addresses...); err != nil { - app.err.Printf("Failed to send announcement emails: %v", err) + } else if err := app.sendByID(msg, req.Users...); err != nil { + app.err.Printf("Failed to send announcement messages: %v", err) respondBool(500, false, gc) return } - app.info.Printf("Sent announcement email to %d users", len(addresses)) + app.info.Println("Sent announcement messages") respondBool(200, true, gc) } @@ -1137,7 +1174,10 @@ func (app *appContext) GetUsers(gc *gin.Context) { if ok { user.Expiry = expiry.Unix() } - + if tgUser, ok := app.storage.telegram[jfUser.ID]; ok { + user.Telegram = tgUser.Username + user.NotifyThroughTelegram = tgUser.Contact + } resp.UserList[i] = user i++ } @@ -1345,6 +1385,10 @@ func (app *appContext) GetConfig(gc *gin.Context) { el := resp.Sections["email"].Settings["language"] el.Options = emailOptions el.Value = app.config.Section("email").Key("language").MustString("en-us") + telegramOptions := app.storage.lang.Email.getOptions() + tl := resp.Sections["telegram"].Settings["language"] + tl.Options = telegramOptions + tl.Value = app.config.Section("telegram").Key("language").MustString("en-us") if updater == "" { delete(resp.Sections, "updates") for i, v := range resp.Order { @@ -1373,6 +1417,7 @@ func (app *appContext) GetConfig(gc *gin.Context) { resp.Sections["ui"].Settings["language-admin"] = al resp.Sections["email"].Settings["language"] = el resp.Sections["password_resets"].Settings["language"] = pl + resp.Sections["telegram"].Settings["language"] = tl gc.JSON(200, resp) } @@ -1397,6 +1442,9 @@ func (app *appContext) ModifyConfig(gc *gin.Context) { tempConfig.NewSection(section) } for setting, value := range settings.(map[string]interface{}) { + if section == "email" && setting == "method" && value == "disabled" { + value = "" + } if value.(string) != app.config.Section(section).Key(setting).MustString("") { tempConfig.Section(section).Key(setting).SetValue(value.(string)) } @@ -1438,6 +1486,7 @@ func (app *appContext) ModifyConfig(gc *gin.Context) { // @Param lang query string false "Language for email titles." // @Success 200 {object} emailListDTO // @Router /config/emails [get] +// @Security Bearer // @tags Configuration func (app *appContext) GetCustomEmails(gc *gin.Context) { lang := gc.Query("lang") @@ -1466,6 +1515,7 @@ func (app *appContext) GetCustomEmails(gc *gin.Context) { // @Failure 500 {object} boolResponse // @Param id path string true "ID of email" // @Router /config/emails/{id} [post] +// @Security Bearer // @tags Configuration func (app *appContext) SetCustomEmail(gc *gin.Context) { var req customEmail @@ -1525,6 +1575,7 @@ func (app *appContext) SetCustomEmail(gc *gin.Context) { // @Param enable/disable path string true "enable/disable" // @Param id path string true "ID of email" // @Router /config/emails/{id}/state/{enable/disable} [post] +// @Security Bearer // @tags Configuration func (app *appContext) SetCustomEmailState(gc *gin.Context) { id := gc.Param("id") @@ -1574,13 +1625,14 @@ func (app *appContext) SetCustomEmailState(gc *gin.Context) { // @Failure 500 {object} boolResponse // @Param id path string true "ID of email" // @Router /config/emails/{id} [get] +// @Security Bearer // @tags Configuration func (app *appContext) GetCustomEmailTemplate(gc *gin.Context) { lang := app.storage.lang.chosenEmailLang id := gc.Param("id") var content string var err error - var msg *Email + var msg *Message var variables []string var conditionals []string var values map[string]interface{} @@ -1762,6 +1814,7 @@ func (app *appContext) GetCustomEmailTemplate(gc *gin.Context) { // @Produce json // @Success 200 {object} checkUpdateDTO // @Router /config/update [get] +// @Security Bearer // @tags Configuration func (app *appContext) CheckUpdate(gc *gin.Context) { if !app.newUpdate { @@ -1776,6 +1829,7 @@ func (app *appContext) CheckUpdate(gc *gin.Context) { // @Success 400 {object} stringResponse // @Success 500 {object} boolResponse // @Router /config/update [post] +// @Security Bearer // @tags Configuration func (app *appContext) ApplyUpdate(gc *gin.Context) { if !app.update.CanUpdate { @@ -1801,6 +1855,7 @@ func (app *appContext) ApplyUpdate(gc *gin.Context) { // @Success 200 {object} boolResponse // @Failure 500 {object} stringResponse // @Router /logout [post] +// @Security Bearer // @tags Other func (app *appContext) Logout(gc *gin.Context) { cookie, err := gc.Cookie("refresh") @@ -1874,8 +1929,162 @@ func (app *appContext) ServeLang(gc *gin.Context) { respondBool(400, false, gc) } +// @Summary Returns a new Telegram verification PIN, and the bot username. +// @Produce json +// @Success 200 {object} telegramPinDTO +// @Router /telegram/pin [get] +// @Security Bearer +// @tags Other +func (app *appContext) TelegramGetPin(gc *gin.Context) { + gc.JSON(200, telegramPinDTO{ + Token: app.telegram.NewAuthToken(), + Username: app.telegram.username, + }) +} + +// @Summary Link a Jellyfin & Telegram user together via a verification PIN. +// @Produce json +// @Param telegramSetDTO body telegramSetDTO true "Token and user's Jellyfin ID." +// @Success 200 {object} boolResponse +// @Failure 500 {object} boolResponse +// @Failure 400 {object} boolResponse +// @Router /users/telegram [post] +// @Security Bearer +// @tags Other +func (app *appContext) TelegramAddUser(gc *gin.Context) { + var req telegramSetDTO + gc.BindJSON(&req) + if req.Token == "" || req.ID == "" { + respondBool(400, false, gc) + return + } + tokenIndex := -1 + for i, v := range app.telegram.verifiedTokens { + if v.Token == req.Token { + tokenIndex = i + break + } + } + if tokenIndex == -1 { + respondBool(500, false, gc) + return + } + tgToken := app.telegram.verifiedTokens[tokenIndex] + tgUser := TelegramUser{ + ChatID: tgToken.ChatID, + Username: tgToken.Username, + Contact: true, + } + if lang, ok := app.telegram.languages[tgToken.ChatID]; ok { + tgUser.Lang = lang + } + if app.storage.telegram == nil { + app.storage.telegram = map[string]TelegramUser{} + } + app.storage.telegram[req.ID] = tgUser + err := app.storage.storeTelegramUsers() + if err != nil { + app.err.Printf("Failed to store Telegram users: %v", err) + } else { + app.telegram.verifiedTokens[len(app.telegram.verifiedTokens)-1], app.telegram.verifiedTokens[tokenIndex] = app.telegram.verifiedTokens[tokenIndex], app.telegram.verifiedTokens[len(app.telegram.verifiedTokens)-1] + app.telegram.verifiedTokens = app.telegram.verifiedTokens[:len(app.telegram.verifiedTokens)-1] + } + respondBool(200, true, gc) +} + +// @Summary Sets whether to notify a user through telegram or not. +// @Produce json +// @Param telegramNotifyDTO body telegramNotifyDTO true "User's Jellyfin ID and whether or not to notify then through Telegram." +// @Success 200 {object} boolResponse +// @Success 400 {object} boolResponse +// @Success 500 {object} boolResponse +// @Router /users/telegram/notify [post] +// @Security Bearer +// @tags Other +func (app *appContext) TelegramSetNotify(gc *gin.Context) { + var req telegramNotifyDTO + gc.BindJSON(&req) + if req.ID == "" { + respondBool(400, false, gc) + return + } + if tgUser, ok := app.storage.telegram[req.ID]; ok { + tgUser.Contact = req.Enabled + app.storage.telegram[req.ID] = tgUser + if err := app.storage.storeTelegramUsers(); err != nil { + respondBool(500, false, gc) + app.err.Printf("Telegram: Failed to store users: %v", err) + return + } + respondBool(200, true, gc) + msg := "" + if !req.Enabled { + msg = "not" + } + app.debug.Printf("Telegram: User \"%s\" will %s be notified through Telegram.", tgUser.Username, msg) + return + } + app.err.Printf("Telegram: User \"%s\" does not have a telegram account registered.", req.ID) + respondBool(400, false, gc) +} + +// @Summary Returns true/false on whether or not a telegram PIN was verified. Requires bearer auth. +// @Produce json +// @Success 200 {object} boolResponse +// @Param pin path string true "PIN code to check" +// @Router /telegram/verified/{pin} [get] +// @Security Bearer +// @tags Other +func (app *appContext) TelegramVerified(gc *gin.Context) { + pin := gc.Param("pin") + tokenIndex := -1 + for i, v := range app.telegram.verifiedTokens { + if v.Token == pin { + tokenIndex = i + break + } + } + // if tokenIndex != -1 { + // length := len(app.telegram.verifiedTokens) + // app.telegram.verifiedTokens[length-1], app.telegram.verifiedTokens[tokenIndex] = app.telegram.verifiedTokens[tokenIndex], app.telegram.verifiedTokens[length-1] + // app.telegram.verifiedTokens = app.telegram.verifiedTokens[:length-1] + // } + respondBool(200, tokenIndex != -1, gc) +} + +// @Summary Returns true/false on whether or not a telegram PIN was verified. Requires invite code. +// @Produce json +// @Success 200 {object} boolResponse +// @Success 401 {object} boolResponse +// @Param pin path string true "PIN code to check" +// @Param invCode path string true "invite Code" +// @Router /invite/{invCode}/telegram/verified/{pin} [get] +// @tags Other +func (app *appContext) TelegramVerifiedInvite(gc *gin.Context) { + code := gc.Param("invCode") + if _, ok := app.storage.invites[code]; !ok { + respondBool(401, false, gc) + return + } + pin := gc.Param("pin") + tokenIndex := -1 + for i, v := range app.telegram.verifiedTokens { + if v.Token == pin { + tokenIndex = i + break + } + } + // if tokenIndex != -1 { + // length := len(app.telegram.verifiedTokens) + // app.telegram.verifiedTokens[length-1], app.telegram.verifiedTokens[tokenIndex] = app.telegram.verifiedTokens[tokenIndex], app.telegram.verifiedTokens[length-1] + // app.telegram.verifiedTokens = app.telegram.verifiedTokens[:length-1] + // } + respondBool(200, tokenIndex != -1, gc) +} + // @Summary Restarts the program. No response means success. // @Router /restart [post] +// @Security Bearer // @tags Other func (app *appContext) restart(gc *gin.Context) { app.info.Println("Restarting...") diff --git a/config.go b/config.go index 7b3092e..9c60efc 100644 --- a/config.go +++ b/config.go @@ -12,6 +12,8 @@ import ( ) var emailEnabled = false +var messagesEnabled = false +var telegramEnabled = false func (app *appContext) GetPath(sect, key string) (fs.FS, string) { val := app.config.Section(sect).Key(key).MustString("") @@ -40,7 +42,7 @@ func (app *appContext) loadConfig() error { key.SetValue(key.MustString(filepath.Join(app.dataPath, (key.Name() + ".json")))) } } - for _, key := range []string{"user_configuration", "user_displayprefs", "user_profiles", "ombi_template", "invites", "emails", "user_template", "custom_emails", "users"} { + for _, key := range []string{"user_configuration", "user_displayprefs", "user_profiles", "ombi_template", "invites", "emails", "user_template", "custom_emails", "users", "telegram_users"} { app.config.Section("files").Key(key).SetValue(app.config.Section("files").Key(key).MustString(filepath.Join(app.dataPath, (key + ".json")))) } app.URLBase = strings.TrimSuffix(app.config.Section("ui").Key("url_base").MustString(""), "/") @@ -83,12 +85,19 @@ func (app *appContext) loadConfig() error { 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)) - - if app.config.Section("email").Key("method").MustString("") == "" { + messagesEnabled = app.config.Section("messages").Key("enabled").MustBool(false) + telegramEnabled = app.config.Section("telegram").Key("enabled").MustBool(false) + if !messagesEnabled { + emailEnabled = false + telegramEnabled = false + } else if app.config.Section("email").Key("method").MustString("") == "" { emailEnabled = false } else { emailEnabled = true } + if !emailEnabled && !telegramEnabled { + messagesEnabled = false + } app.MustSetValue("updates", "enabled", "true") releaseChannel := app.config.Section("updates").Key("channel").String() @@ -128,8 +137,34 @@ func (app *appContext) loadConfig() error { app.storage.lang.chosenAdminLang = app.config.Section("ui").Key("language-admin").MustString("en-us") app.storage.lang.chosenEmailLang = app.config.Section("email").Key("language").MustString("en-us") app.storage.lang.chosenPWRLang = app.config.Section("password_resets").Key("language").MustString("en-us") + app.storage.lang.chosenTelegramLang = app.config.Section("telegram").Key("language").MustString("en-us") app.email = NewEmailer(app) return nil } + +func (app *appContext) migrateEmailConfig() { + tempConfig, _ := ini.Load(app.configPath) + fmt.Println(warning("Part of your email configuration will be migrated to the new \"messages\" section.\nA backup will be made.")) + err := tempConfig.SaveTo(app.configPath + "_" + commit + ".bak") + if err != nil { + app.err.Fatalf("Failed to backup config: %v", err) + return + } + for _, setting := range []string{"use_24h", "date_format", "message"} { + if val := app.config.Section("email").Key(setting).Value(); val != "" { + tempConfig.Section("email").Key(setting).SetValue("") + tempConfig.Section("messages").Key(setting).SetValue(val) + } + } + if app.config.Section("messages").Key("enabled").MustBool(false) || app.config.Section("telegram").Key("enabled").MustBool(false) { + tempConfig.Section("messages").Key("enabled").SetValue("true") + } + err = tempConfig.SaveTo(app.configPath) + if err != nil { + app.err.Fatalf("Failed to save config: %v", err) + return + } + app.loadConfig() +} diff --git a/config/config-base.json b/config/config-base.json index 5cd3838..12f6861 100644 --- a/config/config-base.json +++ b/config/config-base.json @@ -345,33 +345,20 @@ } } }, - "email": { + "messages": { "order": [], "meta": { - "name": "Email", - "description": "General email settings." + "name": "Messages/Notifications", + "description": "General settings for emails/messages." }, "settings": { - "language": { - "name": "Email Language", - "required": false, - "requires_restart": false, - "depends_true": "method", - "type": "select", - "options": [ - ["en-us", "English (US)"] - ], - "value": "en-us", - "description": "Default email language. Submit a PR on github if you'd like to translate." - }, - "no_username": { - "name": "Use email addresses as username", - "required": false, - "requires_restart": false, - "depends_true": "method", + "enabled": { + "name": "Enabled", + "required": true, + "requires_restart": true, "type": "bool", - "value": false, - "description": "Use email address from invite form as username on Jellyfin." + "value": true, + "description": "Enable the sending of emails/messages such as password resets, announcements, etc." }, "use_24h": { "name": "Use 24h time", @@ -399,6 +386,37 @@ "type": "text", "value": "Need help? contact me.", "description": "Message displayed at bottom of emails." + } + } + }, + "email": { + "order": [], + "meta": { + "name": "Email", + "description": "General email settings.", + "depends_true": "messages|enabled" + }, + "settings": { + "language": { + "name": "Email Language", + "required": false, + "requires_restart": false, + "depends_true": "method", + "type": "select", + "options": [ + ["en-us", "English (US)"] + ], + "value": "en-us", + "description": "Default email language. Submit a PR on github if you'd like to translate." + }, + "no_username": { + "name": "Use email addresses as username", + "required": false, + "requires_restart": false, + "depends_true": "method", + "type": "bool", + "value": false, + "description": "Use email address from invite form as username on Jellyfin." }, "method": { "name": "Email method", @@ -443,12 +461,143 @@ } } }, + "mailgun": { + "order": [], + "meta": { + "name": "Mailgun (Email)", + "description": "Mailgun API connection settings", + "depends_true": "email|method" + }, + "settings": { + "api_url": { + "name": "API URL", + "required": false, + "requires_restart": false, + "type": "text", + "value": "https://api.mailgun.net..." + }, + "api_key": { + "name": "API Key", + "required": false, + "requires_restart": false, + "type": "text", + "value": "your api key" + } + } + }, + "smtp": { + "order": [], + "meta": { + "name": "SMTP (Email)", + "description": "SMTP Server connection settings.", + "depends_true": "email|method" + }, + "settings": { + "username": { + "name": "Username", + "required": false, + "requires_restart": false, + "type": "text", + "value": "", + "description": "Username for SMTP. Leave blank to user send from address as username." + }, + "encryption": { + "name": "Encryption Method", + "required": false, + "requires_restart": false, + "type": "select", + "options": [ + ["ssl_tls", "SSL/TLS"], + ["starttls", "STARTTLS"] + ], + "value": "starttls", + "description": "Your email provider should provide different ports for each encryption method. Generally 465 for ssl_tls, 587 for starttls." + }, + "server": { + "name": "Server address", + "required": false, + "requires_restart": false, + "type": "text", + "value": "smtp.jellyf.in", + "description": "SMTP Server address." + }, + "port": { + "name": "Port", + "required": false, + "requires_restart": false, + "type": "number", + "value": 465 + }, + "password": { + "name": "Password", + "required": false, + "requires_restart": false, + "type": "password", + "value": "smtp password" + }, + "ssl_cert": { + "name": "Path to custom SSL certificate", + "required": false, + "requires_restart": false, + "advanced": true, + "type": "text", + "value": "", + "description": "Use if your SMTP server's SSL Certificate is not trusted by the system." + } + } + }, + "telegram": { + "order": [], + "meta": { + "name": "Telegram", + "description": "Settings for Telegram signup/notifications" + }, + "settings": { + "enabled": { + "name": "Enabled", + "required": false, + "requires_restart": true, + "type": "bool", + "value": false, + "description": "Enable signup verification through Telegram and the sending of notifications through it.\nSee the jfa-go wiki for setting up a bot." + }, + "required": { + "name": "Require on sign-up", + "required": false, + "required_restart": true, + "type": "bool", + "value": false, + "description": "Require telegram connection on sign-up." + }, + "token": { + "name": "API Token", + "required": false, + "requires_restart": true, + "depends_true": "enabled", + "type": "text", + "value": "", + "description": "Telegram Bot API Token." + }, + "language": { + "name": "Language", + "required": false, + "requires_restart": false, + "depends_true": "enabled", + "type": "select", + "options": [ + ["en-us", "English (US)"] + ], + "value": "en-us", + "description": "Default telegram message language. Visit weblate if you'd like to translate." + } + } + }, "password_resets": { "order": [], "meta": { "name": "Password Resets", "description": "Settings for the password reset handler.", - "depends_true": "email|method" + "depends_true": "messages|enabled" }, "settings": { "enabled": { @@ -580,7 +729,7 @@ "meta": { "name": "Notifications", "description": "Notification related settings.", - "depends_true": "email|method" + "depends_true": "messages|enabled" }, "settings": { "enabled": { @@ -633,91 +782,6 @@ } } }, - "mailgun": { - "order": [], - "meta": { - "name": "Mailgun (Email)", - "description": "Mailgun API connection settings", - "depends_true": "email|method" - }, - "settings": { - "api_url": { - "name": "API URL", - "required": false, - "requires_restart": false, - "type": "text", - "value": "https://api.mailgun.net..." - }, - "api_key": { - "name": "API Key", - "required": false, - "requires_restart": false, - "type": "text", - "value": "your api key" - } - } - }, - "smtp": { - "order": [], - "meta": { - "name": "SMTP (Email)", - "description": "SMTP Server connection settings.", - "depends_true": "email|method" - }, - "settings": { - "username": { - "name": "Username", - "required": false, - "requires_restart": false, - "type": "text", - "value": "", - "description": "Username for SMTP. Leave blank to user send from address as username." - }, - "encryption": { - "name": "Encryption Method", - "required": false, - "requires_restart": false, - "type": "select", - "options": [ - ["ssl_tls", "SSL/TLS"], - ["starttls", "STARTTLS"] - ], - "value": "starttls", - "description": "Your email provider should provide different ports for each encryption method. Generally 465 for ssl_tls, 587 for starttls." - }, - "server": { - "name": "Server address", - "required": false, - "requires_restart": false, - "type": "text", - "value": "smtp.jellyf.in", - "description": "SMTP Server address." - }, - "port": { - "name": "Port", - "required": false, - "requires_restart": false, - "type": "number", - "value": 465 - }, - "password": { - "name": "Password", - "required": false, - "requires_restart": false, - "type": "password", - "value": "smtp password" - }, - "ssl_cert": { - "name": "Path to custom SSL certificate", - "required": false, - "requires_restart": false, - "advanced": true, - "type": "text", - "value": "", - "description": "Use if your SMTP server's SSL Certificate is not trusted by the system." - } - } - }, "ombi": { "order": [], "meta": { @@ -756,9 +820,9 @@ "welcome_email": { "order": [], "meta": { - "name": "Welcome Emails", - "description": "Optionally send a welcome email to new users with the Jellyfin URL and their username.", - "depends_true": "email|method" + "name": "Welcome Message", + "description": "Optionally send a welcome message to new users with the Jellyfin URL and their username.", + "depends_true": "messages|enabled" }, "settings": { "enabled": { @@ -865,14 +929,14 @@ "requires_restart": false, "type": "bool", "value": true, - "depends_true": "email|method", + "depends_true": "messages|enabled", "description": "Send an email when a user's account expires." }, "subject": { "name": "Email subject", "required": false, "requires_restart": false, - "depends_true": "email|method", + "depends_true": "messages|enabled", "type": "text", "value": "", "description": "Subject of user expiry emails." @@ -882,7 +946,7 @@ "required": false, "requires_restart": false, "advanced": true, - "depends_true": "email|method", + "depends_true": "messages|enabled", "type": "text", "value": "", "description": "Path to custom email html" @@ -892,7 +956,7 @@ "required": false, "requires_restart": false, "advanced": true, - "depends_true": "email|method", + "depends_true": "messages|enabled", "type": "text", "value": "", "description": "Path to custom email in plain text" @@ -904,7 +968,7 @@ "meta": { "name": "Account Disabling/Enabling", "description": "Subject/email files for account disabling/enabling emails.", - "depends_true": "email|method" + "depends_true": "messages|enabled" }, "settings": { "subject_disabled": { @@ -966,7 +1030,7 @@ "meta": { "name": "Account Deletion", "description": "Subject/email files for account deletion emails.", - "depends_true": "email|method" + "depends_true": "messages|enabled" }, "settings": { "subject": { @@ -1068,6 +1132,14 @@ "type": "text", "value": "", "description": "JSON file generated by program in settings, different from email_html/email_text. See wiki for more info." + }, + "telegram_users": { + "name": "Telegram users", + "required": false, + "requires_restart": false, + "type": "text", + "value": "", + "description": "Stores telegram user IDs and language preferences." } } } diff --git a/css/base.css b/css/base.css index e666869..4b1e040 100644 --- a/css/base.css +++ b/css/base.css @@ -39,6 +39,11 @@ } } +.chip.btn:hover:not([disabled]):not(.textarea), +.chip.btn:focus:not([disabled]):not(.textarea) { + filter: brightness(var(--button-filter-brightness,95%)); +} + .banner { margin: calc(-1 * var(--spacing-4,1rem)); } @@ -121,6 +126,10 @@ div.card:contains(section.banner.footer) { text-align: right; } +.ac { + text-align: center; +} + .inline-block { display: inline-block; } @@ -162,6 +171,12 @@ div.card:contains(section.banner.footer) { margin: 0.5rem; } +p.sm, +span.sm { + font-size: 0.75rem; +} + + .col.sm { margin: .25rem; } @@ -459,6 +474,11 @@ a:hover:not(.lang-link):not(.\~urge), a:active:not(.lang-link):not(.\~urge) { color: var(--color-urge-200); } +.link-center { + display: block; + text-align: center; +} + .search { max-width: 15rem; min-width: 10rem; diff --git a/email.go b/email.go index 694ff0e..5a7f6bd 100644 --- a/email.go +++ b/email.go @@ -9,6 +9,7 @@ import ( "fmt" "html/template" "io" + "io/fs" "net/smtp" "os" "strconv" @@ -25,13 +26,13 @@ import ( ) // implements email sending, right now via smtp or mailgun. -type emailClient interface { - send(fromName, fromAddr string, email *Email, address ...string) error +type EmailClient interface { + Send(fromName, fromAddr string, message *Message, address ...string) error } -type dummyClient struct{} +type DummyClient struct{} -func (dc *dummyClient) send(fromName, fromAddr string, email *Email, address ...string) error { +func (dc *DummyClient) Send(fromName, fromAddr string, email *Message, address ...string) error { fmt.Printf("FROM: %s <%s>\nTO: %s\nTEXT: %s\n", fromName, fromAddr, strings.Join(address, ", "), email.Text) return nil } @@ -41,7 +42,7 @@ type Mailgun struct { client *mailgun.MailgunImpl } -func (mg *Mailgun) send(fromName, fromAddr string, email *Email, address ...string) error { +func (mg *Mailgun) Send(fromName, fromAddr string, email *Message, address ...string) error { message := mg.client.NewMessage( fmt.Sprintf("%s <%s>", fromName, fromAddr), email.Subject, @@ -67,7 +68,7 @@ type SMTP struct { tlsConfig *tls.Config } -func (sm *SMTP) send(fromName, fromAddr string, email *Email, address ...string) error { +func (sm *SMTP) Send(fromName, fromAddr string, email *Message, address ...string) error { server := fmt.Sprintf("%s:%d", sm.server, sm.port) from := fmt.Sprintf("%s <%s>", fromName, fromAddr) var wg sync.WaitGroup @@ -93,18 +94,19 @@ func (sm *SMTP) send(fromName, fromAddr string, email *Email, address ...string) return err } -// Emailer contains the email sender, email content, and methods to construct message content. +// Emailer contains the email sender, translations, and methods to construct messages. type Emailer struct { fromAddr, fromName string lang emailLang - sender emailClient + sender EmailClient } -// Email stores content. -type Email struct { - Subject string `json:"subject"` - HTML string `json:"html"` - Text string `json:"text"` +// Message stores content. +type Message struct { + Subject string `json:"subject"` + HTML string `json:"html"` + Text string `json:"text"` + Markdown string `json:"markdown"` } func (emailer *Emailer) formatExpiry(expiry time.Time, tzaware bool, datePattern, timePattern string) (d, t, expiresIn string) { @@ -154,7 +156,7 @@ func NewEmailer(app *appContext) *Emailer { } else if method == "mailgun" { emailer.NewMailgun(app.config.Section("mailgun").Key("api_url").String(), app.config.Section("mailgun").Key("api_key").String()) } else if method == "dummy" { - emailer.sender = &dummyClient{} + emailer.sender = &DummyClient{} } return emailer } @@ -204,7 +206,7 @@ type templ interface { Execute(wr io.Writer, data interface{}) error } -func (emailer *Emailer) construct(app *appContext, section, keyFragment string, data map[string]interface{}) (html, text string, err error) { +func (emailer *Emailer) construct(app *appContext, section, keyFragment string, data map[string]interface{}) (html, text, markdown string, err error) { var tpl templ if substituteStrings == "" { data["jellyfin"] = "Jellyfin" @@ -212,14 +214,31 @@ func (emailer *Emailer) construct(app *appContext, section, keyFragment string, data["jellyfin"] = substituteStrings } var keys []string - if app.config.Section("email").Key("plaintext").MustBool(false) { - keys = []string{"text"} - text = "" + plaintext := app.config.Section("email").Key("plaintext").MustBool(false) + telegram := app.config.Section("telegram").Key("enabled").MustBool(false) + if plaintext { + if telegram { + keys = []string{"text"} + text, markdown = "", "" + } else { + keys = []string{"text"} + text = "" + } } else { - keys = []string{"html", "text"} + if telegram { + keys = []string{"html", "text", "markdown"} + } else { + keys = []string{"html", "text"} + } } for _, key := range keys { - filesystem, fpath := app.GetPath(section, keyFragment+key) + var filesystem fs.FS + var fpath string + if key == "markdown" { + filesystem, fpath = app.GetPath(section, keyFragment+"text") + } else { + filesystem, fpath = app.GetPath(section, keyFragment+key) + } if key == "html" { tpl, err = template.ParseFS(filesystem, fpath) } else { @@ -228,15 +247,28 @@ func (emailer *Emailer) construct(app *appContext, section, keyFragment string, if err != nil { return } + // For constructTemplate, if "md" is found in data it's used in stead of "text". + foundMarkdown := false + if key == "markdown" { + _, foundMarkdown = data["md"] + if foundMarkdown { + data["plaintext"], data["md"] = data["md"], data["plaintext"] + } + } var tplData bytes.Buffer err = tpl.Execute(&tplData, data) if err != nil { return } + if foundMarkdown { + data["plaintext"], data["md"] = data["md"], data["plaintext"] + } if key == "html" { html = tplData.String() - } else { + } else if key == "text" { text = tplData.String() + } else { + markdown = tplData.String() } } return @@ -257,7 +289,7 @@ func (emailer *Emailer) confirmationValues(code, username, key string, app *appC template[v] = "{" + v + "}" } } else { - message := app.config.Section("email").Key("message").String() + message := app.config.Section("messages").Key("message").String() inviteLink := app.config.Section("invite_emails").Key("url_base").String() inviteLink = fmt.Sprintf("%s/%s?key=%s", inviteLink, code, key) template["helloUser"] = emailer.lang.Strings.template("helloUser", tmpl{"username": username}) @@ -267,8 +299,8 @@ func (emailer *Emailer) confirmationValues(code, username, key string, app *appC return template } -func (emailer *Emailer) constructConfirmation(code, username, key string, app *appContext, noSub bool) (*Email, error) { - email := &Email{ +func (emailer *Emailer) constructConfirmation(code, username, key string, app *appContext, noSub bool) (*Message, error) { + email := &Message{ Subject: app.config.Section("email_confirmation").Key("subject").MustString(emailer.lang.EmailConfirmation.get("title")), } var err error @@ -282,7 +314,7 @@ func (emailer *Emailer) constructConfirmation(code, username, key string, app *a ) email, err = emailer.constructTemplate(email.Subject, content, app) } else { - email.HTML, email.Text, err = emailer.construct(app, "email_confirmation", "email_", template) + email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "email_confirmation", "email_", template) } if err != nil { return nil, err @@ -290,17 +322,18 @@ func (emailer *Emailer) constructConfirmation(code, username, key string, app *a return email, nil } -func (emailer *Emailer) constructTemplate(subject, md string, app *appContext) (*Email, error) { - email := &Email{Subject: subject} +func (emailer *Emailer) constructTemplate(subject, md string, app *appContext) (*Message, error) { + email := &Message{Subject: subject} renderer := html.NewRenderer(html.RendererOptions{Flags: html.Smartypants}) html := markdown.ToHTML([]byte(md), nil, renderer) text := stripMarkdown(md) - message := app.config.Section("email").Key("message").String() + message := app.config.Section("messages").Key("message").String() var err error - email.HTML, email.Text, err = emailer.construct(app, "template_email", "email_", map[string]interface{}{ + email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "template_email", "email_", map[string]interface{}{ "text": template.HTML(html), "plaintext": text, "message": message, + "md": md, }) if err != nil { return nil, err @@ -311,7 +344,7 @@ func (emailer *Emailer) constructTemplate(subject, md string, app *appContext) ( func (emailer *Emailer) inviteValues(code string, invite Invite, app *appContext, noSub bool) map[string]interface{} { expiry := invite.ValidTill d, t, expiresIn := emailer.formatExpiry(expiry, false, app.datePattern, app.timePattern) - message := app.config.Section("email").Key("message").String() + message := app.config.Section("messages").Key("message").String() inviteLink := app.config.Section("invite_emails").Key("url_base").String() inviteLink = fmt.Sprintf("%s/%s", inviteLink, code) template := map[string]interface{}{ @@ -338,8 +371,8 @@ func (emailer *Emailer) inviteValues(code string, invite Invite, app *appContext return template } -func (emailer *Emailer) constructInvite(code string, invite Invite, app *appContext, noSub bool) (*Email, error) { - email := &Email{ +func (emailer *Emailer) constructInvite(code string, invite Invite, app *appContext, noSub bool) (*Message, error) { + email := &Message{ Subject: app.config.Section("invite_emails").Key("subject").MustString(emailer.lang.InviteEmail.get("title")), } template := emailer.inviteValues(code, invite, app, noSub) @@ -353,7 +386,7 @@ func (emailer *Emailer) constructInvite(code string, invite Invite, app *appCont ) email, err = emailer.constructTemplate(email.Subject, content, app) } else { - email.HTML, email.Text, err = emailer.construct(app, "invite_emails", "email_", template) + email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "invite_emails", "email_", template) } if err != nil { return nil, err @@ -377,8 +410,8 @@ func (emailer *Emailer) expiryValues(code string, invite Invite, app *appContext return template } -func (emailer *Emailer) constructExpiry(code string, invite Invite, app *appContext, noSub bool) (*Email, error) { - email := &Email{ +func (emailer *Emailer) constructExpiry(code string, invite Invite, app *appContext, noSub bool) (*Message, error) { + email := &Message{ Subject: emailer.lang.InviteExpiry.get("title"), } var err error @@ -392,7 +425,7 @@ func (emailer *Emailer) constructExpiry(code string, invite Invite, app *appCont ) email, err = emailer.constructTemplate(email.Subject, content, app) } else { - email.HTML, email.Text, err = emailer.construct(app, "notifications", "expiry_", template) + email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "notifications", "expiry_", template) } if err != nil { return nil, err @@ -431,8 +464,8 @@ func (emailer *Emailer) createdValues(code, username, address string, invite Inv return template } -func (emailer *Emailer) constructCreated(code, username, address string, invite Invite, app *appContext, noSub bool) (*Email, error) { - email := &Email{ +func (emailer *Emailer) constructCreated(code, username, address string, invite Invite, app *appContext, noSub bool) (*Message, error) { + email := &Message{ Subject: emailer.lang.UserCreated.get("title"), } template := emailer.createdValues(code, username, address, invite, app, noSub) @@ -446,7 +479,7 @@ func (emailer *Emailer) constructCreated(code, username, address string, invite ) email, err = emailer.constructTemplate(email.Subject, content, app) } else { - email.HTML, email.Text, err = emailer.construct(app, "notifications", "created_", template) + email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "notifications", "created_", template) } if err != nil { return nil, err @@ -456,7 +489,7 @@ func (emailer *Emailer) constructCreated(code, username, address string, invite func (emailer *Emailer) resetValues(pwr PasswordReset, app *appContext, noSub bool) map[string]interface{} { d, t, expiresIn := emailer.formatExpiry(pwr.Expiry, true, app.datePattern, app.timePattern) - message := app.config.Section("email").Key("message").String() + message := app.config.Section("messages").Key("message").String() template := map[string]interface{}{ "someoneHasRequestedReset": emailer.lang.PasswordReset.get("someoneHasRequestedReset"), "ifItWasNotYou": emailer.lang.Strings.get("ifItWasNotYou"), @@ -505,8 +538,8 @@ func (emailer *Emailer) resetValues(pwr PasswordReset, app *appContext, noSub bo return template } -func (emailer *Emailer) constructReset(pwr PasswordReset, app *appContext, noSub bool) (*Email, error) { - email := &Email{ +func (emailer *Emailer) constructReset(pwr PasswordReset, app *appContext, noSub bool) (*Message, error) { + email := &Message{ Subject: app.config.Section("password_resets").Key("subject").MustString(emailer.lang.PasswordReset.get("title")), } template := emailer.resetValues(pwr, app, noSub) @@ -520,7 +553,7 @@ func (emailer *Emailer) constructReset(pwr PasswordReset, app *appContext, noSub ) email, err = emailer.constructTemplate(email.Subject, content, app) } else { - email.HTML, email.Text, err = emailer.construct(app, "password_resets", "email_", template) + email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "password_resets", "email_", template) } if err != nil { return nil, err @@ -541,13 +574,13 @@ func (emailer *Emailer) deletedValues(reason string, app *appContext, noSub bool } } else { template["reason"] = reason - template["message"] = app.config.Section("email").Key("message").String() + template["message"] = app.config.Section("messages").Key("message").String() } return template } -func (emailer *Emailer) constructDeleted(reason string, app *appContext, noSub bool) (*Email, error) { - email := &Email{ +func (emailer *Emailer) constructDeleted(reason string, app *appContext, noSub bool) (*Message, error) { + email := &Message{ Subject: app.config.Section("deletion").Key("subject").MustString(emailer.lang.UserDeleted.get("title")), } var err error @@ -561,7 +594,7 @@ func (emailer *Emailer) constructDeleted(reason string, app *appContext, noSub b ) email, err = emailer.constructTemplate(email.Subject, content, app) } else { - email.HTML, email.Text, err = emailer.construct(app, "deletion", "email_", template) + email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "deletion", "email_", template) } if err != nil { return nil, err @@ -582,13 +615,13 @@ func (emailer *Emailer) disabledValues(reason string, app *appContext, noSub boo } } else { template["reason"] = reason - template["message"] = app.config.Section("email").Key("message").String() + template["message"] = app.config.Section("messages").Key("message").String() } return template } -func (emailer *Emailer) constructDisabled(reason string, app *appContext, noSub bool) (*Email, error) { - email := &Email{ +func (emailer *Emailer) constructDisabled(reason string, app *appContext, noSub bool) (*Message, error) { + email := &Message{ Subject: app.config.Section("disable_enable").Key("subject_disabled").MustString(emailer.lang.UserDisabled.get("title")), } var err error @@ -602,7 +635,7 @@ func (emailer *Emailer) constructDisabled(reason string, app *appContext, noSub ) email, err = emailer.constructTemplate(email.Subject, content, app) } else { - email.HTML, email.Text, err = emailer.construct(app, "disable_enable", "disabled_", template) + email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "disable_enable", "disabled_", template) } if err != nil { return nil, err @@ -623,13 +656,13 @@ func (emailer *Emailer) enabledValues(reason string, app *appContext, noSub bool } } else { template["reason"] = reason - template["message"] = app.config.Section("email").Key("message").String() + template["message"] = app.config.Section("messages").Key("message").String() } return template } -func (emailer *Emailer) constructEnabled(reason string, app *appContext, noSub bool) (*Email, error) { - email := &Email{ +func (emailer *Emailer) constructEnabled(reason string, app *appContext, noSub bool) (*Message, error) { + email := &Message{ Subject: app.config.Section("disable_enable").Key("subject_enabled").MustString(emailer.lang.UserEnabled.get("title")), } var err error @@ -643,7 +676,7 @@ func (emailer *Emailer) constructEnabled(reason string, app *appContext, noSub b ) email, err = emailer.constructTemplate(email.Subject, content, app) } else { - email.HTML, email.Text, err = emailer.construct(app, "disable_enable", "enabled_", template) + email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "disable_enable", "enabled_", template) } if err != nil { return nil, err @@ -668,7 +701,7 @@ func (emailer *Emailer) welcomeValues(username string, expiry time.Time, app *ap } else { template["jellyfinURL"] = app.config.Section("jellyfin").Key("public_server").String() template["username"] = username - template["message"] = app.config.Section("email").Key("message").String() + template["message"] = app.config.Section("messages").Key("message").String() exp := app.formatDatetime(expiry) if !expiry.IsZero() { if custom { @@ -683,8 +716,8 @@ func (emailer *Emailer) welcomeValues(username string, expiry time.Time, app *ap return template } -func (emailer *Emailer) constructWelcome(username string, expiry time.Time, app *appContext, noSub bool) (*Email, error) { - email := &Email{ +func (emailer *Emailer) constructWelcome(username string, expiry time.Time, app *appContext, noSub bool) (*Message, error) { + email := &Message{ Subject: app.config.Section("welcome_email").Key("subject").MustString(emailer.lang.WelcomeEmail.get("title")), } var err error @@ -708,7 +741,7 @@ func (emailer *Emailer) constructWelcome(username string, expiry time.Time, app ) email, err = emailer.constructTemplate(email.Subject, content, app) } else { - email.HTML, email.Text, err = emailer.construct(app, "welcome_email", "email_", template) + email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "welcome_email", "email_", template) } if err != nil { return nil, err @@ -723,13 +756,13 @@ func (emailer *Emailer) userExpiredValues(app *appContext, noSub bool) map[strin "message": "", } if !noSub { - template["message"] = app.config.Section("email").Key("message").String() + template["message"] = app.config.Section("messages").Key("message").String() } return template } -func (emailer *Emailer) constructUserExpired(app *appContext, noSub bool) (*Email, error) { - email := &Email{ +func (emailer *Emailer) constructUserExpired(app *appContext, noSub bool) (*Message, error) { + email := &Message{ Subject: app.config.Section("user_expiry").Key("subject").MustString(emailer.lang.UserExpired.get("title")), } var err error @@ -743,7 +776,7 @@ func (emailer *Emailer) constructUserExpired(app *appContext, noSub bool) (*Emai ) email, err = emailer.constructTemplate(email.Subject, content, app) } else { - email.HTML, email.Text, err = emailer.construct(app, "user_expiry", "email_", template) + email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "user_expiry", "email_", template) } if err != nil { return nil, err @@ -752,6 +785,31 @@ func (emailer *Emailer) constructUserExpired(app *appContext, noSub bool) (*Emai } // calls the send method in the underlying emailClient. -func (emailer *Emailer) send(email *Email, address ...string) error { - return emailer.sender.send(emailer.fromName, emailer.fromAddr, email, address...) +func (emailer *Emailer) send(email *Message, address ...string) error { + return emailer.sender.Send(emailer.fromName, emailer.fromAddr, email, address...) +} + +func (app *appContext) sendByID(email *Message, ID ...string) error { + for _, id := range ID { + var err error + if tgChat, ok := app.storage.telegram[id]; ok && tgChat.Contact && telegramEnabled { + err = app.telegram.Send(email, tgChat.ChatID) + } else if address, ok := app.storage.emails[id]; ok { + err = app.email.send(email, address.(string)) + } + if err != nil { + return err + } + } + return nil +} + +func (app *appContext) getAddressOrName(jfID string) string { + if tgChat, ok := app.storage.telegram[jfID]; ok && tgChat.Contact && telegramEnabled { + return "@" + tgChat.Username + } + if addr, ok := app.storage.emails[jfID]; ok { + return addr.(string) + } + return "" } diff --git a/go.mod b/go.mod index 333a4ac..5be7638 100644 --- a/go.mod +++ b/go.mod @@ -17,29 +17,28 @@ require ( github.com/gin-contrib/pprof v1.3.0 github.com/gin-contrib/static v0.0.0-20200916080430-d45d9a37d28e github.com/gin-gonic/gin v1.6.3 - github.com/go-chi/chi v4.1.2+incompatible // indirect github.com/go-openapi/spec v0.20.3 // indirect github.com/go-openapi/swag v0.19.15 // indirect github.com/go-playground/validator/v10 v10.4.1 // indirect + github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible // indirect github.com/golang/protobuf v1.4.3 // indirect - github.com/gomarkdown/markdown v0.0.0-20210208175418-bda154fe17d8 + github.com/gomarkdown/markdown v0.0.0-20210408062403-ad838ccf8cdd 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 - github.com/hrfee/jfa-go/logger v0.0.0-00010101000000-000000000000 // indirect + github.com/hrfee/jfa-go/logger v0.0.0-00010101000000-000000000000 github.com/hrfee/jfa-go/ombi v0.0.0-20201112212552-b6f3cd7c1f71 github.com/hrfee/mediabrowser v0.3.3 github.com/itchyny/timefmt-go v0.1.2 - github.com/jordan-wright/email v4.0.1-0.20200917010138-e1c00e156980+incompatible + github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible github.com/lithammer/shortuuid/v3 v3.0.4 - github.com/mailgun/mailgun-go/v4 v4.3.0 + github.com/mailgun/mailgun-go/v4 v4.5.1 github.com/mailru/easyjson v0.7.7 // indirect - github.com/pkg/errors v0.9.1 // indirect github.com/smartystreets/goconvey v1.6.4 // indirect - github.com/spf13/pflag v1.0.5 // indirect github.com/swaggo/files v0.0.0-20190704085106-630677cd5c14 github.com/swaggo/gin-swagger v1.3.0 github.com/swaggo/swag v1.7.0 // indirect + github.com/technoweenie/multipartstreamer v1.0.1 // indirect github.com/ugorji/go v1.2.0 // indirect github.com/writeas/go-strip-markdown v2.0.1+incompatible golang.org/x/crypto v0.0.0-20201112155050-0c6587e931a9 // indirect diff --git a/go.sum b/go.sum index 6a79883..5b09672 100644 --- a/go.sum +++ b/go.sum @@ -58,9 +58,6 @@ github.com/gin-gonic/gin v1.5.0/go.mod h1:Nd6IXA8m5kNZdNEHMBd93KT+mdY3+bewLgRvmC github.com/gin-gonic/gin v1.6.2/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M= github.com/gin-gonic/gin v1.6.3 h1:ahKqKTFpO5KTPHxWZjEdPScmYaGtLo8Y4DMHoEsnp14= github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M= -github.com/go-chi/chi v4.0.0+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= -github.com/go-chi/chi v4.1.2+incompatible h1:fGFk2Gmi/YKXk0OmGfBh0WgmN3XB8lVnEyNz34tQRec= -github.com/go-chi/chi v4.1.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= github.com/go-openapi/jsonpointer v0.17.0/go.mod h1:cOnomiV+CVVwFLk0A/MExoFMjwdsUdVpsRhURCKh+3M= github.com/go-openapi/jsonpointer v0.19.2/go.mod h1:3akKfEdA7DF1sugOqz1dVQHBcuDBPKZGEoHC/NkiQRg= github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= @@ -96,6 +93,8 @@ github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+ github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI= github.com/go-playground/validator/v10 v10.4.1 h1:pH2c5ADXtd66mxoE0Zm9SUhxE20r7aM3F26W0hOn+GE= github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4= +github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible h1:2cauKuaELYAEARXRkq2LrJ0yDDv1rW7+wrTEdVL3uaU= +github.com/go-telegram-bot-api/telegram-bot-api v4.6.4+incompatible/go.mod h1:qf9acutJ8cwBUhm1bqgz6Bei9/C/c93FPDljKWwsOgM= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/mock v1.1.1 h1:G5FRp8JnTd7RQH5kemVNlMeyXQAztQ3mOWV95KxsXH8= @@ -112,8 +111,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/gomarkdown/markdown v0.0.0-20210408062403-ad838ccf8cdd h1:0b8AqsWQb6A0jjx80UXLG/uMTXQkGD0IGuXWqsrNz1M= +github.com/gomarkdown/markdown v0.0.0-20210408062403-ad838ccf8cdd/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= @@ -127,12 +126,14 @@ github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/hrfee/mediabrowser v0.3.3 h1:7E05uiol8hh2ytKn3WVLrUIvHAyifYEIy3Y5qtuNh8I= github.com/hrfee/mediabrowser v0.3.3/go.mod h1:PnHZbdxmbv1wCVdAQyM7nwPwpVj9fdKx2EcET7sAk+U= github.com/itchyny/timefmt-go v0.1.2 h1:q0Xa4P5it6K6D7ISsbLAMwx1PnWlixDcJL6/sFs93Hs= github.com/itchyny/timefmt-go v0.1.2/go.mod h1:0osSSCQSASBJMsIZnhAaF1C2fCBTJZXrnj37mG8/c+A= -github.com/jordan-wright/email v4.0.1-0.20200917010138-e1c00e156980+incompatible h1:CL0ooBNfbNyJTJATno+m0h+zM5bW6v7fKlboKUGP/dI= -github.com/jordan-wright/email v4.0.1-0.20200917010138-e1c00e156980+incompatible/go.mod h1:1c7szIrayyPPB/987hsnvNzLushdWf4o/79s3P08L8A= +github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible h1:jdpOPRN1zP63Td1hDQbZW73xKmzDvZHzVdNYxhnTMDA= +github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible/go.mod h1:1c7szIrayyPPB/987hsnvNzLushdWf4o/79s3P08L8A= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.5/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= @@ -156,8 +157,8 @@ github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y= github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= github.com/lithammer/shortuuid/v3 v3.0.4 h1:uj4xhotfY92Y1Oa6n6HUiFn87CdoEHYUlTy0+IgbLrs= github.com/lithammer/shortuuid/v3 v3.0.4/go.mod h1:RviRjexKqIzx/7r1peoAITm6m7gnif/h+0zmolKJjzw= -github.com/mailgun/mailgun-go/v4 v4.3.0 h1:9nAF7LI3k6bfDPbMZQMMl63Q8/vs+dr1FUN8eR1XMhk= -github.com/mailgun/mailgun-go/v4 v4.3.0/go.mod h1:fWuBI2iaS/pSSyo6+EBpHjatQO3lV8onwqcRy7joSJI= +github.com/mailgun/mailgun-go/v4 v4.5.1 h1:XrQQ/ZgqFvINRKy+eBqowLl7k3pQO6OCLpKphliMOFs= +github.com/mailgun/mailgun-go/v4 v4.5.1/go.mod h1:FJlF9rI5cQT+mrwujtJjPMbIVy3Ebor9bKTVsJ0QU40= github.com/mailru/easyjson v0.0.0-20180823135443-60711f1a8329/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= @@ -180,9 +181,8 @@ github.com/modern-go/reflect2 v1.0.1 h1:9f412s+6RmYXLWZSEzVVgPGK7C2PphHj5RJrvfx9 github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4 h1:gQz4mCbXsO+nc9n1hCxHcGA3Zx3Eo+UHZoInFGUIXNM= @@ -197,8 +197,6 @@ github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykE github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.2.0 h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48= github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= @@ -216,6 +214,8 @@ github.com/swaggo/swag v1.5.1/go.mod h1:1Bl9F/ZBpVWh22nY0zmYyASPO1lI/zIwRDrpZU+t github.com/swaggo/swag v1.6.7/go.mod h1:xDhTyuFIujYiN3DKWC/H/83xcfHp+UE/IzWWampG7Zc= github.com/swaggo/swag v1.7.0 h1:5bCA/MTLQoIqDXXyHfOpMeDvL9j68OY/udlK4pQoo4E= github.com/swaggo/swag v1.7.0/go.mod h1:BdPIL73gvS9NBsdi7M1JOxLvlbfvNRaBP8m6WT6Aajo= +github.com/technoweenie/multipartstreamer v1.0.1 h1:XRztA5MXiR1TIRHxH2uNxXxaIkKQDeX7m2XsSOlQEnM= +github.com/technoweenie/multipartstreamer v1.0.1/go.mod h1:jNVxdtShOxzAsukZwTSw6MDx5eUJoiEBsSvzDU9uzog= github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= github.com/ugorji/go v1.1.5-pre/go.mod h1:FwP/aQVg39TXzItUBMwnWp9T9gPQnXw4Poh4/oBQZ/0= github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= diff --git a/html/admin.html b/html/admin.html index fb5912d..a5b784f 100644 --- a/html/admin.html +++ b/html/admin.html @@ -6,6 +6,7 @@ window.URLBase = "{{ .urlBase }}"; window.notificationsEnabled = {{ .notifications }}; window.emailEnabled = {{ .email_enabled }}; + window.telegramEnabled = {{ .telegram_enabled }}; window.ombiEnabled = {{ .ombiEnabled }}; window.usernameEnabled = {{ .username }}; window.langFile = JSON.parse({{ .language }}); @@ -179,8 +180,8 @@