diff --git a/core/admin/mailu/__init__.py b/core/admin/mailu/__init__.py index 167f04ae..874fa8e0 100644 --- a/core/admin/mailu/__init__.py +++ b/core/admin/mailu/__init__.py @@ -1,132 +1,58 @@ import flask -import flask_sqlalchemy import flask_bootstrap -import flask_login -import flask_script -import flask_migrate -import flask_babel -import flask_limiter import os import docker import socket import uuid -from werkzeug.contrib import fixers +from mailu import utils, debug, db -# Create application -app = flask.Flask(__name__) -default_config = { - # Specific to the admin UI - 'SQLALCHEMY_DATABASE_URI': 'sqlite:////data/main.db', - 'SQLALCHEMY_TRACK_MODIFICATIONS': False, - 'DOCKER_SOCKET': 'unix:///var/run/docker.sock', - 'BABEL_DEFAULT_LOCALE': 'en', - 'BABEL_DEFAULT_TIMEZONE': 'UTC', - 'BOOTSTRAP_SERVE_LOCAL': True, - 'RATELIMIT_STORAGE_URL': 'redis://redis/2', - 'QUOTA_STORAGE_URL': 'redis://redis/1', - 'DEBUG': False, - 'DOMAIN_REGISTRATION': False, - # Statistics management - 'INSTANCE_ID_PATH': '/data/instance', - 'STATS_ENDPOINT': '0.{}.stats.mailu.io', - # Common configuration variables - 'SECRET_KEY': 'changeMe', - 'DOMAIN': 'mailu.io', - 'HOSTNAMES': 'mail.mailu.io,alternative.mailu.io,yetanother.mailu.io', - 'POSTMASTER': 'postmaster', - 'TLS_FLAVOR': 'cert', - 'AUTH_RATELIMIT': '10/minute;1000/hour', - 'DISABLE_STATISTICS': 'False', - # Mail settings - 'DMARC_RUA': None, - 'DMARC_RUF': None, - 'WELCOME': 'False', - 'WELCOME_SUBJECT': 'Dummy welcome topic', - 'WELCOME_BODY': 'Dummy welcome body', - 'DKIM_SELECTOR': 'dkim', - 'DKIM_PATH': '/dkim/{domain}.{selector}.key', - 'DEFAULT_QUOTA': 1000000000, - # Web settings - 'SITENAME': 'Mailu', - 'WEBSITE': 'https://mailu.io', - 'WEB_ADMIN': '/admin', - 'WEB_WEBMAIL': '/webmail', - 'RECAPTCHA_PUBLIC_KEY': '', - 'RECAPTCHA_PRIVATE_KEY': '', - # Advanced settings - 'PASSWORD_SCHEME': 'BLF-CRYPT', - # Host settings - 'HOST_IMAP': 'imap', - 'HOST_POP3': 'imap', - 'HOST_SMTP': 'smtp', - 'HOST_AUTHSMTP': os.environ.get('HOST_SMTP', 'smtp'), -} +def create_app_from_config(config): + """ Create a new application based on the given configuration + """ + app = flask.Flask(__name__) + app.config = config -# Load configuration from the environment if available -for key, value in default_config.items(): - app.config[key] = os.environ.get(key, value) + # Bootstrap is used for basic JS and CSS loading + # TODO: remove this and use statically generated assets instead + app.bootstrap = flask_bootstrap.Bootstrap(app) -# Base application -flask_bootstrap.Bootstrap(app) -db = flask_sqlalchemy.SQLAlchemy(app) -migrate = flask_migrate.Migrate(app, db) -limiter = flask_limiter.Limiter(app, key_func=lambda: current_user.username) + # Initialize application extensions + models.db.init_app(app) + utils.limiter.init_app(app) + utils.babel.init_app(app) + utils.login.init_app(app) + utils.proxy.init_app(app) + manage.migrate.init_app(app) + manage.manager.init_app(app) -# Debugging toolbar -if app.config.get("DEBUG"): - import flask_debugtoolbar - toolbar = flask_debugtoolbar.DebugToolbarExtension(app) + # Initialize debugging tools + if app.config.get("app.debug"): + debug.toolbar.init_app(app) + debug.profiler.init_app(app) -# Manager commnad -manager = flask_script.Manager(app) -manager.add_command('db', flask_migrate.MigrateCommand) + # Inject the default variables in the Jinja parser + @app.context_processor + def inject_defaults(): + signup_domains = models.Domain.query.filter_by(signup_enabled=True).all() + return dict( + current_user=utils.login.current_user, + signup_domains=signup_domains, + config=app.config + ) -# Babel configuration -babel = flask_babel.Babel(app) -translations = list(map(str, babel.list_translations())) + # Import views + from mailu import ui, internal + app.register_blueprint(ui.ui, url_prefix='/ui') + app.register_blueprint(internal.internal, url_prefix='/internal') -@babel.localeselector -def get_locale(): - return flask.request.accept_languages.best_match(translations) + return app -# Login configuration -login_manager = flask_login.LoginManager() -login_manager.init_app(app) -login_manager.login_view = "ui.login" -@login_manager.unauthorized_handler -def handle_needs_login(): - return flask.redirect( - flask.url_for('ui.login', next=flask.request.endpoint) - ) - -@app.context_processor -def inject_defaults(): - signup_domains = models.Domain.query.filter_by(signup_enabled=True).all() - return dict( - current_user=flask_login.current_user, - signup_domains=signup_domains, - config=app.config - ) - -# Import views -from mailu import ui, internal -app.register_blueprint(ui.ui, url_prefix='/ui') -app.register_blueprint(internal.internal, url_prefix='/internal') - -# Create the prefix middleware -class PrefixMiddleware(object): - - def __init__(self, app): - self.app = app - - def __call__(self, environ, start_response): - prefix = environ.get('HTTP_X_FORWARDED_PREFIX', '') - if prefix: - environ['SCRIPT_NAME'] = prefix - return self.app(environ, start_response) - -app.wsgi_app = PrefixMiddleware(fixers.ProxyFix(app.wsgi_app)) +def create_app(): + """ Create a new application based on the config module + """ + config = configuration.ConfigManager() + return create_app_from_config(config) diff --git a/core/admin/mailu/configuration.py b/core/admin/mailu/configuration.py new file mode 100644 index 00000000..43184bad --- /dev/null +++ b/core/admin/mailu/configuration.py @@ -0,0 +1,70 @@ +import os + + +DEFAULT_CONFIG = { + # Specific to the admin UI + 'SQLALCHEMY_DATABASE_URI': 'sqlite:////data/main.db', + 'SQLALCHEMY_TRACK_MODIFICATIONS': False, + 'DOCKER_SOCKET': 'unix:///var/run/docker.sock', + 'BABEL_DEFAULT_LOCALE': 'en', + 'BABEL_DEFAULT_TIMEZONE': 'UTC', + 'BOOTSTRAP_SERVE_LOCAL': True, + 'RATELIMIT_STORAGE_URL': 'redis://redis/2', + 'QUOTA_STORAGE_URL': 'redis://redis/1', + 'DEBUG': False, + 'DOMAIN_REGISTRATION': False, + # Statistics management + 'INSTANCE_ID_PATH': '/data/instance', + 'STATS_ENDPOINT': '0.{}.stats.mailu.io', + # Common configuration variables + 'SECRET_KEY': 'changeMe', + 'DOMAIN': 'mailu.io', + 'HOSTNAMES': 'mail.mailu.io,alternative.mailu.io,yetanother.mailu.io', + 'POSTMASTER': 'postmaster', + 'TLS_FLAVOR': 'cert', + 'AUTH_RATELIMIT': '10/minute;1000/hour', + 'DISABLE_STATISTICS': 'False', + # Mail settings + 'DMARC_RUA': None, + 'DMARC_RUF': None, + 'WELCOME': 'False', + 'WELCOME_SUBJECT': 'Dummy welcome topic', + 'WELCOME_BODY': 'Dummy welcome body', + 'DKIM_SELECTOR': 'dkim', + 'DKIM_PATH': '/dkim/{domain}.{selector}.key', + 'DEFAULT_QUOTA': 1000000000, + # Web settings + 'SITENAME': 'Mailu', + 'WEBSITE': 'https://mailu.io', + 'WEB_ADMIN': '/admin', + 'WEB_WEBMAIL': '/webmail', + 'RECAPTCHA_PUBLIC_KEY': '', + 'RECAPTCHA_PRIVATE_KEY': '', + # Advanced settings + 'PASSWORD_SCHEME': 'BLF-CRYPT', + # Host settings + 'HOST_IMAP': 'imap', + 'HOST_POP3': 'imap', + 'HOST_SMTP': 'smtp', + 'HOST_WEBMAIL': 'webmail', + 'HOST_FRONT': 'front', + 'HOST_AUTHSMTP': os.environ.get('HOST_SMTP', 'smtp'), + 'POD_ADDRESS_RANGE': None +} + + +class ConfigManager(object): + """ Naive configuration manager that uses environment only + """ + + def __init__(self): + self.config = { + os.environ.get(key, value) + for key, value in DEFAULT_CONFIG.items() + } + + def get(self, *args): + return self.config.get(*args) + + def __getitem__(self, key): + return self.get(key) diff --git a/core/admin/mailu/debug.py b/core/admin/mailu/debug.py new file mode 100644 index 00000000..69e88cf8 --- /dev/null +++ b/core/admin/mailu/debug.py @@ -0,0 +1,17 @@ +import flask_debugtoolbar + +from werkzeug.contrib import profiler as werkzeug_profiler + + +# Debugging toolbar +toolbar = flask_debugtoolbar.DebugToolbarExtension() + + +# Profiler +class Profiler(object): + def init_app(self): + app.wsgi_app = werkzeug_profiler.ProfilerMiddleware( + app.wsgi_app, restrictions=[30] + ) + +profiler = Profiler() diff --git a/core/admin/mailu/internal/__init__.py b/core/admin/mailu/internal/__init__.py index 80a3c754..fcb19692 100644 --- a/core/admin/mailu/internal/__init__.py +++ b/core/admin/mailu/internal/__init__.py @@ -1,6 +1,6 @@ from flask_limiter import RateLimitExceeded -from mailu import limiter +from mailu import utils import socket import flask @@ -19,7 +19,7 @@ def rate_limit_handler(e): return response -@limiter.request_filter +@utils.limiter.request_filter def whitelist_webmail(): try: return flask.request.headers["Client-Ip"] ==\ diff --git a/core/admin/mailu/internal/nginx.py b/core/admin/mailu/internal/nginx.py index cb6bc9cb..460719f2 100644 --- a/core/admin/mailu/internal/nginx.py +++ b/core/admin/mailu/internal/nginx.py @@ -1,4 +1,5 @@ -from mailu import db, models, app +from mailu import models +from flask import current_app as app import re import socket diff --git a/core/admin/mailu/internal/views/auth.py b/core/admin/mailu/internal/views/auth.py index 823fbd40..459a8e57 100644 --- a/core/admin/mailu/internal/views/auth.py +++ b/core/admin/mailu/internal/views/auth.py @@ -1,5 +1,6 @@ -from mailu import db, models, app, limiter +from mailu import models, utils from mailu.internal import internal, nginx +from flask import current_app as app import flask import flask_login @@ -7,7 +8,7 @@ import base64 @internal.route("/auth/email") -@limiter.limit( +@utils.limiter.limit( app.config["AUTH_RATELIMIT"], lambda: flask.request.headers["Client-Ip"] ) diff --git a/core/admin/mailu/internal/views/dovecot.py b/core/admin/mailu/internal/views/dovecot.py index c2f53794..99ec0f6b 100644 --- a/core/admin/mailu/internal/views/dovecot.py +++ b/core/admin/mailu/internal/views/dovecot.py @@ -1,5 +1,6 @@ -from mailu import db, models +from mailu import models from mailu.internal import internal +from flask import current_app as app import flask @@ -25,7 +26,7 @@ def dovecot_quota(ns, user_email): user = models.User.query.get(user_email) or flask.abort(404) if ns == "storage": user.quota_bytes_used = flask.request.get_json() - db.session.commit() + models.db.session.commit() return flask.jsonify(None) diff --git a/core/admin/mailu/internal/views/fetch.py b/core/admin/mailu/internal/views/fetch.py index 60249c4b..ccd3a159 100644 --- a/core/admin/mailu/internal/views/fetch.py +++ b/core/admin/mailu/internal/views/fetch.py @@ -1,4 +1,4 @@ -from mailu import db, models +from mailu import models from mailu.internal import internal import flask @@ -27,6 +27,6 @@ def fetch_done(fetch_id): fetch = models.Fetch.query.get(fetch_id) or flask.abort(404) fetch.last_check = datetime.datetime.now() fetch.error_message = str(flask.request.get_json()) - db.session.add(fetch) - db.session.commit() + models.db.session.add(fetch) + models.db.session.commit() return "" diff --git a/core/admin/mailu/internal/views/postfix.py b/core/admin/mailu/internal/views/postfix.py index 79fbdb8a..8c690b73 100644 --- a/core/admin/mailu/internal/views/postfix.py +++ b/core/admin/mailu/internal/views/postfix.py @@ -1,4 +1,4 @@ -from mailu import db, models +from mailu import models from mailu.internal import internal import flask diff --git a/core/admin/mailu/manage.py b/core/admin/mailu/manage.py new file mode 100644 index 00000000..bc2473eb --- /dev/null +++ b/core/admin/mailu/manage.py @@ -0,0 +1,305 @@ +from mailu import models + +from flask import current_app as app + +import flask +import os +import socket +import uuid + + +manager = flask_script.Manager() +db = models.db + + +@manager.command +def advertise(): + """ Advertise this server against statistic services. + """ + if os.path.isfile(app.config["INSTANCE_ID_PATH"]): + with open(app.config["INSTANCE_ID_PATH"], "r") as handle: + instance_id = handle.read() + else: + instance_id = str(uuid.uuid4()) + with open(app.config["INSTANCE_ID_PATH"], "w") as handle: + handle.write(instance_id) + if app.config["DISABLE_STATISTICS"].lower() != "true": + try: + socket.gethostbyname(app.config["STATS_ENDPOINT"].format(instance_id)) + except: + pass + + +@manager.command +def admin(localpart, domain_name, password): + """ Create an admin user + """ + domain = models.Domain.query.get(domain_name) + if not domain: + domain = models.Domain(name=domain_name) + db.session.add(domain) + user = models.User( + localpart=localpart, + domain=domain, + global_admin=True + ) + user.set_password(password) + db.session.add(user) + db.session.commit() + + +@manager.command +def user(localpart, domain_name, password, + hash_scheme=app.config['PASSWORD_SCHEME']): + """ Create a user + """ + domain = models.Domain.query.get(domain_name) + if not domain: + domain = models.Domain(name=domain_name) + db.session.add(domain) + user = models.User( + localpart=localpart, + domain=domain, + global_admin=False + ) + user.set_password(password, hash_scheme=hash_scheme) + db.session.add(user) + db.session.commit() + + +@manager.option('-n', '--domain_name', dest='domain_name') +@manager.option('-u', '--max_users', dest='max_users') +@manager.option('-a', '--max_aliases', dest='max_aliases') +@manager.option('-q', '--max_quota_bytes', dest='max_quota_bytes') +def domain(domain_name, max_users=0, max_aliases=0, max_quota_bytes=0): + domain = models.Domain.query.get(domain_name) + if not domain: + domain = models.Domain(name=domain_name) + db.session.add(domain) + db.session.commit() + + +@manager.command +def user_import(localpart, domain_name, password_hash, + hash_scheme=app.config['PASSWORD_SCHEME']): + """ Import a user along with password hash. Available hashes: + 'SHA512-CRYPT' + 'SHA256-CRYPT' + 'MD5-CRYPT' + 'CRYPT' + """ + domain = models.Domain.query.get(domain_name) + if not domain: + domain = models.Domain(name=domain_name) + db.session.add(domain) + user = models.User( + localpart=localpart, + domain=domain, + global_admin=False + ) + user.set_password(password_hash, hash_scheme=hash_scheme, raw=True) + db.session.add(user) + db.session.commit() + + +@manager.command +def config_update(verbose=False, delete_objects=False): + """sync configuration with data from YAML-formatted stdin""" + import yaml + import sys + new_config = yaml.load(sys.stdin) + # print new_config + domains = new_config.get('domains', []) + tracked_domains = set() + for domain_config in domains: + if verbose: + print(str(domain_config)) + domain_name = domain_config['name'] + max_users = domain_config.get('max_users', 0) + max_aliases = domain_config.get('max_aliases', 0) + max_quota_bytes = domain_config.get('max_quota_bytes', 0) + tracked_domains.add(domain_name) + domain = models.Domain.query.get(domain_name) + if not domain: + domain = models.Domain(name=domain_name, + max_users=max_users, + max_aliases=max_aliases, + max_quota_bytes=max_quota_bytes) + db.session.add(domain) + print("Added " + str(domain_config)) + else: + domain.max_users = max_users + domain.max_aliases = max_aliases + domain.max_quota_bytes = max_quota_bytes + db.session.add(domain) + print("Updated " + str(domain_config)) + + users = new_config.get('users', []) + tracked_users = set() + user_optional_params = ('comment', 'quota_bytes', 'global_admin', + 'enable_imap', 'enable_pop', 'forward_enabled', + 'forward_destination', 'reply_enabled', + 'reply_subject', 'reply_body', 'displayed_name', + 'spam_enabled', 'email', 'spam_threshold') + for user_config in users: + if verbose: + print(str(user_config)) + localpart = user_config['localpart'] + domain_name = user_config['domain'] + password_hash = user_config.get('password_hash', None) + hash_scheme = user_config.get('hash_scheme', None) + domain = models.Domain.query.get(domain_name) + email = '{0}@{1}'.format(localpart, domain_name) + optional_params = {} + for k in user_optional_params: + if k in user_config: + optional_params[k] = user_config[k] + if not domain: + domain = models.Domain(name=domain_name) + db.session.add(domain) + user = models.User.query.get(email) + tracked_users.add(email) + tracked_domains.add(domain_name) + if not user: + user = models.User( + localpart=localpart, + domain=domain, + **optional_params + ) + else: + for k in optional_params: + setattr(user, k, optional_params[k]) + user.set_password(password_hash, hash_scheme=hash_scheme, raw=True) + db.session.add(user) + + aliases = new_config.get('aliases', []) + tracked_aliases = set() + for alias_config in aliases: + if verbose: + print(str(alias_config)) + localpart = alias_config['localpart'] + domain_name = alias_config['domain'] + if type(alias_config['destination']) is str: + destination = alias_config['destination'].split(',') + else: + destination = alias_config['destination'] + wildcard = alias_config.get('wildcard', False) + domain = models.Domain.query.get(domain_name) + email = '{0}@{1}'.format(localpart, domain_name) + if not domain: + domain = models.Domain(name=domain_name) + db.session.add(domain) + alias = models.Alias.query.get(email) + tracked_aliases.add(email) + tracked_domains.add(domain_name) + if not alias: + alias = models.Alias( + localpart=localpart, + domain=domain, + wildcard=wildcard, + destination=destination, + email=email + ) + else: + alias.destination = destination + alias.wildcard = wildcard + db.session.add(alias) + + db.session.commit() + + managers = new_config.get('managers', []) + # tracked_managers=set() + for manager_config in managers: + if verbose: + print(str(manager_config)) + domain_name = manager_config['domain'] + user_name = manager_config['user'] + domain = models.Domain.query.get(domain_name) + manageruser = models.User.query.get(user_name + '@' + domain_name) + if manageruser not in domain.managers: + domain.managers.append(manageruser) + db.session.add(domain) + + db.session.commit() + + if delete_objects: + for user in db.session.query(models.User).all(): + if not (user.email in tracked_users): + if verbose: + print("Deleting user: " + str(user.email)) + db.session.delete(user) + for alias in db.session.query(models.Alias).all(): + if not (alias.email in tracked_aliases): + if verbose: + print("Deleting alias: " + str(alias.email)) + db.session.delete(alias) + for domain in db.session.query(models.Domain).all(): + if not (domain.name in tracked_domains): + if verbose: + print("Deleting domain: " + str(domain.name)) + db.session.delete(domain) + db.session.commit() + + +@manager.command +def user_delete(email): + """delete user""" + user = models.User.query.get(email) + if user: + db.session.delete(user) + db.session.commit() + + +@manager.command +def alias_delete(email): + """delete alias""" + alias = models.Alias.query.get(email) + if alias: + db.session.delete(alias) + db.session.commit() + + +@manager.command +def alias(localpart, domain_name, destination): + """ Create an alias + """ + domain = models.Domain.query.get(domain_name) + if not domain: + domain = models.Domain(name=domain_name) + db.session.add(domain) + alias = models.Alias( + localpart=localpart, + domain=domain, + destination=destination.split(','), + email="%s@%s" % (localpart, domain_name) + ) + db.session.add(alias) + db.session.commit() + +# Set limits to a domain + + +@manager.command +def setlimits(domain_name, max_users, max_aliases, max_quota_bytes): + domain = models.Domain.query.get(domain_name) + domain.max_users = max_users + domain.max_aliases = max_aliases + domain.max_quota_bytes = max_quota_bytes + + db.session.add(domain) + db.session.commit() + +# Make the user manager of a domain + + +@manager.command +def setmanager(domain_name, user_name='manager'): + domain = models.Domain.query.get(domain_name) + manageruser = models.User.query.get(user_name + '@' + domain_name) + domain.managers.append(manageruser) + db.session.add(domain) + db.session.commit() + + +if __name__ == "__main__": + manager.run() diff --git a/core/admin/mailu/models.py b/core/admin/mailu/models.py index 0c80fd4f..43565c55 100644 --- a/core/admin/mailu/models.py +++ b/core/admin/mailu/models.py @@ -1,10 +1,12 @@ -from mailu import app, db, dkim, login_manager +from mailu import dkim from sqlalchemy.ext import declarative from passlib import context, hash from datetime import datetime, date from email.mime import text +from flask import current_app as app +import flask_sqlalchemy import sqlalchemy import re import time @@ -15,6 +17,9 @@ import idna import dns +db = flask_sqlalchemy.SQLAlchemy() + + class IdnaDomain(db.TypeDecorator): """ Stores a Unicode string in it's IDNA representation (ASCII only) """ @@ -67,6 +72,27 @@ class CommaSeparatedList(db.TypeDecorator): return filter(bool, value.split(",")) +class JSONEncoded(db.TypeDecorator): + """Represents an immutable structure as a json-encoded string. + """ + + impl = db.String + + def process_bind_param(self, value, dialect): + return json.dumps(value) if value else None + + def process_result_value(self, value, dialect): + return json.loads(value) if value else None + + +class Config(db.Model): + """ In-database configuration values + """ + + name = db.Column(db.String(255), primary_key=True, nullable=False) + value = db.Column(JSONEncoded) + + # Many-to-many association table for domain managers managers = db.Table('manager', db.Column('domain_name', IdnaDomain, db.ForeignKey('domain.name')), @@ -324,8 +350,6 @@ class User(Base, Email): user = cls.query.get(email) return user if (user and user.enabled and user.check_password(password)) else None -login_manager.user_loader(User.query.get) - class Alias(Base, Email): """ An alias is an email address that redirects to some destination. diff --git a/core/admin/mailu/ui/access.py b/core/admin/mailu/ui/access.py index 2b6a3767..6a923c8e 100644 --- a/core/admin/mailu/ui/access.py +++ b/core/admin/mailu/ui/access.py @@ -1,4 +1,4 @@ -from mailu import db, models +from mailu import models from mailu.ui import forms import flask diff --git a/core/admin/mailu/ui/views/admins.py b/core/admin/mailu/ui/views/admins.py index e9771ec6..7462c545 100644 --- a/core/admin/mailu/ui/views/admins.py +++ b/core/admin/mailu/ui/views/admins.py @@ -1,4 +1,4 @@ -from mailu import db, models +from mailu import models from mailu.ui import ui, forms, access import flask @@ -25,7 +25,7 @@ def admin_create(): user = models.User.query.get(form.admin.data) if user: user.global_admin = True - db.session.commit() + models.db.session.commit() flask.flash('User %s is now admin' % user) return flask.redirect(flask.url_for('.admin_list')) else: @@ -40,7 +40,7 @@ def admin_delete(admin): user = models.User.query.get(admin) if user: user.global_admin = False - db.session.commit() + models.db.session.commit() flask.flash('User %s is no longer admin' % user) return flask.redirect(flask.url_for('.admin_list')) else: diff --git a/core/admin/mailu/ui/views/aliases.py b/core/admin/mailu/ui/views/aliases.py index 7fc03266..eaab5cad 100644 --- a/core/admin/mailu/ui/views/aliases.py +++ b/core/admin/mailu/ui/views/aliases.py @@ -1,4 +1,4 @@ -from mailu import db, models +from mailu import models from mailu.ui import ui, forms, access import flask @@ -27,8 +27,8 @@ def alias_create(domain_name): else: alias = models.Alias(domain=domain) form.populate_obj(alias) - db.session.add(alias) - db.session.commit() + models.db.session.add(alias) + models.db.session.commit() flask.flash('Alias %s created' % alias) return flask.redirect( flask.url_for('.alias_list', domain_name=domain.name)) @@ -45,7 +45,7 @@ def alias_edit(alias): form.localpart.validators = [] if form.validate_on_submit(): form.populate_obj(alias) - db.session.commit() + models.db.session.commit() flask.flash('Alias %s updated' % alias) return flask.redirect( flask.url_for('.alias_list', domain_name=alias.domain.name)) @@ -59,8 +59,8 @@ def alias_edit(alias): def alias_delete(alias): alias = models.Alias.query.get(alias) or flask.abort(404) domain = alias.domain - db.session.delete(alias) - db.session.commit() + models.db.session.delete(alias) + models.db.session.commit() flask.flash('Alias %s deleted' % alias) return flask.redirect( flask.url_for('.alias_list', domain_name=domain.name)) diff --git a/core/admin/mailu/ui/views/alternatives.py b/core/admin/mailu/ui/views/alternatives.py index 79acb074..e5761c83 100644 --- a/core/admin/mailu/ui/views/alternatives.py +++ b/core/admin/mailu/ui/views/alternatives.py @@ -26,8 +26,8 @@ def alternative_create(domain_name): else: alternative = models.Alternative(domain=domain) form.populate_obj(alternative) - db.session.add(alternative) - db.session.commit() + models.db.session.add(alternative) + models.db.session.commit() flask.flash('Alternative domain %s created' % alternative) return flask.redirect( flask.url_for('.alternative_list', domain_name=domain.name)) @@ -41,8 +41,8 @@ def alternative_create(domain_name): def alternative_delete(alternative): alternative = models.Alternative.query.get(alternative) or flask.abort(404) domain = alternative.domain - db.session.delete(alternative) - db.session.commit() + models.db.session.delete(alternative) + models.db.session.commit() flask.flash('Alternative %s deleted' % alternative) return flask.redirect( flask.url_for('.alternative_list', domain_name=domain.name)) diff --git a/core/admin/mailu/ui/views/base.py b/core/admin/mailu/ui/views/base.py index 1ea87401..7501a883 100644 --- a/core/admin/mailu/ui/views/base.py +++ b/core/admin/mailu/ui/views/base.py @@ -1,11 +1,9 @@ -from mailu import dockercli, app, db, models +from mailu import models from mailu.ui import ui, forms, access import flask import flask_login -from urllib import parse - @ui.route('/', methods=["GET"]) @access.authenticated diff --git a/core/admin/mailu/ui/views/domains.py b/core/admin/mailu/ui/views/domains.py index 7afc1746..719d3844 100644 --- a/core/admin/mailu/ui/views/domains.py +++ b/core/admin/mailu/ui/views/domains.py @@ -1,5 +1,6 @@ -from mailu import app, db, models +from mailu import models from mailu.ui import ui, forms, access +from flask import current_app as app import flask import flask_login @@ -26,8 +27,8 @@ def domain_create(): else: domain = models.Domain() form.populate_obj(domain) - db.session.add(domain) - db.session.commit() + models.db.session.add(domain) + models.db.session.commit() flask.flash('Domain %s created' % domain) return flask.redirect(flask.url_for('.domain_list')) return flask.render_template('domain/create.html', form=form) @@ -42,7 +43,7 @@ def domain_edit(domain_name): form.name.validators = [] if form.validate_on_submit(): form.populate_obj(domain) - db.session.commit() + models.db.session.commit() flask.flash('Domain %s saved' % domain) return flask.redirect(flask.url_for('.domain_list')) return flask.render_template('domain/edit.html', form=form, @@ -54,8 +55,8 @@ def domain_edit(domain_name): @access.confirmation_required("delete {domain_name}") def domain_delete(domain_name): domain = models.Domain.query.get(domain_name) or flask.abort(404) - db.session.delete(domain) - db.session.commit() + models.db.session.delete(domain) + models.db.session.commit() flask.flash('Domain %s deleted' % domain) return flask.redirect(flask.url_for('.domain_list')) @@ -99,7 +100,7 @@ def domain_signup(domain_name=None): domain.max_users = 10 domain.max_aliases = 10 if domain.check_mx(): - db.session.add(domain) + models.db.session.add(domain) if flask_login.current_user.is_authenticated: user = models.User.query.get(flask_login.current_user.email) else: @@ -108,9 +109,9 @@ def domain_signup(domain_name=None): form.populate_obj(user) user.set_password(form.pw.data) user.quota_bytes = domain.max_quota_bytes - db.session.add(user) + models.db.session.add(user) domain.managers.append(user) - db.session.commit() + models.db.session.commit() flask.flash('Domain %s created' % domain) return flask.redirect(flask.url_for('.domain_list')) else: diff --git a/core/admin/mailu/ui/views/fetches.py b/core/admin/mailu/ui/views/fetches.py index b4ddb824..c0421146 100644 --- a/core/admin/mailu/ui/views/fetches.py +++ b/core/admin/mailu/ui/views/fetches.py @@ -1,4 +1,4 @@ -from mailu import db, models +from mailu import models from mailu.ui import ui, forms, access import flask @@ -24,8 +24,8 @@ def fetch_create(user_email): if form.validate_on_submit(): fetch = models.Fetch(user=user) form.populate_obj(fetch) - db.session.add(fetch) - db.session.commit() + models.db.session.add(fetch) + models.db.session.commit() flask.flash('Fetch configuration created') return flask.redirect( flask.url_for('.fetch_list', user_email=user.email)) @@ -39,7 +39,7 @@ def fetch_edit(fetch_id): form = forms.FetchForm(obj=fetch) if form.validate_on_submit(): form.populate_obj(fetch) - db.session.commit() + models.db.session.commit() flask.flash('Fetch configuration updated') return flask.redirect( flask.url_for('.fetch_list', user_email=fetch.user.email)) @@ -53,8 +53,8 @@ def fetch_edit(fetch_id): def fetch_delete(fetch_id): fetch = models.Fetch.query.get(fetch_id) or flask.abort(404) user = fetch.user - db.session.delete(fetch) - db.session.commit() + models.db.session.delete(fetch) + models.db.session.commit() flask.flash('Fetch configuration delete') return flask.redirect( flask.url_for('.fetch_list', user_email=user.email)) diff --git a/core/admin/mailu/ui/views/managers.py b/core/admin/mailu/ui/views/managers.py index 15ebcd50..45974f94 100644 --- a/core/admin/mailu/ui/views/managers.py +++ b/core/admin/mailu/ui/views/managers.py @@ -1,4 +1,4 @@ -from mailu import db, models +from mailu import models from mailu.ui import ui, forms, access import flask @@ -30,7 +30,7 @@ def manager_create(domain_name): flask.flash('User %s is already manager' % user, 'error') else: domain.managers.append(user) - db.session.commit() + models.db.session.commit() flask.flash('User %s can now manage %s' % (user, domain.name)) return flask.redirect( flask.url_for('.manager_list', domain_name=domain.name)) @@ -46,7 +46,7 @@ def manager_delete(domain_name, user_email): user = models.User.query.get(user_email) or flask.abort(404) if user in domain.managers: domain.managers.remove(user) - db.session.commit() + models.db.session.commit() flask.flash('User %s can no longer manager %s' % (user, domain)) else: flask.flash('User %s is not manager' % user, 'error') diff --git a/core/admin/mailu/ui/views/relays.py b/core/admin/mailu/ui/views/relays.py index a0ddc614..f1847798 100644 --- a/core/admin/mailu/ui/views/relays.py +++ b/core/admin/mailu/ui/views/relays.py @@ -1,4 +1,4 @@ -from mailu import db, models +from mailu import models from mailu.ui import ui, forms, access import flask @@ -25,8 +25,8 @@ def relay_create(): else: relay = models.Relay() form.populate_obj(relay) - db.session.add(relay) - db.session.commit() + models.db.session.add(relay) + models.db.session.commit() flask.flash('Relayed domain %s created' % relay) return flask.redirect(flask.url_for('.relay_list')) return flask.render_template('relay/create.html', form=form) @@ -41,7 +41,7 @@ def relay_edit(relay_name): form.name.validators = [] if form.validate_on_submit(): form.populate_obj(relay) - db.session.commit() + models.db.session.commit() flask.flash('Relayed domain %s saved' % relay) return flask.redirect(flask.url_for('.relay_list')) return flask.render_template('relay/edit.html', form=form, @@ -53,8 +53,8 @@ def relay_edit(relay_name): @access.confirmation_required("delete {relay_name}") def relay_delete(relay_name): relay = models.Relay.query.get(relay_name) or flask.abort(404) - db.session.delete(relay) - db.session.commit() + models.db.session.delete(relay) + models.db.session.commit() flask.flash('Relayed domain %s deleted' % relay) return flask.redirect(flask.url_for('.relay_list')) diff --git a/core/admin/mailu/ui/views/tokens.py b/core/admin/mailu/ui/views/tokens.py index 4b9881af..3864617b 100644 --- a/core/admin/mailu/ui/views/tokens.py +++ b/core/admin/mailu/ui/views/tokens.py @@ -1,4 +1,4 @@ -from mailu import db, models +from mailu import models from mailu.ui import ui, forms, access from passlib import pwd @@ -32,8 +32,8 @@ def token_create(user_email): token = models.Token(user=user) token.set_password(form.raw_password.data) form.populate_obj(token) - db.session.add(token) - db.session.commit() + models.db.session.add(token) + models.db.session.commit() flask.flash('Authentication token created') return flask.redirect( flask.url_for('.token_list', user_email=user.email)) @@ -46,8 +46,8 @@ def token_create(user_email): def token_delete(token_id): token = models.Token.query.get(token_id) or flask.abort(404) user = token.user - db.session.delete(token) - db.session.commit() + models.db.session.delete(token) + models.db.session.commit() flask.flash('Authentication token deleted') return flask.redirect( flask.url_for('.token_list', user_email=user.email)) diff --git a/core/admin/mailu/ui/views/users.py b/core/admin/mailu/ui/views/users.py index c54d22ec..ac572c2e 100644 --- a/core/admin/mailu/ui/views/users.py +++ b/core/admin/mailu/ui/views/users.py @@ -1,5 +1,6 @@ -from mailu import db, models, app +from mailu import models from mailu.ui import ui, access, forms +from flask import current_app as app import flask import flask_login @@ -33,8 +34,8 @@ def user_create(domain_name): user = models.User(domain=domain) form.populate_obj(user) user.set_password(form.pw.data) - db.session.add(user) - db.session.commit() + models.db.session.add(user) + models.db.session.commit() user.send_welcome() flask.flash('User %s created' % user) return flask.redirect( @@ -63,7 +64,7 @@ def user_edit(user_email): form.populate_obj(user) if form.pw.data: user.set_password(form.pw.data) - db.session.commit() + models.db.session.commit() flask.flash('User %s updated' % user) return flask.redirect( flask.url_for('.user_list', domain_name=user.domain.name)) @@ -77,8 +78,8 @@ def user_edit(user_email): def user_delete(user_email): user = models.User.query.get(user_email) or flask.abort(404) domain = user.domain - db.session.delete(user) - db.session.commit() + models.db.session.delete(user) + models.db.session.commit() flask.flash('User %s deleted' % user) return flask.redirect( flask.url_for('.user_list', domain_name=domain.name)) @@ -93,7 +94,7 @@ def user_settings(user_email): form = forms.UserSettingsForm(obj=user) if form.validate_on_submit(): form.populate_obj(user) - db.session.commit() + models.db.session.commit() flask.flash('Settings updated for %s' % user) if user_email: return flask.redirect( @@ -113,7 +114,7 @@ def user_password(user_email): flask.flash('Passwords do not match', 'error') else: user.set_password(form.pw.data) - db.session.commit() + models.db.session.commit() flask.flash('Password updated for %s' % user) if user_email: return flask.redirect(flask.url_for('.user_list', @@ -130,7 +131,7 @@ def user_forward(user_email): form = forms.UserForwardForm(obj=user) if form.validate_on_submit(): form.populate_obj(user) - db.session.commit() + models.db.session.commit() flask.flash('Forward destination updated for %s' % user) if user_email: return flask.redirect( @@ -147,7 +148,7 @@ def user_reply(user_email): form = forms.UserReplyForm(obj=user) if form.validate_on_submit(): form.populate_obj(user) - db.session.commit() + models.db.session.commit() flask.flash('Auto-reply message updated for %s' % user) if user_email: return flask.redirect( @@ -179,8 +180,8 @@ def user_signup(domain_name=None): form.populate_obj(user) user.set_password(form.pw.data) user.quota_bytes = quota_bytes - db.session.add(user) - db.session.commit() + models.db.session.add(user) + models.db.session.commit() user.send_welcome() flask.flash('Successfully signed up %s' % user) return flask.redirect(flask.url_for('.index')) diff --git a/core/admin/mailu/utils.py b/core/admin/mailu/utils.py new file mode 100644 index 00000000..60902951 --- /dev/null +++ b/core/admin/mailu/utils.py @@ -0,0 +1,46 @@ +import flask +import flask_login +import flask_script +import flask_migrate +import flask_babel +import flask_limiter + + +# Login configuration +login = flask_login.LoginManager() +login.login_view = "ui.login" +login.user_loader(models.User.query.get) + +@login_manager.unauthorized_handler +def handle_needs_login(): + return flask.redirect( + flask.url_for('ui.login', next=flask.request.endpoint) + ) + + +# Request rate limitation +limiter = flask_limiter.Limiter(key_func=lambda: current_user.username) + + +# Application translation +babel = flask_babel.Babel() + +@babel.localeselector +def get_locale(): + translations = list(map(str, babel.list_translations())) + return flask.request.accept_languages.best_match(translations) + + +# Proxy fixer +class PrefixMiddleware(object): + def __call__(self, environ, start_response): + prefix = environ.get('HTTP_X_FORWARDED_PREFIX', '') + if prefix: + environ['SCRIPT_NAME'] = prefix + return self.app(environ, start_response) + + def init_app(self, app): + self.app = fixers.ProxyFix(app.wsgi_app) + app.wsgi_app = self + +proxy = PrefixMiddleware()