mirror of
https://github.com/hrfee/jfa-go.git
synced 2026-01-18 16:47:42 +01:00
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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
73
email.go
73
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"),
|
||||
|
||||
1
lang.go
1
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
|
||||
|
||||
@@ -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}."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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\""
|
||||
|
||||
77
mail/expiry-reminder.mjml
Normal file
77
mail/expiry-reminder.mjml
Normal 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
5
mail/expiry-reminder.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
{{ .helloUser }}
|
||||
|
||||
{{ .yourAccountIsDueToExpire }}
|
||||
|
||||
{{ .message }}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@ type UserExpiry struct {
|
||||
JellyfinID string `badgerhold:"key"`
|
||||
Expiry time.Time
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
4
timer.go
4
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.
|
||||
|
||||
35
user-d.go
35
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)
|
||||
}
|
||||
|
||||
|
||||
2
users.go
2
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 {
|
||||
go func() {
|
||||
pendingTasks.Add(1)
|
||||
go func() {
|
||||
app.webhooks.Send(uri, summary)
|
||||
pendingTasks.Done()
|
||||
}()
|
||||
|
||||
Reference in New Issue
Block a user