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:
2020-04-19 22:35:51 +01:00
parent 561e87984c
commit e8ad3f98d6
19 changed files with 644 additions and 222 deletions

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 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.events import FileSystemEventHandler
from dateutil import parser as date_parser
from jinja2 import Environment, FileSystemLoader, Template
from jellyfin_accounts.email import Mailgun, Smtp
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.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:
def __init__(self, dir):
self.observer = Observer()
@@ -152,7 +15,10 @@ class Watcher:
def run(self):
event_handler = Handler()
self.observer.schedule(event_handler, self.dir, recursive=True)
self.observer.start()
try:
self.observer.start()
except NotADirectoryError:
log.error(f'Directory {self.dir} does not exist')
try:
while True:
time.sleep(5)
@@ -181,7 +47,7 @@ class Handler(FileSystemEventHandler):
email = Mailgun(address)
elif method == 'smtp':
email = Smtp(address)
if email.construct(reset):
if email.construct_reset(reset):
email.send()
except (FileNotFoundError,
json.decoder.JSONDecodeError,
@@ -190,6 +56,6 @@ class Handler(FileSystemEventHandler):
log.error(err)
def start():
log.info(f'Monitoring {config["email"]["watch_directory"]}')
w = Watcher(config['email']['watch_directory'])
log.info(f'Monitoring {config["password_resets"]["watch_directory"]}')
w = Watcher(config['password_resets']['watch_directory'])
w.run()

View File

@@ -1,3 +1,4 @@
import json
from pathlib import Path
from flask import Flask, send_from_directory, render_template
from __main__ import config, app, g
@@ -31,11 +32,23 @@ from jellyfin_accounts.web_api import checkInvite
def inviteProxy(path):
if checkInvite(path):
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',
contactMessage=config['ui']['contact_message'],
helpMessage=config['ui']['help_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:
return app.send_static_file(path)
else:

View File

@@ -62,7 +62,7 @@ while attempts != 3:
except Jellyfin.AuthenticationError:
attempts += 1
log.error(('Failed to authenticate with ' +
config['jellyfin']['server'] +
config['jellyfin']['server'] +
'. Retrying...'))
time.sleep(5)
@@ -140,12 +140,26 @@ def newUser():
def generateInvite():
current_time = datetime.datetime.now()
data = request.get_json()
delta = datetime.timedelta(hours=int(data['hours']),
delta = datetime.timedelta(hours=int(data['hours']),
minutes=int(data['minutes']))
invite = {'code': secrets.token_urlsafe(16)}
log.debug(f'Creating new invite: {invite["code"]}')
invite['valid_till'] = (current_time +
delta).strftime('%Y-%m-%dT%H:%M:%S.%f')
valid_till = current_time + delta
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:
with open(config['files']['invites'], 'r') as f:
invites = json.load(f)
@@ -178,13 +192,15 @@ def getInvites():
del invites['invites'][index]
else:
valid_for = expiry - current_time
response['invites'].append({
'code': i['code'],
'hours': valid_for.seconds//3600,
'minutes': (valid_for.seconds//60) % 60})
invite = {'code': i['code'],
'hours': valid_for.seconds//3600,
'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:
f.write(json.dumps(invites, indent=4, default=str))
return jsonify(response)
return jsonify(response)
@app.route('/deleteInvite', methods=['POST'])
@@ -235,5 +251,3 @@ def modifyConfig():
temp_config.write(config_file)
log.debug('Config written')
return jsonify(data)