Added per-invite notifications for expiry and user creation

Notifications must be enabled in settings; they can then be
toggled in the dropdown menu of each invite.
This commit is contained in:
Harvey Tindall 2020-07-17 16:08:36 +01:00
parent e80b233af2
commit b8fdb64f68
18 changed files with 494 additions and 12 deletions

View File

@ -290,13 +290,6 @@ def main():
success = True success = True
else: else:
def signal_handler(sig, frame):
print("Quitting...")
sys.exit(0)
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
global app global app
app = Flask(__name__, root_path=str(local_dir)) app = Flask(__name__, root_path=str(local_dir))
app.config["DEBUG"] = config.getboolean("ui", "debug") app.config["DEBUG"] = config.getboolean("ui", "debug")
@ -306,6 +299,13 @@ def main():
from waitress import serve from waitress import serve
if first_run: if first_run:
def signal_handler(sig, frame):
print("Quitting...")
sys.exit(0)
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
import jellyfin_accounts.setup import jellyfin_accounts.setup
host = config["ui"]["host"] host = config["ui"]["host"]
@ -315,6 +315,7 @@ def main():
else: else:
import jellyfin_accounts.web_api import jellyfin_accounts.web_api
import jellyfin_accounts.web import jellyfin_accounts.web
import jellyfin_accounts.invite_daemon
host = config["ui"]["host"] host = config["ui"]["host"]
port = config["ui"]["port"] port = config["ui"]["port"]
@ -330,4 +331,12 @@ def main():
log.info("Starting email thread") log.info("Starting email thread")
pwr.start() pwr.start()
def signal_handler(sig, frame):
print("Quitting...")
if config.getboolean("notifications", "enabled"):
jellyfin_accounts.invite_daemon.inviteDaemon.stop()
sys.exit(0)
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
serve(app, host=host, port=int(port)) serve(app, host=host, port=int(port))

View File

@ -16,6 +16,7 @@ class Config:
@staticmethod @staticmethod
def load_config(config_path, data_dir, local_dir, log): def load_config(config_path, data_dir, local_dir, log):
# Lord forgive me for this mess
config = configparser.RawConfigParser() config = configparser.RawConfigParser()
config.read(config_path) config.read(config_path)
for key in config["files"]: for key in config["files"]:
@ -64,6 +65,31 @@ class Config:
config["jellyfin"]["public_server"] = config["jellyfin"]["server"] config["jellyfin"]["public_server"] = config["jellyfin"]["server"]
if "bs5" not in config["ui"] or config["ui"]["bs5"] == "": if "bs5" not in config["ui"] or config["ui"]["bs5"] == "":
config["ui"]["bs5"] = "false" config["ui"]["bs5"] = "false"
if (
"expiry_html" not in config["notifications"]
or config["notifications"]["expiry_html"] == ""
):
log.debug("Using default expiry notification HTML template")
config["notifications"]["expiry_html"] = str(local_dir / "expired.html")
if (
"expiry_text" not in config["notifications"]
or config["notifications"]["expiry_text"] == ""
):
log.debug("Using default expiry notification plaintext template")
config["notifications"]["expiry_text"] = str(local_dir / "expired.txt")
if (
"created_html" not in config["notifications"]
or config["notifications"]["created_html"] == ""
):
log.debug("Using default user creation notification HTML template")
config["notifications"]["created_html"] = str(local_dir / "created.html")
if (
"created_text" not in config["notifications"]
or config["notifications"]["created_text"] == ""
):
log.debug("Using default user creation notification plaintext template")
config["notifications"]["created_text"] = str(local_dir / "created.txt")
return config return config
def __init__(self, file, instance, data_dir, local_dir, log): def __init__(self, file, instance, data_dir, local_dir, log):

View File

