added option to send invite to an address

The admin page now has the option to send an invite to an email address.
Since there are now two email types (invites and pw resets), the new
sections have been added to config.ini, and email_template and
email_plaintext have been renamed to email_html and email_text
respectively.
This commit is contained in:
Harvey Tindall 2020-04-19 22:35:51 +01:00
parent 561e87984c
commit e8ad3f98d6
19 changed files with 644 additions and 222 deletions

View File

@ -85,6 +85,7 @@ optional arguments:
For detailed descriptions of each setting, see [setup](https://github.com/hrfee/jellyfin-accounts/wiki/Setup). For detailed descriptions of each setting, see [setup](https://github.com/hrfee/jellyfin-accounts/wiki/Setup).
On first run, the default configuration is copied to `~/.jf-accounts/config.ini`. On first run, the default configuration is copied to `~/.jf-accounts/config.ini`.
``` ```
[jellyfin] [jellyfin]
; It is reccommended to create a limited admin account for this program. ; It is reccommended to create a limited admin account for this program.
@ -98,7 +99,7 @@ device = jf-accounts
device_id = jf-accounts-0.1 device_id = jf-accounts-0.1
[ui] [ui]
; Set to 0.0.0.0 to run localhost ; Set 0.0.0.0 to run localhost
host = 0.0.0.0 host = 0.0.0.0
port = 8056 port = 8056
username = your username username = your username
@ -127,28 +128,42 @@ number = 1
special = 0 special = 0
[email] [email]
; Enable to store provided email addresses, monitor jellyfin directory for pw-resets, and send pin ; Leave this whole section if you aren't using any email-related features.
enabled = true
; Directory to monitor for passwordReset*.json files. Usually the jellyfin config directory
watch_directory = /path/to/jellyfin
use_24h = true use_24h = true
; Date format follows datetime's strftime. ; Date format follows datetime's strftime.
date_format = %-d/%-m/%-y date_format = %-d/%-m/%-y
; Path to custom email html. If blank, uses the internal template.
email_template =
; Path to alternate plaintext email. If blank, uses the internal template.
email_plaintext =
; Displayed at bottom of emails ; Displayed at bottom of emails
message = Need help? contact me. message = Need help? contact me.
; Mail methods: mailgun, smtp ; Mail methods: mailgun, smtp
method = mailgun method = smtp
; Subject of emails
subject = Password Reset - Jellyfin
; Address to send from ; Address to send from
address = jellyfin@jellyf.in address = jellyfin@jellyf.in
; The name of the sender ; The name of the sender
from = Jellyfin from = Jellyfin
[password_resets]
; Enable to store provided email addresses, monitor jellyfin directory for pw-resets, and send pin
enabled = true
; Directory to monitor for passwordReset*.json files. Usually the jellyfin config directory
watch_directory = /path/to/jellyfin
; Path to custom email html. If blank, uses the internal template.
email_html =
; Path to alternate plaintext email. If blank, uses the internal template.
email_text =
; Subject of emails
subject = Password Reset - Jellyfin
[invite_emails]
; If enabled, allows one to send an invite directly to an email address.
enabled = true
; Path to custom email html. If blank, uses the internal template.
email_html =
; Path to alternate plaintext email. If blank, uses the internal template.
email_text =
subject = Invite - Jellyfin
; Base url for jf-accounts. This necessary because most will use a reverse proxy, so the program has no other way of knowing what URL to send.
url_base = http://accounts.jellyf.in:8056/invite
[mailgun] [mailgun]
api_url = https://api.mailgun.net... api_url = https://api.mailgun.net...
@ -167,7 +182,7 @@ password = smtp password
; Path to store valid invites. ; Path to store valid invites.
invites = invites =
; Path to store email addresses in JSON ; Path to store emails addresses in JSON
emails = emails =
; Path to the user policy template. Can be acquired with get-template. ; Path to the user policy template. Can be acquired with get-template.
user_template = user_template =

View File

@ -39,28 +39,42 @@ number = 1
special = 0 special = 0
[email] [email]
; Enable to store provided email addresses, monitor jellyfin directory for pw-resets, and send pin ; Leave this whole section if you aren't using any email-related features.
enabled = true
; Directory to monitor for passwordReset*.json files. Usually the jellyfin config directory
watch_directory = /path/to/jellyfin
use_24h = true use_24h = true
; Date format follows datetime's strftime. ; Date format follows datetime's strftime.
date_format = %-d/%-m/%-y date_format = %-d/%-m/%-y
; Path to custom email html. If blank, uses the internal template.
email_template =
; Path to alternate plaintext email. If blank, uses the internal template.
email_plaintext =
; Displayed at bottom of emails ; Displayed at bottom of emails
message = Need help? contact me. message = Need help? contact me.
; Mail methods: mailgun, smtp ; Mail methods: mailgun, smtp
method = smtp method = smtp
; Subject of emails
subject = Password Reset - Jellyfin
; Address to send from ; Address to send from
address = jellyfin@jellyf.in address = jellyfin@jellyf.in
; The name of the sender ; The name of the sender
from = Jellyfin from = Jellyfin
[password_resets]
; Enable to store provided email addresses, monitor jellyfin directory for pw-resets, and send pin
enabled = true
; Directory to monitor for passwordReset*.json files. Usually the jellyfin config directory
watch_directory = /path/to/jellyfin
; Path to custom email html. If blank, uses the internal template.
email_html =
; Path to alternate plaintext email. If blank, uses the internal template.
email_text =
; Subject of emails
subject = Password Reset - Jellyfin
[invite_emails]
; If enabled, allows one to send an invite directly to an email address.
enabled = true
; Path to custom email html. If blank, uses the internal template.
email_html =
; Path to alternate plaintext email. If blank, uses the internal template.
email_text =
subject = Invite - Jellyfin
; Base url for jf-accounts. This necessary because most will use a reverse proxy, so the program has no other way of knowing what URL to send.
url_base = http://accounts.jellyf.in:8056/invite
[mailgun] [mailgun]
api_url = https://api.mailgun.net... api_url = https://api.mailgun.net...

View File

@ -149,7 +149,7 @@
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%"> <table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tr> <tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;"> <td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Noto Sans;font-size:15px;line-height:1;text-align:left;color:#000000;"> <div style="font-family:Noto Sans, Helvetica, Arial, sans-serif;font-size:15px;line-height:1;text-align:left;color:#000000;">
<p>Hi {{ username }},</p> <p>Hi {{ username }},</p>
<p> Someone has recently requested a password reset on Jellyfin.</p> <p> Someone has recently requested a password reset on Jellyfin.</p>
<p>If this was you, enter the below pin into the prompt.</p> <p>If this was you, enter the below pin into the prompt.</p>

View File

@ -12,7 +12,7 @@
</mj-section> </mj-section>
<mj-section> <mj-section>
<mj-column> <mj-column>
<mj-text font-size="15px" font-family="Noto Sans"> <mj-text font-size="15px" font-family="Noto Sans, Helvetica, Arial, sans-serif">
<p>Hi {{ username }},</p> <p>Hi {{ username }},</p>
<p> Someone has recently requested a password reset on Jellyfin.</p> <p> Someone has recently requested a password reset on Jellyfin.</p>
<p>If this was you, enter the below pin into the prompt.</p> <p>If this was you, enter the below pin into the prompt.</p>

239
data/invite-email.html Normal file
View File

@ -0,0 +1,239 @@
<!-- FILE: invite-email.mjml -->
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<title>
</title>
<!--[if !mso]><!-- -->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style type="text/css">
#outlook a {
padding: 0;
}
body {
margin: 0;
padding: 0;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table,
td {
border-collapse: collapse;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
-ms-interpolation-mode: bicubic;
}
p {
display: block;
margin: 13px 0;
}
</style>
<!--[if mso]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
<![endif]-->
<!--[if lte mso 11]>
<style type="text/css">
.mj-outlook-group-fix { width:100% !important; }
</style>
<![endif]-->
<!--[if !mso]><!-->
<link href="https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700" rel="stylesheet" type="text/css">
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;700&display=swap" rel="stylesheet" type="text/css">
<style type="text/css">
@import url(https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700);
@import url(https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;700&display=swap);
</style>
<!--<![endif]-->
<style type="text/css">
@media only screen and (min-width:480px) {
.mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
}
</style>
<style type="text/css">
</style>
</head>
<body>
<div style="">
<!--[if mso | IE]>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="background:#f0f0f0;background-color:#f0f0f0;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#f0f0f0;background-color:#f0f0f0;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
<![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:20px;font-style:bold;line-height:1;text-align:left;color:#000000;">Jellyfin</div>
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
<![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Noto Sans, Helvetica, Arial, sans-serif;font-size:15px;line-height:1;text-align:left;color:#000000;">
<p>Hi,</p>
<h2>You've been invited to Jellyfin.</h2>
<p>To join, click the button below.</p>
<p>This invite will expire on {{ expiry_date }}, at {{ expiry_time }}, which is in {{ expires_in }}, so act quick.</p>
</div>
</td>
</tr>
<tr>
<td align="center" vertical-align="middle" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;">
<tr>
<td align="center" bgcolor="#414141" role="presentation" style="border:none;border-radius:3px;cursor:auto;mso-padding-alt:10px 25px;background:#414141;" valign="middle">
<a href="{{ invite_link }}" style="display:inline-block;background:#414141;color:#ffffff;font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:13px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:3px;" target="_blank"> Setup your account </a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<table
align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600"
>
<tr>
<td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;">
<![endif]-->
<div style="background:#f0f0f0;background-color:#f0f0f0;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#f0f0f0;background-color:#f0f0f0;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
<!--[if mso | IE]>
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
<td
class="" style="vertical-align:top;width:600px;"
>
<![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:10px;font-style:italic;line-height:1;text-align:left;color:#000000;">{{ message }}</div>
</td>
</tr>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]>
</td>
</tr>
</table>
<![endif]-->
</div>
</body>
</html>

33
data/invite-email.mjml Normal file
View File

@ -0,0 +1,33 @@
<mjml>
<mj-head>
<mj-font name="Noto Sans" href="https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;700&display=swap" />
</mj-head>
<mj-body>
<mj-section background-color="#f0f0f0">
<mj-column>
<mj-text font-style="bold" font-size="20px">
Jellyfin
</mj-text>
</mj-column>
</mj-section>
<mj-section>
<mj-column>
<mj-text font-size="15px" font-family="Noto Sans, Helvetica, Arial, sans-serif">
<p>Hi,</p>
<h2>You've been invited to Jellyfin.</h2>
<p>To join, click the button below.</p>
<p>This invite will expire on {{ expiry_date }}, at {{ expiry_time }}, which is in {{ expires_in }}, so act quick.</p>
</mj-text>
<mj-button href="{{ invite_link }}">Setup your account</mj-button>
</mj-column>
</mj-section>
<mj-section background-color="#f0f0f0">
<mj-column>
<mj-text font-style="italic" font-size="10px">
{{ message }}
</mj-text>
</mj-column>
</mj-section>
</body>
</mjml>

8
data/invite-email.txt Normal file
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

@ -2,7 +2,7 @@ function parseInvite(invite, empty = false) {
if (empty === true) { if (empty === true) {
return ["None", "", "1"] return ["None", "", "1"]
} else { } else {
var i = ["", "", "0"]; var i = ["", "", "0", invite['email']];
i[0] = invite['code']; i[0] = invite['code'];
if (invite['hours'] == 0) { if (invite['hours'] == 0) {
i[1] = invite['minutes'] + 'm'; i[1] = invite['minutes'] + 'm';
@ -40,6 +40,13 @@ function addItem(invite) {
codeCopy.onclick = function(){toClipboard(inviteCode)}; codeCopy.onclick = function(){toClipboard(inviteCode)};
codeCopy.classList.add('fa', 'fa-clipboard'); codeCopy.classList.add('fa', 'fa-clipboard');
listCode.appendChild(codeCopy); listCode.appendChild(codeCopy);
console.log(invite[3]);
if (typeof(invite[3]) != 'undefined') {
var sentTo = document.createElement('span');
sentTo.setAttribute('style', 'color: grey; margin-left: 2%; font-style: italic; font-size: 75%;');
sentTo.appendChild(document.createTextNode('Sent to ' + invite[3]));
listCode.appendChild(sentTo);
};
var listDelete = document.createElement('button'); var listDelete = document.createElement('button');
listDelete.onclick = function(){deleteInvite(invite[0])}; listDelete.onclick = function(){deleteInvite(invite[0])};
listDelete.classList.add('btn', 'btn-outline-danger'); listDelete.classList.add('btn', 'btn-outline-danger');
@ -153,7 +160,11 @@ function toClipboard(str) {
} }
}; };
$("form#inviteForm").submit(function() { $("form#inviteForm").submit(function() {
var send = $("form#inviteForm").serializeJSON(); var send_object = $("form#inviteForm").serializeObject();
if (document.getElementById('send_to_address_enabled').checked) {
send_object['email'] = document.getElementById('send_to_address').value;
}
var send = JSON.stringify(send_object);
$.ajax('/generateInvite', { $.ajax('/generateInvite', {
data : send, data : send,
contentType : 'application/json', contentType : 'application/json',

View File

@ -14,22 +14,30 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-serialize-object/2.5.0/jquery.serialize-object.min.js" integrity="sha256-E8KRdFk/LTaaCBoQIV/rFNc0s3ICQQiOHFT4Cioifa8=" crossorigin="anonymous"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-serialize-object/2.5.0/jquery.serialize-object.min.js" integrity="sha256-E8KRdFk/LTaaCBoQIV/rFNc0s3ICQQiOHFT4Cioifa8=" crossorigin="anonymous"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
<style> <style>
h1 { .pageContainer {
margin: 20%; margin: 20%;
}
@media (max-width: 1100px) {
.pageContainer {
margin: 2%;
}
}
h1 {
/*margin: 20%;*/
margin-bottom: 5%; margin-bottom: 5%;
} }
.linkGroup { .linkGroup {
margin: 20%; /*margin: 20%;*/
margin-bottom: 5%; margin-bottom: 5%;
margin-top: 5%; margin-top: 5%;
} }
.linkForm { .linkForm {
margin: 20%; /*margin: 20%;*/
margin-top: 5%; margin-top: 5%;
margin-bottom: 5%; margin-bottom: 5%;
} }
.contactBox { .contactBox {
margin: 20%; /*margin: 20%;*/
margin-top: 5%; margin-top: 5%;
color: grey; color: grey;
} }
@ -65,6 +73,7 @@
</div> </div>
</div> </div>
</div> </div>
<div class="pageContainer">
<h1> <h1>
Accounts admin Accounts admin
</h1> </h1>
@ -88,6 +97,17 @@
<select class="form-control" id="minutes" name="minutes"> <select class="form-control" id="minutes" name="minutes">
</select> </select>
</div> </div>
<div class="form-group">
<label for="send_to_address">Send invite to address</label>
<div class="input-group">
<div class="input-group-prepend">
<div class="input-group-text">
<input type="checkbox" onchange="document.getElementById('send_to_address').disabled = !this.checked;" aria-label="Checkbox to allow input of email address" id="send_to_address_enabled">
</div>
</div>
<input type="email" class="form-control" placeholder="example@example.com" id="send_to_address" disabled>
</div>
</div>
<input type="submit" class="btn btn-primary" value="Generate"> <input type="submit" class="btn btn-primary" value="Generate">
</form> </form>
</div> </div>
@ -96,6 +116,7 @@
<div class="contactBox"> <div class="contactBox">
<p>{{ contactMessage }}</p> <p>{{ contactMessage }}</p>
</div> </div>
</div>
<script src="admin.js"></script> <script src="admin.js"></script>
</body> </body>
</html> </html>

View File

@ -13,6 +13,11 @@
.pageContainer { .pageContainer {
margin: 20%; margin: 20%;
} }
@media (max-width: 1100px) {
.pageContainer {
margin: 2%;
}
}
.contactBox { .contactBox {
color: grey; color: grey;
} }
@ -54,7 +59,7 @@
<form action="#" method="POST"> <form action="#" method="POST">
<div class="form-group"> <div class="form-group">
<label for="inputEmail">Email</label> <label for="inputEmail">Email</label>
<input type="email" class="form-control" id="inputEmail" name="email" placeholder="Email" required> <input type="email" class="form-control" id="inputEmail" name="email" placeholder="Email" value="{{ email }}" required>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="inputUsername">Username</label> <label for="inputUsername">Username</label>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

166
jellyfin_accounts/email.py Normal file
View File

@ -0,0 +1,166 @@
import datetime
import pytz
import requests
import smtplib
import ssl
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from pathlib import Path
from dateutil import parser as date_parser
from jinja2 import Environment, FileSystemLoader
from __main__ import config
from __main__ import email_log as log
class Email():
def __init__(self, address):
self.address = address
log.debug(f'{self.address}: Creating email')
self.content = {}
self.from_address = config['email']['address']
self.from_name = config['email']['from']
log.debug((
f'{self.address}: Sending from {self.from_address} ' +
f'({self.from_name})'))
def pretty_time(self, expiry):
current_time = datetime.datetime.now()
date = expiry.strftime(config['email']['date_format'])
if config.getboolean('email', 'use_24h'):
log.debug(f'{self.address}: Using 24h time')
time = expiry.strftime('%H:%M')
else:
log.debug(f'{self.address}: Using 12h time')
time = expiry.strftime('%-I:%M %p')
expiry_delta = (expiry - current_time).seconds
expires_in = {'hours': expiry_delta//3600,
'minutes': (expiry_delta//60) % 60}
if expires_in['hours'] == 0:
expires_in = f'{str(expires_in["minutes"])}m'
else:
expires_in = (f'{str(expires_in["hours"])}h ' +
f'{str(expires_in["minutes"])}m')
log.debug(f'{self.address}: Expires in {expires_in}')
return {'date': date, 'time': time, 'expires_in': expires_in}
def construct_invite(self, invite):
self.subject = config['invite_emails']['subject']
log.debug(f'{self.address}: Using subject {self.subject}')
log.debug(f'{self.address}: Constructing email content')
expiry = invite['expiry']
expiry.replace(tzinfo=None)
pretty = self.pretty_time(expiry)
email_message = config['email']['message']
invite_link = config['invite_emails']['url_base']
invite_link += '/' + invite['code']
for key in ['text', 'html']:
sp = Path(config['invite_emails']['email_' + key]) / '..'
sp = str(sp.resolve()) + '/'
template_loader = FileSystemLoader(searchpath=sp)
template_env = Environment(loader=template_loader)
fname = Path(config['invite_emails']['email_' + key]).name
template = template_env.get_template(fname)
c = template.render(expiry_date=pretty['date'],
expiry_time=pretty['time'],
expires_in=pretty['expires_in'],
invite_link=invite_link,
message=email_message)
self.content[key] = c
log.info(f'{self.address}: {key} constructed')
def construct_reset(self, reset):
self.subject = config['password_resets']['subject']
log.debug(f'{self.address}: Using subject {self.subject}')
log.debug(f'{self.address}: Constructing email content')
try:
expiry = date_parser.parse(reset['ExpirationDate'])
expiry = expiry.replace(tzinfo=None)
except:
log.error(f"{self.address}: Couldn't parse expiry time")
return False
current_time = datetime.datetime.now()
if expiry >= current_time:
log.debug(f'{self.address}: Invite valid')
pretty = self.pretty_time(expiry)
email_message = config['email']['message']
for key in ['text', 'html']:
sp = Path(config['password_resets']['email_' + key]) / '..'
sp = str(sp.resolve()) + '/'
template_loader = FileSystemLoader(searchpath=sp)
template_env = Environment(loader=template_loader)
fname = Path(config['password_resets']['email_' + key]).name
template = template_env.get_template(fname)
c = template.render(username=reset['UserName'],
expiry_date=pretty['date'],
expiry_time=pretty['time'],
expires_in=pretty['expires_in'],
pin=reset['Pin'],
message=email_message)
self.content[key] = c
log.info(f'{self.address}: {key} constructed')
return True
else:
err = ((f"{self.address}: " +
"Reset has reportedly already expired. " +
"Ensure timezones are correctly configured."))
log.error(err)
return False
class Mailgun(Email):
def __init__(self, address):
super().__init__(address)
self.api_url = config['mailgun']['api_url']
self.api_key = config['mailgun']['api_key']
self.from_mg = f'{self.from_name} <{self.from_address}>'
def send(self):
response = requests.post(self.api_url,
auth=("api", self.api_key),
data={"from": self.from_mg,
"to": [self.address],
"subject": self.subject,
"text": self.content['text'],
"html": self.content['html']})
if response.ok:
log.info(f'{self.address}: Sent via mailgun.')
return True
log.debug(f'{self.address}: Mailgun: {response.status_code}')
return response
class Smtp(Email):
def __init__(self, address):
super().__init__(address)
self.server = config['smtp']['server']
self.password = config['smtp']['password']
try:
self.port = int(config['smtp']['port'])
except ValueError:
self.port = 465
log.debug(f'{self.address}: Defaulting to port {self.port}')
self.context = ssl.create_default_context()
def send(self):
message = MIMEMultipart("alternative")
message["Subject"] = self.subject
message["From"] = self.from_address
message["To"] = self.address
text = MIMEText(self.content['text'], 'plain')
html = MIMEText(self.content['html'], 'html')
message.attach(text)
message.attach(html)
try:
with smtplib.SMTP_SSL(self.server,
self.port,
context=self.context) as server:
server.login(self.from_address, self.password)
server.sendmail(self.from_address,
self.address,
message.as_string())
log.info(f'{self.address}: Sent via smtp')
return True
except Exception as e:
err = f'{self.address}: Failed to send via smtp: '
err += type(e).__name__
log.error(err)

View File

@ -1,149 +1,12 @@
import time import time
import json import json
import os
import datetime
import pytz
import requests
import smtplib
import ssl
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from pathlib import Path
from watchdog.observers import Observer from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler from watchdog.events import FileSystemEventHandler
from dateutil import parser as date_parser from jellyfin_accounts.email import Mailgun, Smtp
from jinja2 import Environment, FileSystemLoader, Template
from __main__ import config from __main__ import config
from __main__ import email_log as log from __main__ import email_log as log
class Email():
def __init__(self, address):
self.address = address
log.debug(f'{self.address}: Creating email')
self.content = {}
self.subject = config['email']['subject']
log.debug(f'{self.address}: Using subject {self.subject}')
self.from_address = config['email']['address']
self.from_name = config['email']['from']
log.debug((
f'{self.address}: Sending from {self.from_address} ' +
f'({self.from_name})'))
def construct(self, reset):
log.debug(f'{self.address}: Constructing email content')
try:
expiry = date_parser.parse(reset['ExpirationDate'])
expiry = expiry.replace(tzinfo=None)
except:
log.error(f"{self.address}: Couldn't parse expiry time")
return False
current_time = datetime.datetime.now()
if expiry >= current_time:
log.debug(f'{self.address}: Invite valid')
date = expiry.strftime(config['email']['date_format'])
if config.getboolean('email', 'use_24h'):
log.debug(f'{self.address}: Using 24h time')
time = expiry.strftime('%H:%M')
else:
log.debug(f'{self.address}: Using 12h time')
time = expiry.strftime('%-I:%M %p')
expiry_delta = (expiry - current_time).seconds
expires_in = {'hours': expiry_delta//3600,
'minutes': (expiry_delta//60) % 60}
if expires_in['hours'] == 0:
expires_in = f'{str(expires_in["minutes"])}m'
else:
expires_in = (f'{str(expires_in["hours"])}h ' +
f'{str(expires_in["minutes"])}m')
log.debug(f'{self.address}: Expires in {expires_in}')
sp = Path(config['email']['email_template']) / '..'
sp = str(sp.resolve()) + '/'
templateLoader = FileSystemLoader(searchpath=sp)
templateEnv = Environment(loader=templateLoader)
file_text = Path(config['email']['email_plaintext']).name
file_html = Path(config['email']['email_template']).name
template = {}
template['text'] = templateEnv.get_template(file_text)
template['html'] = templateEnv.get_template(file_html)
email_message = config['email']['message']
for key in template:
c = template[key].render(username=reset['UserName'],
expiry_date=date,
expiry_time=time,
expires_in=expires_in,
pin=reset['Pin'],
message=email_message)
self.content[key] = c
log.info(f'{self.address}: {key} constructed')
return True
else:
err = ((f"{self.address}: " +
"Reset has reportedly already expired. " +
"Ensure timezones are correctly configured."))
log.error(err)
return False
class Mailgun(Email):
def __init__(self, address):
super().__init__(address)
self.api_url = config['mailgun']['api_url']
self.api_key = config['mailgun']['api_key']
self.from_mg = f'{self.from_name} <{self.from_address}>'
def send(self):
response = requests.post(self.api_url,
auth=("api", self.api_key),
data={"from": self.from_mg,
"to": [self.address],
"subject": self.subject,
"text": self.content['text'],
"html": self.content['html']})
if response.ok:
log.info(f'{self.address}: Sent via mailgun.')
return True
log.debug(f'{self.address}: Mailgun: {response.status_code}')
return response
class Smtp(Email):
def __init__(self, address):
super().__init__(address)
self.server = config['smtp']['server']
self.password = config['smtp']['password']
try:
self.port = int(config['smtp']['port'])
except ValueError:
self.port = 465
log.debug(f'{self.address}: Defaulting to port {self.port}')
self.context = ssl.create_default_context()
def send(self):
message = MIMEMultipart("alternative")
message["Subject"] = self.subject
message["From"] = self.from_address
message["To"] = self.address
text = MIMEText(self.content['text'], 'plain')
html = MIMEText(self.content['html'], 'html')
message.attach(text)
message.attach(html)
try:
with smtplib.SMTP_SSL(self.server,
self.port,
context=self.context) as server:
server.login(self.from_address, self.password)
server.sendmail(self.from_address,
self.address,
message.as_string())
log.info(f'{self.address}: Sent via smtp')
return True
except Exception as e:
err = f'{self.address}: Failed to send via smtp: '
err += type(e).__name__
log.error(err)
class Watcher: class Watcher:
def __init__(self, dir): def __init__(self, dir):
self.observer = Observer() self.observer = Observer()
@ -152,7 +15,10 @@ class Watcher:
def run(self): def run(self):
event_handler = Handler() event_handler = Handler()
self.observer.schedule(event_handler, self.dir, recursive=True) self.observer.schedule(event_handler, self.dir, recursive=True)
try:
self.observer.start() self.observer.start()
except NotADirectoryError:
log.error(f'Directory {self.dir} does not exist')
try: try:
while True: while True:
time.sleep(5) time.sleep(5)
@ -181,7 +47,7 @@ class Handler(FileSystemEventHandler):
email = Mailgun(address) email = Mailgun(address)
elif method == 'smtp': elif method == 'smtp':
email = Smtp(address) email = Smtp(address)
if email.construct(reset): if email.construct_reset(reset):
email.send() email.send()
except (FileNotFoundError, except (FileNotFoundError,
json.decoder.JSONDecodeError, json.decoder.JSONDecodeError,
@ -190,6 +56,6 @@ class Handler(FileSystemEventHandler):
log.error(err) log.error(err)
def start(): def start():
log.info(f'Monitoring {config["email"]["watch_directory"]}') log.info(f'Monitoring {config["password_resets"]["watch_directory"]}')
w = Watcher(config['email']['watch_directory']) w = Watcher(config['password_resets']['watch_directory'])
w.run() w.run()

View File

@ -1,3 +1,4 @@
import json
from pathlib import Path from pathlib import Path
from flask import Flask, send_from_directory, render_template from flask import Flask, send_from_directory, render_template
from __main__ import config, app, g from __main__ import config, app, g
@ -31,11 +32,23 @@ from jellyfin_accounts.web_api import checkInvite
def inviteProxy(path): def inviteProxy(path):
if checkInvite(path): if checkInvite(path):
log.info(f'Invite {path} used to request form') log.info(f'Invite {path} used to request form')
try:
with open(config['files']['invites'], 'r') as f:
invites = json.load(f)
except (FileNotFoundError, json.decoder.JSONDecodeError):
invites = {'invites': []}
for invite in invites['invites']:
if invite['code'] == path:
try:
email = invite['email']
except KeyError:
email = ""
return render_template('form.html', return render_template('form.html',
contactMessage=config['ui']['contact_message'], contactMessage=config['ui']['contact_message'],
helpMessage=config['ui']['help_message'], helpMessage=config['ui']['help_message'],
successMessage=config['ui']['success_message'], successMessage=config['ui']['success_message'],
jfLink=config['jellyfin']['server']) jfLink=config['jellyfin']['server'],
email=email)
elif 'admin.html' not in path and 'admin.html' not in path: elif 'admin.html' not in path and 'admin.html' not in path:
return app.send_static_file(path) return app.send_static_file(path)
else: else:

View File

@ -144,8 +144,22 @@ def generateInvite():
minutes=int(data['minutes'])) minutes=int(data['minutes']))
invite = {'code': secrets.token_urlsafe(16)} invite = {'code': secrets.token_urlsafe(16)}
log.debug(f'Creating new invite: {invite["code"]}') log.debug(f'Creating new invite: {invite["code"]}')
invite['valid_till'] = (current_time + valid_till = current_time + delta
delta).strftime('%Y-%m-%dT%H:%M:%S.%f') invite['valid_till'] = valid_till.strftime('%Y-%m-%dT%H:%M:%S.%f')
if 'email' in data and config.getboolean('invite_emails', 'enabled'):
address = data['email']
invite['email'] = address
log.info(f'Sending invite to {address}')
method = config['email']['method']
if method == 'mailgun':
from jellyfin_accounts.email import Mailgun
email = Mailgun(address)
elif method == 'smtp':
from jellyfin_accounts.email import Smtp
email = Smtp(address)
email.construct_invite({'expiry': valid_till,
'code': invite['code']})
email.send()
try: try:
with open(config['files']['invites'], 'r') as f: with open(config['files']['invites'], 'r') as f:
invites = json.load(f) invites = json.load(f)
@ -178,10 +192,12 @@ def getInvites():
del invites['invites'][index] del invites['invites'][index]
else: else:
valid_for = expiry - current_time valid_for = expiry - current_time
response['invites'].append({ invite = {'code': i['code'],
'code': i['code'],
'hours': valid_for.seconds//3600, 'hours': valid_for.seconds//3600,
'minutes': (valid_for.seconds//60) % 60}) 'minutes': (valid_for.seconds//60) % 60}
if 'email' in i:
invite['email'] = i['email']
response['invites'].append(invite)
with open(config['files']['invites'], 'w') as f: with open(config['files']['invites'], 'w') as f:
f.write(json.dumps(invites, indent=4, default=str)) f.write(json.dumps(invites, indent=4, default=str))
return jsonify(response) return jsonify(response)
@ -235,5 +251,3 @@ def modifyConfig():
temp_config.write(config_file) temp_config.write(config_file)
log.debug('Config written') log.debug('Config written')
return jsonify(data) return jsonify(data)

View File

@ -87,12 +87,26 @@ for key in config['files']:
log.debug(f'Using default {key}') log.debug(f'Using default {key}')
config['files'][key] = str(data_dir / (key + '.json')) config['files'][key] = str(data_dir / (key + '.json'))
if config['email']['email_template'] == '': if ('email_html' not in config['password_resets'] or
log.debug('Using default email HTML template') config['password_resets']['email_html'] == ''):
config['email']['email_template'] = str(local_dir / 'email.html') log.debug('Using default password reset email HTML template')
if config['email']['email_plaintext'] == '': config['password_resets']['email_html'] = str(local_dir / 'email.html')
log.debug('Using default email plaintext template') if ('email_text' not in config['password_resets'] or
config['email']['email_plaintext'] = str(local_dir / 'email.txt') config['password_resets']['email_text'] == ''):
log.debug('Using default password reset email plaintext template')
config['password_resets']['email_text'] = str(local_dir / 'email.txt')
if ('email_html' not in config['invite_emails'] or
config['invite_emails']['email_html'] == ''):
log.debug('Using default invite email HTML template')
config['invite_emails']['email_html'] = str(local_dir /
'invite-email.html')
if ('email_text' not in config['invite_emails'] or
config['invite_emails']['email_text'] == ''):
log.debug('Using default invite email plaintext template')
config['invite_emails']['email_text'] = str(local_dir /
'invite-email.txt')
if args.get_policy: if args.get_policy:
import json import json
@ -136,7 +150,7 @@ else:
host = config['ui']['host'] host = config['ui']['host']
port = config['ui']['port'] port = config['ui']['port']
log.info(f'Starting web UI on {host}:{port}') log.info(f'Starting web UI on {host}:{port}')
if config.getboolean('email', 'enabled'): if config.getboolean('password_resets', 'enabled'):
def start_pwr(): def start_pwr():
import jellyfin_accounts.pw_reset import jellyfin_accounts.pw_reset
jellyfin_accounts.pw_reset.start() jellyfin_accounts.pw_reset.start()

View File

@ -23,7 +23,10 @@ setup(
data_files=[('data', ['data/config-default.ini', data_files=[('data', ['data/config-default.ini',
'data/email.html', 'data/email.html',
'data/email.mjml', 'data/email.mjml',
'data/email.txt']), 'data/email.txt',
'data/invite-email.html',
'data/invite-email.mjml',
'data/invite-email.txt']),
('data/static', ['data/static/admin.js']), ('data/static', ['data/static/admin.js']),
('data/templates', [ ('data/templates', [
'data/templates/404.html', 'data/templates/404.html',