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.
This commit is contained in:
Harvey Tindall
2025-08-04 20:30:46 +01:00
parent 5fe0e0ab9f
commit 94efe9f746
15 changed files with 243 additions and 8 deletions

View File

@@ -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}, "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}, "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}, "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}, "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}, "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"]}, "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 { if noContent {
msg, err = app.email.constructExpiryAdjusted("", time.Time{}, "", app, true) 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": case "InviteEmail":
if noContent { if noContent {
msg, err = app.email.constructInvite("", Invite{}, app, true) msg, err = app.email.constructInvite("", Invite{}, app, true)

View File

@@ -552,6 +552,7 @@ func (app *appContext) ExtendExpiry(gc *gin.Context) {
}(id, expiry.Expiry) }(id, expiry.Expiry)
} }
} }
app.InvalidateWebUserCache()
respondBool(204, true, gc) respondBool(204, true, gc)
} }
@@ -563,6 +564,7 @@ func (app *appContext) ExtendExpiry(gc *gin.Context) {
// @tags Users // @tags Users
func (app *appContext) RemoveExpiry(gc *gin.Context) { func (app *appContext) RemoveExpiry(gc *gin.Context) {
app.storage.DeleteUserExpiryKey(gc.Param("id")) app.storage.DeleteUserExpiryKey(gc.Param("id"))
app.InvalidateWebUserCache()
respondBool(200, true, gc) respondBool(200, true, gc)
} }

View File

@@ -24,7 +24,7 @@ var telegramEnabled = false
var discordEnabled = false var discordEnabled = false
var matrixEnabled = 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 /. // IMPORTANT: When linking straight to a page, rather than appending further to the URL (like accessing an API route), append a /.
var PAGES = PagePaths{} 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_html", "jfa-go:"+"expiry-adjusted.html")
app.MustSetValue("user_expiry", "adjustment_email_text", "jfa-go:"+"expiry-adjusted.txt") 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("email", "collect", "true")
app.MustSetValue("matrix", "topic", "Jellyfin notifications") app.MustSetValue("matrix", "topic", "Jellyfin notifications")

View File

@@ -1474,6 +1474,10 @@ sections:
value: true value: true
depends_true: messages|enabled depends_true: messages|enabled
description: Send an email when a user's account expires. 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 - setting: subject
name: Email subject name: Email subject
depends_true: messages|enabled depends_true: messages|enabled
@@ -1509,6 +1513,23 @@ sections:
depends_true: messages|enabled depends_true: messages|enabled
type: text type: text
description: Path to custom email in plain 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 - section: disable_enable
meta: meta:
name: Account Disabling/Enabling name: Account Disabling/Enabling

View File

@@ -849,6 +849,79 @@ func (emailer *Emailer) constructExpiryAdjusted(username string, expiry time.Tim
return email, nil 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{} { func (emailer *Emailer) welcomeValues(username string, expiry time.Time, app *appContext, noSub bool, custom bool) map[string]interface{} {
template := map[string]interface{}{ template := map[string]interface{}{
"welcome": emailer.lang.WelcomeEmail.get("welcome"), "welcome": emailer.lang.WelcomeEmail.get("welcome"),

View File

@@ -108,6 +108,7 @@ type emailLang struct {
WelcomeEmail langSection `json:"welcomeEmail"` WelcomeEmail langSection `json:"welcomeEmail"`
EmailConfirmation langSection `json:"emailConfirmation"` EmailConfirmation langSection `json:"emailConfirmation"`
UserExpired langSection `json:"userExpired"` UserExpired langSection `json:"userExpired"`
ExpiryReminder langSection `json:"expiryReminder"`
} }
type setupLangs map[string]setupLang type setupLangs map[string]setupLang

View File

@@ -80,5 +80,10 @@
"title": "Your account has expired - Jellyfin", "title": "Your account has expired - Jellyfin",
"yourAccountHasExpired": "Your account has expired.", "yourAccountHasExpired": "Your account has expired.",
"contactTheAdmin": "Contact the administrator for more info." "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}."
} }
} }

View File

@@ -384,6 +384,10 @@ const (
FailedSendExpiryAdjustmentMessage = "Failed to send expiry adjustment message for \"%s\" to \"%s\": %v" FailedSendExpiryAdjustmentMessage = "Failed to send expiry adjustment message for \"%s\" to \"%s\": %v"
SentExpiryAdjustmentMessage = "Sent expiry adjustment message for \"%s\" to \"%s\"" 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" FailedConstructExpiryMessage = "Failed to construct expiry message for \"%s\": %v"
FailedSendExpiryMessage = "Failed to send expiry message for \"%s\" to \"%s\": %v" FailedSendExpiryMessage = "Failed to send expiry message for \"%s\" to \"%s\": %v"
SentExpiryMessage = "Sent expiry message for \"%s\" to \"%s\"" SentExpiryMessage = "Sent expiry message for \"%s\" to \"%s\""

77
mail/expiry-reminder.mjml Normal file
View File

@@ -0,0 +1,77 @@
<mjml>
<mj-head>
<mj-raw>
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
</mj-raw>
<mj-style>
: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;
}
}
</mj-style>
<mj-attributes>
<mj-class name="bg" background-color="#101010" />
<mj-class name="bg2" background-color="#242424" />
<mj-class name="text" color="#cacaca" />
<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">
<p>{{ .helloUser }}</p>
<p>{{ .yourAccountIsDueToExpire }}</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>

5
mail/expiry-reminder.txt Normal file
View File

@@ -0,0 +1,5 @@
{{ .helloUser }}
{{ .yourAccountIsDueToExpire }}
{{ .message }}

View File

