From 94efe9f746ad0eb6e2a26d8a63067d7d870bab03 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Mon, 4 Aug 2025 20:30:46 +0100 Subject: [PATCH] expiry: add "remind N days before" new setting to send an email/message N days before a user is due to expire. Multiple can be set. --- api-messages.go | 8 +++- api-users.go | 2 + config.go | 5 ++- config/config-base.yaml | 21 +++++++++++ email.go | 73 ++++++++++++++++++++++++++++++++++++ lang.go | 1 + lang/email/en-us.json | 5 +++ logmessages/logmessages.go | 4 ++ mail/expiry-reminder.mjml | 77 ++++++++++++++++++++++++++++++++++++++ mail/expiry-reminder.txt | 5 +++ migrations.go | 3 ++ storage.go | 6 ++- timer.go | 4 +- user-d.go | 35 ++++++++++++++++- users.go | 2 +- 15 files changed, 243 insertions(+), 8 deletions(-) create mode 100644 mail/expiry-reminder.mjml create mode 100644 mail/expiry-reminder.txt diff --git a/api-messages.go b/api-messages.go index e3297b5..b06cc0f 100644 --- a/api-messages.go +++ b/api-messages.go @@ -39,6 +39,7 @@ func (app *appContext) GetCustomContent(gc *gin.Context) { "WelcomeEmail": {Name: app.storage.lang.Email[lang].WelcomeEmail["name"], Enabled: app.storage.MustGetCustomContentKey("WelcomeEmail").Enabled}, "EmailConfirmation": {Name: app.storage.lang.Email[lang].EmailConfirmation["name"], Enabled: app.storage.MustGetCustomContentKey("EmailConfirmation").Enabled}, "UserExpired": {Name: app.storage.lang.Email[lang].UserExpired["name"], Enabled: app.storage.MustGetCustomContentKey("UserExpired").Enabled}, + "ExpiryReminder": {Name: app.storage.lang.Email[lang].ExpiryReminder["name"], Enabled: app.storage.MustGetCustomContentKey("ExpiryReminder").Enabled}, "UserLogin": {Name: app.storage.lang.Admin[adminLang].Strings["userPageLogin"], Enabled: app.storage.MustGetCustomContentKey("UserLogin").Enabled}, "UserPage": {Name: app.storage.lang.Admin[adminLang].Strings["userPagePage"], Enabled: app.storage.MustGetCustomContentKey("UserPage").Enabled}, "PostSignupCard": {Name: app.storage.lang.Admin[adminLang].Strings["postSignupCard"], Enabled: app.storage.MustGetCustomContentKey("PostSignupCard").Enabled, Description: app.storage.lang.Admin[adminLang].Strings["postSignupCardDescription"]}, @@ -196,7 +197,12 @@ func (app *appContext) GetCustomMessageTemplate(gc *gin.Context) { if noContent { msg, err = app.email.constructExpiryAdjusted("", time.Time{}, "", app, true) } - values = app.email.expiryAdjustedValues(username, time.Now(), app.storage.lang.Email[lang].Strings.get("reason"), app, false, true) + values = app.email.expiryAdjustedValues(username, time.Time{}, app.storage.lang.Email[lang].Strings.get("reason"), app, false, true) + case "ExpiryReminder": + if noContent { + msg, err = app.email.constructExpiryReminder("", time.Now().AddDate(0, 0, 3), app, true) + } + values = app.email.expiryReminderValues(username, time.Now().AddDate(0, 0, 3), app, false, true) case "InviteEmail": if noContent { msg, err = app.email.constructInvite("", Invite{}, app, true) diff --git a/api-users.go b/api-users.go index 26da7fc..c71f8f9 100644 --- a/api-users.go +++ b/api-users.go @@ -552,6 +552,7 @@ func (app *appContext) ExtendExpiry(gc *gin.Context) { }(id, expiry.Expiry) } } + app.InvalidateWebUserCache() respondBool(204, true, gc) } @@ -563,6 +564,7 @@ func (app *appContext) ExtendExpiry(gc *gin.Context) { // @tags Users func (app *appContext) RemoveExpiry(gc *gin.Context) { app.storage.DeleteUserExpiryKey(gc.Param("id")) + app.InvalidateWebUserCache() respondBool(200, true, gc) } diff --git a/config.go b/config.go index 444b23f..c997705 100644 --- a/config.go +++ b/config.go @@ -24,7 +24,7 @@ var telegramEnabled = false var discordEnabled = false var matrixEnabled = false -// URL subpaths. Ignore the "Current" field. +// URL subpaths. Ignore the "Current" field, it's populated when in copies of the struct used for page templating. // IMPORTANT: When linking straight to a page, rather than appending further to the URL (like accessing an API route), append a /. var PAGES = PagePaths{} @@ -240,6 +240,9 @@ func (app *appContext) loadConfig() error { app.MustSetValue("user_expiry", "adjustment_email_html", "jfa-go:"+"expiry-adjusted.html") app.MustSetValue("user_expiry", "adjustment_email_text", "jfa-go:"+"expiry-adjusted.txt") + app.MustSetValue("user_expiry", "reminder_email_html", "jfa-go:"+"expiry-reminder.html") + app.MustSetValue("user_expiry", "reminder_email_text", "jfa-go:"+"expiry-reminder.txt") + app.MustSetValue("email", "collect", "true") app.MustSetValue("matrix", "topic", "Jellyfin notifications") diff --git a/config/config-base.yaml b/config/config-base.yaml index 7587a24..aef4bb5 100644 --- a/config/config-base.yaml +++ b/config/config-base.yaml @@ -1474,6 +1474,10 @@ sections: value: true depends_true: messages|enabled description: Send an email when a user's account expires. + - setting: send_reminder_n_days_before + name: Send message N days before expiry + type: list + description: Send users a message N days before their account is due to expire. Multiple can be set. - setting: subject name: Email subject depends_true: messages|enabled @@ -1509,6 +1513,23 @@ sections: depends_true: messages|enabled type: text description: Path to custom email in plain text + - setting: reminder_subject + name: 'Reminder: email subject' + depends_true: messages|enabled + type: text + description: Subject of expiry reminder emails. + - setting: reminder_email_html + name: 'Reminder: Custom email (HTML)' + advanced: true + depends_true: messages|enabled + type: text + description: Path to custom email html + - setting: reminder_email_text + name: 'Reminder: Custom email (plaintext)' + advanced: true + depends_true: messages|enabled + type: text + description: Path to custom email in plain text - section: disable_enable meta: name: Account Disabling/Enabling diff --git a/email.go b/email.go index 0b244b2..9fda36e 100644 --- a/email.go +++ b/email.go @@ -849,6 +849,79 @@ func (emailer *Emailer) constructExpiryAdjusted(username string, expiry time.Tim return email, nil } +func (emailer *Emailer) expiryReminderValues(username string, expiry time.Time, app *appContext, noSub bool, custom bool) map[string]interface{} { + template := map[string]interface{}{ + "yourAccountIsDueToExpire": emailer.lang.ExpiryReminder.get("yourAccountIsDueToExpire"), + "expiresIn": "", + "date": "", + "time": "", + "message": "", + } + if noSub { + template["helloUser"] = emailer.lang.Strings.get("helloUser") + empty := []string{"date", "expiresIn"} + for _, v := range empty { + template[v] = "{" + v + "}" + } + } else { + template["message"] = app.config.Section("messages").Key("message").String() + template["helloUser"] = emailer.lang.Strings.template("helloUser", tmpl{"username": username}) + d, t, expiresIn := emailer.formatExpiry(expiry, false, app.datePattern, app.timePattern) + if !expiry.IsZero() { + if custom { + template["expiresIn"] = expiresIn + template["date"] = d + template["time"] = t + } else if !expiry.IsZero() { + template["yourAccountIsDueToExpire"] = emailer.lang.ExpiryReminder.template("yourAccountIsDueToExpire", tmpl{ + "expiresIn": expiresIn, + "date": d, + "time": t, + }) + } + } + } + return template +} + +func (emailer *Emailer) constructExpiryReminder(username string, expiry time.Time, app *appContext, noSub bool) (*Message, error) { + email := &Message{ + Subject: app.config.Section("user_expiry").Key("reminder_subject").MustString(emailer.lang.ExpiryReminder.get("title")), + } + var err error + var template map[string]interface{} + message := app.storage.MustGetCustomContentKey("ExpiryReminder") + if message.Enabled { + template = emailer.expiryReminderValues(username, expiry, app, noSub, true) + } else { + template = emailer.expiryReminderValues(username, expiry, app, noSub, false) + } + /*if noSub { + template["newExpiry"] = emailer.lang.UserExpiryAdjusted.template("newExpiry", tmpl{ + "date": "{newExpiry}", + }) + }*/ + if message.Enabled { + var content string + content, err = templateEmail( + message.Content, + message.Variables, + nil, + template, + ) + if err != nil { + app.err.Printf(lm.FailedConstructCustomContent, app.config.Section("user_expiry").Key("reminder_subject").MustString(emailer.lang.ExpiryReminder.get("title")), err) + } + email, err = emailer.constructTemplate(email.Subject, content, app) + } else { + email.HTML, email.Text, email.Markdown, err = emailer.construct(app, "user_expiry", "reminder_email_", template) + } + if err != nil { + return nil, err + } + return email, nil +} + func (emailer *Emailer) welcomeValues(username string, expiry time.Time, app *appContext, noSub bool, custom bool) map[string]interface{} { template := map[string]interface{}{ "welcome": emailer.lang.WelcomeEmail.get("welcome"), diff --git a/lang.go b/lang.go index abf20e0..a131296 100644 --- a/lang.go +++ b/lang.go @@ -108,6 +108,7 @@ type emailLang struct { WelcomeEmail langSection `json:"welcomeEmail"` EmailConfirmation langSection `json:"emailConfirmation"` UserExpired langSection `json:"userExpired"` + ExpiryReminder langSection `json:"expiryReminder"` } type setupLangs map[string]setupLang diff --git a/lang/email/en-us.json b/lang/email/en-us.json index bf832ef..8539778 100644 --- a/lang/email/en-us.json +++ b/lang/email/en-us.json @@ -80,5 +80,10 @@ "title": "Your account has expired - Jellyfin", "yourAccountHasExpired": "Your account has expired.", "contactTheAdmin": "Contact the administrator for more info." + }, + "expiryReminder": { + "name": "Expiry reminder", + "title": "Reminder: your account will expire soon - Jellyfin", + "yourAccountIsDueToExpire": "Your account is due to expire in {expiresIn}, or on {date} at {time}." } } diff --git a/logmessages/logmessages.go b/logmessages/logmessages.go index aa10579..3a25b16 100644 --- a/logmessages/logmessages.go +++ b/logmessages/logmessages.go @@ -384,6 +384,10 @@ const ( FailedSendExpiryAdjustmentMessage = "Failed to send expiry adjustment message for \"%s\" to \"%s\": %v" SentExpiryAdjustmentMessage = "Sent expiry adjustment message for \"%s\" to \"%s\"" + FailedConstructExpiryReminderMessage = "Failed to construct expiry reminder message for \"%s\": %v" + FailedSendExpiryReminderMessage = "Failed to send expiry reminder message for \"%s\" to \"%s\": %v" + SentExpiryReminderMessage = "Sent expiry reminder message for \"%s\" to \"%s\"" + FailedConstructExpiryMessage = "Failed to construct expiry message for \"%s\": %v" FailedSendExpiryMessage = "Failed to send expiry message for \"%s\" to \"%s\": %v" SentExpiryMessage = "Sent expiry message for \"%s\" to \"%s\"" diff --git a/mail/expiry-reminder.mjml b/mail/expiry-reminder.mjml new file mode 100644 index 0000000..3c69ecb --- /dev/null +++ b/mail/expiry-reminder.mjml @@ -0,0 +1,77 @@ + + + + + + + + :root { + Color-scheme: light dark; + supported-color-schemes: light dark; + } + @media (prefers-color-scheme: light) { + Color-scheme: dark; + .body { + background: #242424 !important; + background-color: #242424 !important; + } + [data-ogsc] .body { + background: #242424 !important; + background-color: #242424 !important; + } + [data-ogsb] .body { + background: #242424 !important; + background-color: #242424 !important; + } + } + @media (prefers-color-scheme: dark) { + Color-scheme: dark; + .body { + background: #242424 !important; + background-color: #242424 !important; + } + [data-ogsc] .body { + background: #242424 !important; + background-color: #242424 !important; + } + [data-ogsb] .body { + background: #242424 !important; + background-color: #242424 !important; + } + } + + + + + + + + + + + + + + + + {{ .jellyfin }} + + + + + +

{{ .helloUser }}

+ +

{{ .yourAccountIsDueToExpire }}

+
+
+
+ + + + {{ .message }} + + + + +
diff --git a/mail/expiry-reminder.txt b/mail/expiry-reminder.txt new file mode 100644 index 0000000..0bbae39 --- /dev/null +++ b/mail/expiry-reminder.txt @@ -0,0 +1,5 @@ +{{ .helloUser }} + +{{ .yourAccountIsDueToExpire }} + +{{ .message }} diff --git a/migrations.go b/migrations.go index a9f03aa..1286537 100644 --- a/migrations.go +++ b/migrations.go @@ -387,6 +387,9 @@ func intialiseCustomContent(app *appContext) { if _, ok := app.storage.GetCustomContentKey("UserExpiryAdjusted"); !ok { app.storage.SetCustomContentKey("UserExpiryAdjusted", emptyCC) } + if _, ok := app.storage.GetCustomContentKey("ExpiryReminder"); !ok { + app.storage.SetCustomContentKey("ExpiryReminder", emptyCC) + } if _, ok := app.storage.GetCustomContentKey("PostSignupCard"); !ok { app.storage.SetCustomContentKey("PostSignupCard", emptyCC) diff --git a/storage.go b/storage.go index 9a5126b..02eb6be 100644 --- a/storage.go +++ b/storage.go @@ -66,7 +66,8 @@ type Activity struct { type UserExpiry struct { JellyfinID string `badgerhold:"key"` Expiry time.Time - DeleteAfterPeriod bool // Whether or not to further disable the user later on + DeleteAfterPeriod bool // Whether or not to further disable the user later on + LastNotified time.Time // Last time an expiry notification/reminder was sent to the user. } type DebugLogAction int @@ -679,6 +680,7 @@ type customEmails struct { WelcomeEmail CustomContent `json:"welcomeEmail"` EmailConfirmation CustomContent `json:"emailConfirmation"` UserExpired CustomContent `json:"userExpired"` + ExpiryReminder CustomContent `json:"expiryReminder"` } // CustomContent stores customized versions of jfa-go content, including emails and user messages. @@ -1311,6 +1313,7 @@ func (st *Storage) loadLangEmail(filesystems ...fs.FS) error { patchLang(&lang.WelcomeEmail, &fallback.WelcomeEmail, &english.WelcomeEmail) patchLang(&lang.EmailConfirmation, &fallback.EmailConfirmation, &english.EmailConfirmation) patchLang(&lang.UserExpired, &fallback.UserExpired, &english.UserExpired) + patchLang(&lang.ExpiryReminder, &fallback.ExpiryReminder, &english.ExpiryReminder) patchLang(&lang.Strings, &fallback.Strings, &english.Strings) } } @@ -1326,6 +1329,7 @@ func (st *Storage) loadLangEmail(filesystems ...fs.FS) error { patchLang(&lang.WelcomeEmail, &english.WelcomeEmail) patchLang(&lang.EmailConfirmation, &english.EmailConfirmation) patchLang(&lang.UserExpired, &english.UserExpired) + patchLang(&lang.ExpiryReminder, &english.ExpiryReminder) patchLang(&lang.Strings, &english.Strings) } } diff --git a/timer.go b/timer.go index cbcddbd..e11fc8c 100644 --- a/timer.go +++ b/timer.go @@ -26,7 +26,7 @@ type DayTimerSet struct { clock Clock } -func NewDayTimerSet(deltaStrings []string, unit time.Duration) DayTimerSet { +func NewDayTimerSet(deltaStrings []string, unit time.Duration) *DayTimerSet { as := DayTimerSet{ deltas: make([]time.Duration, 0, len(deltaStrings)), clock: realClock{}, @@ -39,7 +39,7 @@ func NewDayTimerSet(deltaStrings []string, unit time.Duration) DayTimerSet { } } - return as + return &as } // Returns one or no time.Duration values, Giving the delta for the timer which went off. Pass a non-zero lastFired to stop too many going off at once, and store the returned time.Time value to pass as this later. diff --git a/user-d.go b/user-d.go index 9ed0a90..97a72d3 100644 --- a/user-d.go +++ b/user-d.go @@ -9,9 +9,14 @@ import ( ) func newUserDaemon(interval time.Duration, app *appContext) *GenericDaemon { + preExpiryCutoffDays := app.config.Section("user_expiry").Key("send_reminder_n_days_before").StringsWithShadows("|") + var as *DayTimerSet + if len(preExpiryCutoffDays) > 0 { + as = NewDayTimerSet(preExpiryCutoffDays, -24*time.Hour) + } d := NewGenericDaemon(interval, app, func(app *appContext) { - app.checkUsers() + app.checkUsers(as) }, ) d.Name("User daemon") @@ -23,7 +28,7 @@ const ( ExpiryModeDelete ) -func (app *appContext) checkUsers() { +func (app *appContext) checkUsers(remindBeforeExpiry *DayTimerSet) { if len(app.storage.GetUserExpiries()) == 0 { return } @@ -63,7 +68,29 @@ func (app *appContext) checkUsers() { app.storage.DeleteUserExpiryKey(expiry.JellyfinID) continue } + if !time.Now().After(expiry.Expiry) { + if shouldContact && remindBeforeExpiry != nil { + app.debug.Printf("Checking for expiry reminder timers") + duration := remindBeforeExpiry.Check(expiry.Expiry, expiry.LastNotified) + if duration != 0 { + expiry.LastNotified = time.Now() + app.storage.SetUserExpiryKey(user.ID, expiry) + name := app.getAddressOrName(user.ID) + // Skip blank contact info + if name == "" { + continue + } + msg, err := app.email.constructExpiryReminder(user.Name, expiry.Expiry, app, false) + if err != nil { + app.err.Printf(lm.FailedConstructExpiryReminderMessage, user.ID, err) + } else if err := app.sendByID(msg, user.ID); err != nil { + app.err.Printf(lm.FailedSendExpiryReminderMessage, user.ID, name, err) + } else { + app.info.Printf(lm.SentExpiryReminderMessage, user.ID, name) + } + } + } continue } @@ -131,6 +158,10 @@ func (app *appContext) checkUsers() { } else if deleteAfterPeriod > 0 && !alreadyExpired { // Otherwise, mark the expiry as done pending a delete after N days. expiry.DeleteAfterPeriod = true + // Sure, we haven't contacted them yet, but we're about to + if shouldContact { + expiry.LastNotified = time.Now() + } app.storage.SetUserExpiryKey(user.ID, expiry) } diff --git a/users.go b/users.go index 242c355..f981651 100644 --- a/users.go +++ b/users.go @@ -143,8 +143,8 @@ func (app *appContext) NewUserPostVerification(p NewUserParams) (out NewUserData if len(webhookURIs) != 0 { summary := app.userSummary(out.User) for _, uri := range webhookURIs { + pendingTasks.Add(1) go func() { - pendingTasks.Add(1) app.webhooks.Send(uri, summary) pendingTasks.Done() }()