From d22ba6133b56d739ffb708b799d89a084316c876 Mon Sep 17 00:00:00 2001 From: Harvey Tindall Date: Sun, 12 Apr 2020 21:25:27 +0100 Subject: [PATCH] Add pw reset support; add logging --- .gitignore | 5 +- README.md | 68 ++++++++-- data/config-default.ini | 42 +++++- data/email.html | 242 ++++++++++++++++++++++++++++++++++ data/email.mjml | 34 +++++ data/email.txt | 10 ++ jellyfin_accounts/login.py | 5 +- jellyfin_accounts/pw_reset.py | 194 +++++++++++++++++++++++++++ jellyfin_accounts/web.py | 3 + jellyfin_accounts/web_api.py | 37 +++++- jf-accounts | 62 ++++++++- setup.py | 5 +- 12 files changed, 680 insertions(+), 27 deletions(-) create mode 100644 data/email.html create mode 100644 data/email.mjml create mode 100644 data/email.txt create mode 100755 jellyfin_accounts/pw_reset.py diff --git a/.gitignore b/.gitignore index bc7b66a..d50c9ad 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ -__pycache__ +__pycache__/ notes.md MANIFEST.in dist/ build/ +test.txt +data/node_modules/ *.egg-info/ +pw-reset/ diff --git a/README.md b/README.md index 84198f8..f2218e2 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,13 @@ # jellyfin-accounts -A simple, web-based invite system for [Jellyfin](https://github.com/jellyfin/jellyfin). +***New: Now capable of sending password reset emails!*** + +A basic account management system for [Jellyfin](https://github.com/jellyfin/jellyfin). +* Provides a web interface for creating invite codes +* Sends out emails when a user requests a password reset * Uses a basic python jellyfin API client for communication with the server. * Uses [Flask](https://github.com/pallets/flask), [HTTPAuth](https://github.com/miguelgrinberg/Flask-HTTPAuth), [itsdangerous](https://github.com/pallets/itsdangerous), and [Waitress](https://github.com/Pylons/waitress) * Frontend uses [Bootstrap](https://getbootstrap.com), [jQuery](https://jquery.com) and [jQuery-serialize-object](https://github.com/macek/jquery-serialize-object) +* Password resets are handled using smtplib, requests, and [jinja](https://github.com/pallets/jinja) ## Screenshots