@ -133,6 +133,15 @@
"value": "your password", "value": "your password",
"description": "Password for admin page (Leave blank if using jellyfin_login)" "description": "Password for admin page (Leave blank if using jellyfin_login)"
}, },
"email": {
"name": "Admin email address",
"required": false,
"requires_restart": false,
"depends_false": "jellyfin_login",
"type": "text",
"value": "example@example.com",
"description": "Address to send notifications to (Leave blank if using jellyfin_login)"
},
"debug": { "debug": {
"name": "Debug logging", "name": "Debug logging",
"required": false, "required": false,
@ -391,6 +400,56 @@
"description": "Base URL for jf-accounts. This is necessary because using a reverse proxy means the program has no way of knowing the URL itself." "description": "Base URL for jf-accounts. This is necessary because using a reverse proxy means the program has no way of knowing the URL itself."
} }
}, },
"notifications": {
"meta": {
"name": "Notifications",
"description": "Notification related settings."
},
"enabled": {
"name": "Enabled",
"required": "false",
"requires_restart": true,
"type": "bool",
"value": true,
"description": "Enabling adds optional toggles to invites to notify on expiry and user creation."
},
"expiry_html": {
"name": "Expiry email (HTML)",
"required": false,
"requires_restart": false,
"depends_true": "enabled",
"type": "text",
"value": "",
"description": "Path to expiry notification email HTML."
},
"expiry_text": {
"name": "Expiry email (Plaintext)",
"required": false,
"requires_restart": "false",
"depends_true": "enabled",
"type": "text",
"value": "",
"description": "Path to expiry notification email in plaintext."
},
"created_html": {
"name": "User created email (HTML)",
"required": false,
"requires_restart": false,
"depends_true": "enabled",
"type": "text",
"value": "",
"description": "Path to user creation notification email HTML."
},
"created_text": {
"name": "User created email (Plaintext)",
"required": false,
"requires_restart": false,
"depends_true": "enabled",
"type": "text",
"value": "",
"description": "Path to user creation notification email in plaintext."
}
},
"mailgun": { "mailgun": {
"meta": { "meta": {
"name": "Mailgun (Email)", "name": "Mailgun (Email)",

View File

@ -0,0 +1,7 @@
A user was created using code {{ code }}.
Name: {{ username }}
Address: {{ address }}
Time: {{ time }}
Note: Notification emails can be toggled on the admin dashboard.

View File

@ -0,0 +1,10 @@
Hi {{ username }},
Someone has recently requests a password reset on Jellyfin.
If this was you, enter the below pin into the prompt.
This code will expire on {{ expiry_date }}, at {{ expiry_time }} , which is in {{ expires_in }}.
If this wasn't you, please ignore this email.
PIN: {{ pin }}
{{ message }}

View File

@ -0,0 +1,5 @@
Invite expired.
Code {{ code }} expired at {{ expiry }}.
Note: Notification emails can be toggled on the admin dashboard.

View File

@ -0,0 +1,8 @@
Hi,
You've been invited to Jellyfin.
To join, follow the below link.
This invite will expire on {{ expiry_date }}, at {{ expiry_time }}, which is in {{ expires_in }}, so act quick.
{{ invite_link }}
{{ message }}

View File

@ -158,7 +158,7 @@ var userDefaultsModal = createModal('userDefaults');
var usersModal = createModal('users'); var usersModal = createModal('users');
var restartModal = createModal('restartModal'); var restartModal = createModal('restartModal');
// Parsed invite: [<code>, <expires in _>, <1: Empty invite (no delete/link), 0: Actual invite>, <email address>, <remaining uses>, [<used-by>], <date created>] // Parsed invite: [<code>, <expires in _>, <1: Empty invite (no delete/link), 0: Actual invite>, <email address>, <remaining uses>, [<used-by>], <date created>, <notify on expiry>, <notify on creation>]
function parseInvite(invite, empty = false) { function parseInvite(invite, empty = false) {
if (empty) { if (empty) {
return ["None", "", 1]; return ["None", "", 1];
@ -185,6 +185,12 @@ function parseInvite(invite, empty = false) {
if ('created' in invite) { if ('created' in invite) {
i[6] = invite['created']; i[6] = invite['created'];
} }
if ('notify-expiry' in invite) {
i[7] = invite['notify-expiry'];
}
if ('notify-creation' in invite) {
i[8] = invite['notify-creation'];
}
return i; return i;
} }
@ -292,6 +298,78 @@ function addItem(parsedInvite) {
dropdownLeft.appendChild(leftList); dropdownLeft.appendChild(leftList);
dropdownContent.appendChild(dropdownLeft); dropdownContent.appendChild(dropdownLeft);
{% if notifications %}
let dropdownMiddle = document.createElement('div');
dropdownMiddle.id = parsedInvite[0] + '_notifyButtons';
dropdownMiddle.classList.add('col');
let middleList = document.createElement('ul');
middleList.classList.add('list-group', 'list-group-flush');
middleList.textContent = 'Notify on:';
let notifyExpiry = document.createElement('li');
notifyExpiry.classList.add('list-group-item', 'py-1', 'form-check');
notifyExpiry.innerHTML = `
<input class="form-check-input" type="checkbox" value="" id="${parsedInvite[0]}_notifyExpiry">
<label class="form-check-label" for="${parsedInvite[0]}_notifyExpiry">Expiry</label>
`;
if (typeof(parsedInvite[7]) == 'boolean') {
notifyExpiry.getElementsByTagName('input')[0].checked = parsedInvite[7];
}
notifyExpiry.getElementsByTagName('input')[0].onclick = function() {
let req = new XMLHttpRequest();
var thisEl = this;
let send = {};
let code = thisEl.id.replace('_notifyExpiry', '');
send[code] = {};
send[code]['notify-expiry'] = thisEl.checked;
req.open("POST", "/setNotify", true);
req.setRequestHeader("Authorization", "Basic " + btoa(window.token + ":"));
req.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
req.onreadystatechange = function() {
if (this.readyState == 4 && this.status != 200) {
thisEl.checked = !thisEl.checked;
}
};
req.send(JSON.stringify(send));
};
middleList.appendChild(notifyExpiry);
let notifyCreation = document.createElement('li');
notifyCreation.classList.add('list-group-item', 'py-1', 'form-check');
notifyCreation.innerHTML = `
<input class="form-check-input" type="checkbox" value="" id="${parsedInvite[0]}_notifyCreation">
<label class="form-check-label" for="${parsedInvite[0]}_notifyCreation">User creation</label>
`;
if (typeof(parsedInvite[8]) == 'boolean') {
notifyCreation.getElementsByTagName('input')[0].checked = parsedInvite[8];
}
notifyCreation.getElementsByTagName('input')[0].onclick = function() {
let req = new XMLHttpRequest();
var thisEl = this;
let send = {};
let code = thisEl.id.replace('_notifyCreation', '');
send[code] = {};
send[code]['notify-creation'] = thisEl.checked;
req.open("POST", "/setNotify", true);
req.setRequestHeader("Authorization", "Basic " + btoa(window.token + ":"));
req.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
req.onreadystatechange = function() {
if (this.readyState == 4 && this.status != 200) {
thisEl.checked = !thisEl.checked;
}
};
req.send(JSON.stringify(send));
};
middleList.appendChild(notifyCreation);
dropdownMiddle.appendChild(middleList);
dropdownContent.appendChild(dropdownMiddle);
{% endif %}
let dropdownRight = document.createElement('div'); let dropdownRight = document.createElement('div');
dropdownRight.id = parsedInvite[0] + '_usersCreated'; dropdownRight.id = parsedInvite[0] + '_usersCreated';
dropdownRight.classList.add('col'); dropdownRight.classList.add('col');

View File

@ -43,7 +43,10 @@ class JSONFile(dict):
def __delitem__(self, key): def __delitem__(self, key):
data = self.readJSON(self.path) data = self.readJSON(self.path)
super(JSONFile, self).__init__(data) super(JSONFile, self).__init__(data)
try:
del data[key] del data[key]
except KeyError:
pass
self.writeJSON(self.path, data) self.writeJSON(self.path, data)
super(JSONFile, self).__delitem__(key) super(JSONFile, self).__delitem__(key)

View File

@ -13,6 +13,15 @@ from jellyfin_accounts import config
from jellyfin_accounts import email_log as log from jellyfin_accounts import email_log as log
def format_datetime(dt):
result = dt.strftime(config["email"]["date_format"])
if config.getboolean("email", "use_24h"):
result += f' {dt.strftime("%H:%M")}'
else:
result += f' {dt.strftime("%I:%M %p")}'
return result
class Email: class Email:
def __init__(self, address): def __init__(self, address):
self.address = address self.address = address
@ -75,6 +84,47 @@ class Email:
self.content[key] = c self.content[key] = c
log.info(f"{self.address}: {key} constructed") log.info(f"{self.address}: {key} constructed")
def construct_expiry(self, invite):
self.subject = "Notice: Invite expired"
log.debug(f'Constructing expiry notification for {invite["code"]}')
expiry = format_datetime(invite["expiry"])
for key in ["text", "html"]:
sp = Path(config["notifications"]["expiry_" + key]) / ".."
sp = str(sp.resolve()) + "/"
template_loader = FileSystemLoader(searchpath=sp)
template_env = Environment(loader=template_loader)
fname = Path(config["notifications"]["expiry_" + key]).name
template = template_env.get_template(fname)
c = template.render(code=invite["code"], expiry=expiry)
self.content[key] = c
log.info(f"{self.address}: {key} constructed")
return True
def construct_created(self, invite):
self.subject = "Notice: User created"
log.debug(f'Constructing expiry notification for {invite["code"]}')
created = format_datetime(invite["created"])
if config.getboolean("email", "no_username"):
email = "n/a"
else:
email = invite["address"]
for key in ["text", "html"]:
sp = Path(config["notifications"]["created_" + key]) / ".."
sp = str(sp.resolve()) + "/"
template_loader = FileSystemLoader(searchpath=sp)
template_env = Environment(loader=template_loader)
fname = Path(config["notifications"]["created_" + key]).name
template = template_env.get_template(fname)
c = template.render(
code=invite["code"],
username=invite["username"],
address=email,
time=created,
)
self.content[key] = c
log.info(f"{self.address}: {key} constructed")
return True
def construct_reset(self, reset): def construct_reset(self, reset):
self.subject = config["password_resets"]["subject"] self.subject = config["password_resets"]["subject"]
log.debug(f"{self.address}: Using subject {self.subject}") log.debug(f"{self.address}: Using subject {self.subject}")

View File

@ -0,0 +1,42 @@
from threading import Timer
import time
from jellyfin_accounts import config, data_store
from jellyfin_accounts.web_api import checkInvite
class Repeat:
def __init__(self, interval, function, *args, **kwargs):
self._timer = None
self.interval = interval
self.function = function
self.args = args
self.kwargs = kwargs
self.is_running = False
self.next_call = time.time()
self.start()
def _run(self):
self.is_running = False
self.start()
self.function(*self.args, **self.kwargs)
def start(self):
if not self.is_running:
self.next_call += self.interval
self._timer = Timer(self.next_call - time.time(), self._run)
self._timer.start()
self.is_running = True
def stop(self):
self._timer.cancel()
self.is_running = False
def checkInvites():
invites = dict(data_store.invites)
# checkInvite already loops over everything, no point running it multiple times.
checkInvite(list(invites.keys())[0])
if config.getboolean("notifications", "enabled"):
inviteDaemon = Repeat(60, checkInvites)

View File

@ -106,6 +106,8 @@ def verify_password(username, password):
user = Account().verify_token(username, accounts) user = Account().verify_token(username, accounts)
if user: if user:
verified = True verified = True
if user in accounts:
user = accounts[user]
if not user: if not user:
log.debug(f"User {username} not found on Jellyfin") log.debug(f"User {username} not found on Jellyfin")
return False return False
@ -116,10 +118,10 @@ def verify_password(username, password):
if username == user.username and user.verify_password(password): if username == user.username and user.verify_password(password):
g.user = user g.user = user
log.debug("HTTPAuth Allowed") log.debug("HTTPAuth Allowed")
return True return user
else: else:
log.debug("HTTPAuth Denied") log.debug("HTTPAuth Denied")
return False return False
g.user = user g.user = user
log.debug("HTTPAuth Allowed") log.debug("HTTPAuth Allowed")
return True return user

View File

@ -43,7 +43,12 @@ def static_proxy(path):
if "html" not in path: if "html" not in path:
if "admin.js" in path: if "admin.js" in path:
return ( return (
render_template("admin.js", bsVersion=bsVersion(), css_file=css_file), render_template(
"admin.js",
bsVersion=bsVersion(),
css_file=css_file,
notifications=config.getboolean("notifications", "enabled"),
),
200, 200,
{"Content-Type": "text/javascript"}, {"Content-Type": "text/javascript"},
) )

View File

@ -15,6 +15,7 @@ from jellyfin_accounts import (
configparser, configparser,
config_base_path, config_base_path,
) )
from jellyfin_accounts.email import Mailgun, Smtp
from jellyfin_accounts import web_log as log from jellyfin_accounts import web_log as log
from jellyfin_accounts.validate_password import PasswordValidator from jellyfin_accounts.validate_password import PasswordValidator
@ -45,6 +46,22 @@ def checkInvite(code, used=False, username=None):
"no-limit" not in invites[invite] and invites[invite]["remaining-uses"] < 1 "no-limit" not in invites[invite] and invites[invite]["remaining-uses"] < 1
): ):
log.debug(f"Housekeeping: Deleting expired invite {invite}") log.debug(f"Housekeeping: Deleting expired invite {invite}")
if (
config.getboolean("notifications", "enabled")
and "notify" in invites[invite]
):
for address in invites[invite]["notify"]:
if "notify-expiry" in invites[invite]["notify"][address]:
if invites[invite]["notify"][address]["notify-expiry"]:
method = config["email"]["method"]
if method == "mailgun":
email = Mailgun(address)
elif method == "smtp":
email = Smtp(address)
if email.construct_expiry(
{"code": invite, "expiry": expiry}
):
email.send()
del data_store.invites[invite] del data_store.invites[invite]
elif invite == code: elif invite == code:
match = True match = True
@ -184,7 +201,28 @@ def newUser():
return jsonify({"error": error}) return jsonify({"error": error})
except: except:
return jsonify({"error": "Unknown error"}) return jsonify({"error": "Unknown error"})
invites = dict(data_store.invites)
checkInvite(data["code"], used=True, username=data["username"]) checkInvite(data["code"], used=True, username=data["username"])
if (
config.getboolean("notifications", "enabled")
and "notify" in invites[data["code"]]
):
for address in invites[data["code"]]["notify"]:
if "notify-creation" in invites[data["code"]]["notify"][address]:
if invites[data["code"]]["notify"][address]["notify-creation"]:
method = config["email"]["method"]
if method == "mailgun":
email = Mailgun(address)
elif method == "smtp":
email = Smtp(address)
if email.construct_created(
{
"code": data["code"],
"username": data["username"],
"created": datetime.datetime.now(),
}
):
email.send()
if user.status_code == 200: if user.status_code == 200:
try: try:
policy = data_store.user_template policy = data_store.user_template
@ -258,6 +296,11 @@ def generateInvite():
response = email.send() response = email.send()
if response is False or type(response) != bool: if response is False or type(response) != bool:
invite["email"] = f"Failed to send to {address}" invite["email"] = f"Failed to send to {address}"
if config.getboolean("notifications", "enabled"):
if "notify-creation" in data:
invite["notify-creation"] = data["notify-creation"]
if "notify-expiry" in data:
invite["notify-expiry"] = data["notify-expiry"]
data_store.invites[invite_code] = invite data_store.invites[invite_code] = invite
log.info(f"New invite created: {invite_code}") log.info(f"New invite created: {invite_code}")
return resp() return resp()
@ -296,6 +339,20 @@ def getInvites():
invite["remaining-uses"] = 1 invite["remaining-uses"] = 1
if "email" in invites[code]: if "email" in invites[code]:
invite["email"] = invites[code]["email"] invite["email"] = invites[code]["email"]
if "notify" in invites[code]:
if config.getboolean("ui", "jellyfin_login"):
address = data_store.emails[g.user.id]
else:
address = config["ui"]["email"]
if address in invites[code]["notify"]:
if "notify-expiry" in invites[code]["notify"][address]:
invite["notify-expiry"] = invites[code]["notify"][address][
"notify-expiry"
]
if "notify-creation" in invites[code]["notify"][address]:
invite["notify-creation"] = invites[code]["notify"][address][
"notify-creation"
]
response["invites"].append(invite) response["invites"].append(invite)
return jsonify(response) return jsonify(response)
@ -407,3 +464,29 @@ def getConfig():
if entry in config[section]: if entry in config[section]:
response_config[section][entry]["value"] = config[section][entry] response_config[section][entry]["value"] = config[section][entry]
return jsonify(response_config), 200 return jsonify(response_config), 200
@app.route("/setNotify", methods=["POST"])
@auth.login_required
def setNotify():
data = request.get_json()
change = False
for code in data:
for key in data[code]:
if key in ["notify-expiry", "notify-creation"]:
inv = data_store.invites[code]
if config.getboolean("ui", "jellyfin_login"):
address = data_store.emails[g.user.id]
else:
address = config["ui"]["email"]
if "notify" not in inv:
inv["notify"] = {}
if address not in inv["notify"]:
inv["notify"][address] = {}
inv["notify"][address][key] = data[code][key]
log.debug(f"{code}: Notification settings changed")
change = True
if change:
data_store.invites[code] = inv
return resp()
return resp(success=False)

47
mail/created.mjml Normal file
View File

@ -0,0 +1,47 @@
<mjml>
<mj-head>
<mj-attributes>
<mj-class name="bg" background-color="#101010" />
<mj-class name="bg2" background-color="#242424" />
<mj-class name="text" color="rgba(255,255,255,0.8)" />
<mj-class name="bold" color="rgba(255,255,255,0.87)" />
<mj-class name="secondary" color="rgb(153,153,153)" />
<mj-class name="blue" background-color="rgb(0,164,220)" />
</mj-attributes>
<mj-font name="Quicksand" href="https://fonts.googleapis.com/css2?family=Quicksand" />
<mj-font name="Noto Sans" href="https://fonts.googleapis.com/css2?family=Noto+Sans" />
</mj-head>
<mj-body>
<mj-section mj-class="bg2">
<mj-column>
<mj-text mj-class="bold" font-size="25px" font-family="Quicksand, Noto Sans, Helvetica, Arial, sans-serif"> jellyfin-accounts </mj-text>
</mj-column>
</mj-section>
<mj-section mj-class="bg">
<mj-column>
<mj-text mj-class="text" font-size="16px" font-family="Noto Sans, Helvetica, Arial, sans-serif">
<h3>User Created</h3>
<p>A user was created using code {{ code }}.</p>
</mj-text>
<mj-table mj-class="text" container-background-color="#242424">
<tr style="text-align: left;">
<th>Name</th>
<th>Address</th>
<th>Time</th>
</tr>
<tr style="font-style: italic; text-align: left; color: rgb(153,153,153);">
<th>{{ username }}</th>
<th>{{ address }}</th>
<th>{{ time }}</th>
</mj-table>
</mj-column>
</mj-section>
<mj-section mj-class="bg2">
<mj-column>
<mj-text mj-class="secondary" font-style="italic" font-size="14px">
Notification emails can be toggled on the admin dashboard.
</mj-text>
</mj-column>
</mj-section>
</body>
</mjml>

7
mail/created.txt Normal file
View File

@ -0,0 +1,7 @@
A user was created using code {{ code }}.
Name: {{ username }}
Address: {{ address }}
Time: {{ time }}
Note: Notification emails can be toggled on the admin dashboard.

36
mail/expired.mjml Normal file
View File

@ -0,0 +1,36 @@
<mjml>
<mj-head>
<mj-attributes>
<mj-class name="bg" background-color="#101010" />
<mj-class name="bg2" background-color="#242424" />
<mj-class name="text" color="rgba(255,255,255,0.8)" />
<mj-class name="bold" color="rgba(255,255,255,0.87)" />
<mj-class name="secondary" color="rgb(153,153,153)" />
<mj-class name="blue" background-color="rgb(0,164,220)" />
</mj-attributes>
<mj-font name="Quicksand" href="https://fonts.googleapis.com/css2?family=Quicksand" />
<mj-font name="Noto Sans" href="https://fonts.googleapis.com/css2?family=Noto+Sans" />
</mj-head>
<mj-body>
<mj-section mj-class="bg2">
<mj-column>
<mj-text mj-class="bold" font-size="25px" font-family="Quicksand, Noto Sans, Helvetica, Arial, sans-serif"> jellyfin-accounts </mj-text>
</mj-column>
</mj-section>
<mj-section mj-class="bg">
<mj-column>
<mj-text mj-class="text" font-size="16px" font-family="Noto Sans, Helvetica, Arial, sans-serif">
<h3>Invite Expired.</h3>
<p>Code {{ code }} expired at {{ expiry }}.</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">
Notification emails can be toggled on the admin dashboard.
</mj-text>
</mj-column>
</mj-section>
</body>
</mjml>

5
mail/expired.txt Normal file
View File

@ -0,0 +1,5 @@
Invite expired.
Code {{ code }} expired at {{ expiry }}.
Note: Notification emails can be toggled on the admin dashboard.