@@ -387,6 +387,9 @@ func intialiseCustomContent(app *appContext) {
if _, ok := app.storage.GetCustomContentKey("UserExpiryAdjusted"); !ok { if _, ok := app.storage.GetCustomContentKey("UserExpiryAdjusted"); !ok {
app.storage.SetCustomContentKey("UserExpiryAdjusted", emptyCC) app.storage.SetCustomContentKey("UserExpiryAdjusted", emptyCC)
} }
if _, ok := app.storage.GetCustomContentKey("ExpiryReminder"); !ok {
app.storage.SetCustomContentKey("ExpiryReminder", emptyCC)
}
if _, ok := app.storage.GetCustomContentKey("PostSignupCard"); !ok { if _, ok := app.storage.GetCustomContentKey("PostSignupCard"); !ok {
app.storage.SetCustomContentKey("PostSignupCard", emptyCC) app.storage.SetCustomContentKey("PostSignupCard", emptyCC)

View File

@@ -66,7 +66,8 @@ type Activity struct {
type UserExpiry struct { type UserExpiry struct {
JellyfinID string `badgerhold:"key"` JellyfinID string `badgerhold:"key"`
Expiry time.Time 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 type DebugLogAction int
@@ -679,6 +680,7 @@ type customEmails struct {
WelcomeEmail CustomContent `json:"welcomeEmail"` WelcomeEmail CustomContent `json:"welcomeEmail"`
EmailConfirmation CustomContent `json:"emailConfirmation"` EmailConfirmation CustomContent `json:"emailConfirmation"`
UserExpired CustomContent `json:"userExpired"` UserExpired CustomContent `json:"userExpired"`
ExpiryReminder CustomContent `json:"expiryReminder"`
} }
// CustomContent stores customized versions of jfa-go content, including emails and user messages. // 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.WelcomeEmail, &fallback.WelcomeEmail, &english.WelcomeEmail)
patchLang(&lang.EmailConfirmation, &fallback.EmailConfirmation, &english.EmailConfirmation) patchLang(&lang.EmailConfirmation, &fallback.EmailConfirmation, &english.EmailConfirmation)
patchLang(&lang.UserExpired, &fallback.UserExpired, &english.UserExpired) patchLang(&lang.UserExpired, &fallback.UserExpired, &english.UserExpired)
patchLang(&lang.ExpiryReminder, &fallback.ExpiryReminder, &english.ExpiryReminder)
patchLang(&lang.Strings, &fallback.Strings, &english.Strings) 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.WelcomeEmail, &english.WelcomeEmail)
patchLang(&lang.EmailConfirmation, &english.EmailConfirmation) patchLang(&lang.EmailConfirmation, &english.EmailConfirmation)
patchLang(&lang.UserExpired, &english.UserExpired) patchLang(&lang.UserExpired, &english.UserExpired)
patchLang(&lang.ExpiryReminder, &english.ExpiryReminder)
patchLang(&lang.Strings, &english.Strings) patchLang(&lang.Strings, &english.Strings)
} }
} }

View File

@@ -26,7 +26,7 @@ type DayTimerSet struct {
clock Clock clock Clock
} }
func NewDayTimerSet(deltaStrings []string, unit time.Duration) DayTimerSet { func NewDayTimerSet(deltaStrings []string, unit time.Duration) *DayTimerSet {
as := DayTimerSet{ as := DayTimerSet{
deltas: make([]time.Duration, 0, len(deltaStrings)), deltas: make([]time.Duration, 0, len(deltaStrings)),
clock: realClock{}, 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. // 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.

View File

@@ -9,9 +9,14 @@ import (
) )
func newUserDaemon(interval time.Duration, app *appContext) *GenericDaemon { 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, d := NewGenericDaemon(interval, app,
func(app *appContext) { func(app *appContext) {
app.checkUsers() app.checkUsers(as)
}, },
) )
d.Name("User daemon") d.Name("User daemon")
@@ -23,7 +28,7 @@ const (
ExpiryModeDelete ExpiryModeDelete
) )
func (app *appContext) checkUsers() { func (app *appContext) checkUsers(remindBeforeExpiry *DayTimerSet) {
if len(app.storage.GetUserExpiries()) == 0 { if len(app.storage.GetUserExpiries()) == 0 {
return return
} }
@@ -63,7 +68,29 @@ func (app *appContext) checkUsers() {
app.storage.DeleteUserExpiryKey(expiry.JellyfinID) app.storage.DeleteUserExpiryKey(expiry.JellyfinID)
continue continue
} }
if !time.Now().After(expiry.Expiry) { 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 continue
} }
@@ -131,6 +158,10 @@ func (app *appContext) checkUsers() {
} else if deleteAfterPeriod > 0 && !alreadyExpired { } else if deleteAfterPeriod > 0 && !alreadyExpired {
// Otherwise, mark the expiry as done pending a delete after N days. // Otherwise, mark the expiry as done pending a delete after N days.
expiry.DeleteAfterPeriod = true 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) app.storage.SetUserExpiryKey(user.ID, expiry)
} }

View File

@@ -143,8 +143,8 @@ func (app *appContext) NewUserPostVerification(p NewUserParams) (out NewUserData
if len(webhookURIs) != 0 { if len(webhookURIs) != 0 {
summary := app.userSummary(out.User) summary := app.userSummary(out.User)
for _, uri := range webhookURIs { for _, uri := range webhookURIs {
pendingTasks.Add(1)
go func() { go func() {
pendingTasks.Add(1)
app.webhooks.Send(uri, summary) app.webhooks.Send(uri, summary)
pendingTasks.Done() pendingTasks.Done()
}() }()