@@ -10,16 +15,23 @@ A simple, web-based invite system for [Jellyfin](https://github.com/jellyfin/jel ## Get it ### Requirements + +* This should work anywhere Python does, i've tried to not use anything OS-specific. Drop an issue if you encounter issues, of course. +``` * python >= 3.6 * flask * flask_httpauth +* jinja2 * requests * itsdangerous * passlib * secrets * configparser * waitress - +* pytz +* dateutil +* watchdog +``` ### Install ``` git clone https://github.com/hrfee/jellyfin-accounts.git @@ -47,16 +59,21 @@ optional arguments: ``` ### Setup #### Policy template -* You may want to restrict from accessing certain libraries (e.g 4K Movies), or display their account on the login screen by default. Jellyfin stores these settings as a user's policy. +* You may want to restrict a user from accessing certain libraries (e.g 4K Movies), or display their account on the login screen by default. Jellyfin stores these settings as a user's policy. * Make a temporary account and change its settings, then run `jf-accounts --get_policy`. Choose your user, and the policy will be stored at the location you set in `user_template`, and used for all subsequent new accounts. +#### Emails/Password Resets +* When someone initiates forget password on Jellyfin, a file named `passwordreset*.json` is created in its configuration directory. This directory is monitored and when created, the program reads the username, expiry time and PIN, puts it into a template and sends it to whatever address is specified in `emails.json`. +* **The default forget password popup references the `passwordreset*.json` file created. This is confusing for users, so a quick fix is to edit the `MessageForgotPasswordFileCreated` string in Jellyfin's language folder.** +* Currently, jellyfin-accounts supports generic SSL/TLS secured SMTP, and the [mailgun](https://mailgun.com) REST API. +* Email html is created using [mjml](https://mjml.io), and [jinja](https://github.com/pallets/jinja) templating is used. If you wish to create your own, ensure you use the same jinja expressions (`{{ pin }}`, etc.) as used in `data/email.mjml`, and also create a plain text version for legacy email clients. -### Configuration +#### Configuration * Note: Make sure to put this behind a reverse proxy with HTTPS. On first run, the default configuration is copied to `~/.jf-accounts/config.ini`. ``` -; It is reccommended to create a limited admin account for this program. [jellyfin] +; It is reccommended to create a limited admin account for this program. username = username password = password ; Server will also be used in the invite form, so make sure it's publicly accessible. @@ -72,23 +89,56 @@ port = 8056 username = your username password = your password debug = false -; Enable to store request email address and store. Useful for sending password reset emails. -emails_enabled = false -; Displayed at the bottom of all pages except admin. +; Displayed at the bottom of all pages except admin contact_message = Need help? contact me. ; Displayed at top of form page. help_message = Enter your details to create an account. ; Displayed when an account is created. success_message = Your account has been created. Click below to continue to Jellyfin. +[email] +; 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 +use_24h = true +; Date format follows datetime's strftime. +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 +message = Need help? contact me. +; Mail methods: mailgun, smtp +method = mailgun +; Subject of emails +subject = Password Reset - Jellyfin +; Address to send from +address = jellyfin@jellyf.in +; The name of the sender +from = Jellyfin + +[mailgun] + +api_url = https://api.mailgun.net... +api_key = your api key + +[smtp] +; Insecure SMTP hasn't been implemented, although I doubt many will need it. +ssl = true +server = smtp.jellyf.in +; Uses SMTP_SSL, so make sure the port is for this, not starttls. +port = 465 +password = smtp password [files] ; When the below paths are left blank, files are stored in ~/.jf-accounts/. ; Path to store valid invites. invites = -; Path to store emails in JSON +; Path to store email addresses in JSON emails = ; Path to the user policy template. Can be acquired with get-template. user_template = diff --git a/data/config-default.ini b/data/config-default.ini index 25bfeb7..f29996b 100644 --- a/data/config-default.ini +++ b/data/config-default.ini @@ -15,25 +15,57 @@ port = 8056 username = your username password = your password debug = false -; Enable to store request email address and store. Useful for sending password reset emails. -emails_enabled = false -; Displayed at the bottom of all pages except admin. +; Displayed at the bottom of all pages except admin contact_message = Need help? contact me. ; Displayed at top of form page. help_message = Enter your details to create an account. ; Displayed when an account is created. success_message = Your account has been created. Click below to continue to Jellyfin. +[email] +; 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 +use_24h = true +; Date format follows datetime's strftime. +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 +message = Need help? contact me. +; Mail methods: mailgun, smtp +method = smtp +; Subject of emails +subject = Password Reset - Jellyfin +; Address to send from +address = jellyfin@jellyf.in +; The name of the sender +from = Jellyfin + +[mailgun] + +api_url = https://api.mailgun.net... +api_key = your api key + +[smtp] +; Insecure SMTP hasn't been implemented, although I doubt many will need it. +ssl = true +server = smtp.jellyf.in +; Uses SMTP_SSL, so make sure the port is for this, not starttls. +port = 465 +password = smtp password [files] ; When the below paths are left blank, files are stored in ~/.jf-accounts/. ; Path to store valid invites. invites = -; Path to store emails in JSON +; Path to store emails addresses in JSON emails = ; Path to the user policy template. Can be acquired with get-template. user_template = - diff --git a/data/email.html b/data/email.html new file mode 100644 index 0000000..4aa7822 --- /dev/null +++ b/data/email.html @@ -0,0 +1,242 @@ + + + + + + + + + + + + + + + + + + + + + + + + + +

+ +
+ + + + + + +
+ +
+ + + + +
+
Jellyfin
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + + +
+
+

Hi {{ username }},

+

Someone has recently requested a password reset on Jellyfin.

+

If this was you, enter the below pin into the prompt.

+

The code will expire on {{ expiry_date }}, at {{ expiry_time }}, which is in {{ expires_in }}.

+

If this wasn't you, please ignore this email.

+
+
+ + + + +
+

+ {{ pin }} +

+
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + +
+
{{ message }}
+
+
+ +
+
+ +
+ + + diff --git a/data/email.mjml b/data/email.mjml new file mode 100644 index 0000000..2b17e48 --- /dev/null +++ b/data/email.mjml @@ -0,0 +1,34 @@ + + + + + + + + + Jellyfin + + + + + + +

Hi {{ username }},

+

Someone has recently requested a password reset on Jellyfin.

+

If this was you, enter the below pin into the prompt.

+

The code will expire on {{ expiry_date }}, at {{ expiry_time }}, which is in {{ expires_in }}.

+

If this wasn't you, please ignore this email.

+
+ {{ pin }} +
+
+ + + + {{ message }} + + + + +
+ diff --git a/data/email.txt b/data/email.txt new file mode 100644 index 0000000..43cd930 --- /dev/null +++ b/data/email.txt @@ -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 }} diff --git a/jellyfin_accounts/login.py b/jellyfin_accounts/login.py index 7a213a1..3738ccd 100644 --- a/jellyfin_accounts/login.py +++ b/jellyfin_accounts/login.py @@ -7,6 +7,7 @@ from itsdangerous import (TimedJSONWebSignatureSerializer from passlib.apps import custom_app_context as pwd_context import uuid from __main__ import config, app, g +from __main__ import auth_log as log class Account(): @@ -44,11 +45,13 @@ def verify_password(username, password): if not user: if username == adminAccount.username and adminAccount.verify_password(password): g.user = adminAccount - print(g) + log.debug("HTTPAuth Allowed") return True else: + log.debug("HTTPAuth Denied") return False g.user = adminAccount + log.debug("HTTPAuth Allowed") return True diff --git a/jellyfin_accounts/pw_reset.py b/jellyfin_accounts/pw_reset.py new file mode 100755 index 0000000..e12fbbf --- /dev/null +++ b/jellyfin_accounts/pw_reset.py @@ -0,0 +1,194 @@ +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 __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() + self.dir = str(dir) + + def run(self): + event_handler = Handler() + self.observer.schedule(event_handler, self.dir, recursive=True) + self.observer.start() + try: + while True: + time.sleep(5) + except: + self.observer.stop() + log.info('Watchdog stopped') + + +class Handler(FileSystemEventHandler): + @staticmethod + def on_any_event(event): + if event.is_directory: + return None + elif (event.event_type == 'created' and + 'passwordreset' in event.src_path): + with open(event.src_path, 'r') as f: + reset = json.load(f) + log.info(f'New password reset for {reset["UserName"]}') + try: + with open(config['files']['emails'], 'r') as f: + emails = json.load(f) + address = emails[reset['UserName']] + method = config['email']['method'] + if method == 'mailgun': + email = Mailgun(address) + elif method == 'smtp': + email = Smtp(address) + if email.construct(reset): + email.send() + except (FileNotFoundError, + json.decoder.JSONDecodeError, + IndexError) as e: + err = f'{address}: Failed: ' + type(e).__name__ + log.error(err) + +def start(): + log.info(f'Monitoring {config["email"]["watch_directory"]}') + w = Watcher(config['email']['watch_directory']) + w.run() diff --git a/jellyfin_accounts/web.py b/jellyfin_accounts/web.py index f8a9059..863ae5b 100644 --- a/jellyfin_accounts/web.py +++ b/jellyfin_accounts/web.py @@ -1,6 +1,7 @@ from pathlib import Path from flask import Flask, send_from_directory, render_template from __main__ import config, app, g +from __main__ import web_log as log @app.errorhandler(404) @@ -29,6 +30,7 @@ from jellyfin_accounts.web_api import checkInvite @app.route('/invite/') def inviteProxy(path): if checkInvite(path): + log.info(f'Invite {path} used to request form') return render_template('form.html', contactMessage=config['ui']['contact_message'], helpMessage=config['ui']['help_message'], @@ -37,5 +39,6 @@ def inviteProxy(path): elif 'admin.html' not in path and 'admin.html' not in path: return app.send_static_file(path) else: + log.debug('Attempted use of invalid invite') return render_template('invalidCode.html', contactMessage=config['ui']['contact_message']) diff --git a/jellyfin_accounts/web_api.py b/jellyfin_accounts/web_api.py index 6135198..3f7fad7 100644 --- a/jellyfin_accounts/web_api.py +++ b/jellyfin_accounts/web_api.py @@ -3,7 +3,9 @@ from jellyfin_accounts.jf_api import Jellyfin import json import datetime import secrets +import time from __main__ import config, app, g +from __main__ import web_log as log from jellyfin_accounts.login import auth def resp(success=True, code=500): @@ -28,6 +30,8 @@ def checkInvite(code, delete=False): expiry = datetime.datetime.strptime(i['valid_till'], '%Y-%m-%dT%H:%M:%S.%f') if current_time >= expiry: + log.debug(('Housekeeping: Deleting old invite ' + + invites['invites'][index]['code'])) del invites['invites'][index] else: if i['code'] == code: @@ -45,13 +49,26 @@ jf = Jellyfin(config['jellyfin']['server'], config['jellyfin']['device'], config['jellyfin']['device_id']) -jf.authenticate(config['jellyfin']['username'], - config['jellyfin']['password']) +attempts = 0 +while attempts != 3: + try: + jf.authenticate(config['jellyfin']['username'], + config['jellyfin']['password']) + log.info(('Successfully authenticated with ' + + config['jellyfin']['server'])) + break + except jellyfin_accounts.jf_api.AuthenticationError: + attempts += 1 + log.error(('Failed to authenticate with ' + + config['jellyfin']['server'] + + '. Retrying...')) + time.sleep(5) @app.route('/newUser', methods=['GET', 'POST']) def newUser(): data = request.get_json() + log.debug('Attempted newUser') if checkInvite(data['code'], delete=True): user = jf.newUser(data['username'], data['password']) if user.status_code == 200: @@ -60,8 +77,9 @@ def newUser(): default_policy = json.load(f) jf.setPolicy(user.json()['Id'], default_policy) except: + log.debug('setPolicy failed') pass - if config['ui']['emails_enabled'] == 'true': + if config.getboolean('email', 'enabled'): try: with open(config['files']['emails'], 'r') as f: emails = json.load(f) @@ -70,10 +88,14 @@ def newUser(): emails[data['username']] = data['email'] with open(config['files']['emails'], 'w') as f: f.write(json.dumps(emails, indent=4)) + log.debug('Email address stored') + log.info('New User created.') return resp() else: + log.error(f'New user creation failed: {user.status_code}') return resp(False) else: + log.debug('Attempted newUser unauthorized') return resp(False, code=401) @@ -85,6 +107,7 @@ def generateInvite(): 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') try: @@ -95,12 +118,14 @@ def generateInvite(): invites['invites'].append(invite) with open(config['files']['invites'], 'w') as f: f.write(json.dumps(invites, indent=4, default=str)) + log.info(f'New invite created: {invite["code"]}') return resp() @app.route('/getInvites', methods=['GET']) @auth.login_required def getInvites(): + log.debug('Invites requested') current_time = datetime.datetime.now() try: with open(config['files']['invites'], 'r') as f: @@ -109,8 +134,10 @@ def getInvites(): invites = {'invites': []} response = {'invites': []} for index, i in enumerate(invites['invites']): - expiry = datetime.datetime.strptime(i['valid_till'], '%Y-%m-%dT%H:%M:%S.%f') + expiry = datetime.datetime.strptime(i['valid_till'], '%Y-%m-%dT%H:%M:%S.%f') if current_time >= expiry: + log.debug(('Housekeeping: Deleting old invite ' + + invites['invites'][index]['code'])) del invites['invites'][index] else: valid_for = expiry - current_time @@ -137,6 +164,7 @@ def deleteInvite(): del invites['invites'][index] with open(config['files']['invites'], 'w') as f: f.write(json.dumps(invites, indent=4, default=str)) + log.info(f'Invite deleted: {code}') return resp() @@ -144,6 +172,7 @@ def deleteInvite(): @auth.login_required def get_token(): token = g.user.generate_token() + log.debug('Token generated') return jsonify({'token': token.decode('ascii')}) diff --git a/jf-accounts b/jf-accounts index 3a5acba..b560081 100755 --- a/jf-accounts +++ b/jf-accounts @@ -1,9 +1,12 @@ #!/usr/bin/env python3 - import secrets import configparser import shutil import argparse +import logging +import threading +import signal +import sys from pathlib import Path from flask import Flask, g @@ -48,18 +51,49 @@ if not data_dir.exists(): else: config_path = data_dir / 'config.ini' -config = configparser.ConfigParser() +config = configparser.RawConfigParser() config.read(config_path) +def create_log(name): + log = logging.getLogger(name) + handler = logging.StreamHandler(sys.stdout) + if config.getboolean('ui', 'debug'): + log.setLevel(logging.DEBUG) + handler.setLevel(logging.DEBUG) + else: + log.setLevel(logging.INFO) + handler.setLevel(logging.INFO) + fmt = ' %(name)s - %(levelname)s - %(message)s' + format = logging.Formatter(fmt) + handler.setFormatter(format) + log.addHandler(handler) + log.propagate = False + return log + +log = create_log('main') +email_log = create_log('emails') +web_log = create_log('waitress') +auth_log = create_log('auth') + if args.host is not None: + log.debug(f'Using specified host {args.host}') config['ui']['host'] = args.host if args.port is not None: + log.debug(f'Using specified port {args.port}') config['ui']['port'] = args.port for key in config['files']: if config['files'][key] == '': + log.debug(f'Using default {key}') config['files'][key] = str(data_dir / (key + '.json')) +if config['email']['email_template'] == '': + log.debug('Using default email HTML template') + config['email']['email_template'] = str(local_dir / 'email.html') +if config['email']['email_plaintext'] == '': + log.debug('Using default email plaintext template') + config['email']['email_plaintext'] = str(local_dir / 'email.txt') + if args.get_policy: import json from jellyfin_accounts.jf_api import Jellyfin @@ -85,15 +119,31 @@ if args.get_policy: print(f'Policy written to "{config["files"]["user_template"]}".') print('In future, this policy will be copied to all new users.') else: + def signal_handler(sig, frame): + print('Quitting...') + sys.exit(0) + + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) app = Flask(__name__, root_path=str(local_dir)) app.config['DEBUG'] = config.getboolean('ui', 'debug') app.config['SECRET_KEY'] = secrets.token_urlsafe(16) - + if __name__ == '__main__': import jellyfin_accounts.web_api import jellyfin_accounts.web - print(jellyfin_accounts.web.__file__) from waitress import serve + host = config['ui']['host'] + port = config['ui']['port'] + log.info(f'Starting web UI on {host}:{port}') + if config.getboolean('email', 'enabled'): + def start_pwr(): + import jellyfin_accounts.pw_reset + jellyfin_accounts.pw_reset.start() + pwr = threading.Thread(target=start_pwr, daemon=True) + log.info('Starting email thread') + pwr.start() + serve(app, - host=config['ui']['host'], - port=int(config['ui']['port'])) + host=host, + port=int(port)) diff --git a/setup.py b/setup.py index 5f920f8..120b4b8 100644 --- a/setup.py +++ b/setup.py @@ -20,7 +20,10 @@ setup( ], packages=find_packages(), # include_package_data=True, - data_files=[('data', ['data/config-default.ini']), + data_files=[('data', ['data/config-default.ini', + 'data/email.html', + 'data/email.mjml', + 'data/email.txt']), ('data/static', ['data/static/admin.js']), ('data/templates', [ 'data/templates/404.html',