Merge branch 'master' of https://github.com/Mailu/Mailu into reduce-logging

main
Florent Daigniere 2 years ago
commit 46f05cb651

1
.gitignore vendored

@ -10,6 +10,7 @@ pip-selfcheck.json
/docs/include
/docs/_build
/.env
/.venv
/docker-compose.yml
/.idea
/.vscode

@ -22,7 +22,7 @@ RUN set -euxo pipefail \
RUN echo $VERSION >/version
EXPOSE 80/tcp
#EXPOSE 80/tcp
HEALTHCHECK CMD curl -skfLo /dev/null http://localhost/ping
VOLUME ["/data","/dkim"]

@ -89,10 +89,11 @@ def create_app_from_config(config):
app.route('/ping')(ping)
# Import views
from mailu import ui, internal, sso
from mailu import ui, internal, sso, api
app.register_blueprint(ui.ui, url_prefix=app.config['WEB_ADMIN'])
app.register_blueprint(internal.internal, url_prefix='/internal')
app.register_blueprint(sso.sso, url_prefix='/sso')
api.register(app, web_api_root=app.config.get('WEB_API'))
return app

@ -0,0 +1,32 @@
from flask import redirect, url_for, Blueprint
from flask_restx import apidoc
from . import v1 as APIv1
def register(app, web_api_root):
APIv1.app = app
# register api bluprint(s)
apidoc.apidoc.url_prefix = f'{web_api_root}/v{int(APIv1.VERSION)}'
APIv1.api_token = app.config['API_TOKEN']
if app.config['API_TOKEN'] != '':
app.register_blueprint(APIv1.blueprint, url_prefix=f'{web_api_root}/v{int(APIv1.VERSION)}')
# add redirect to current api version
redirect_api = Blueprint('redirect_api', __name__)
@redirect_api.route('/')
def redir():
return redirect(url_for(f'{APIv1.blueprint.name}.root'))
app.register_blueprint(redirect_api, url_prefix=f'{web_api_root}')
# swagger ui config
app.config.SWAGGER_UI_DOC_EXPANSION = 'list'
app.config.SWAGGER_UI_OPERATION_ID = True
app.config.SWAGGER_UI_REQUEST_DURATION = True
app.config.RESTX_MASK_SWAGGER = False
else:
api = Blueprint('api', __name__)
@api.route('/', defaults={'path': ''})
@api.route('/<path:path>')
def api_token_missing(path):
return "<p>Error: API_TOKEN is not configured</p>", 500
app.register_blueprint(api, url_prefix=f'{web_api_root}')

@ -0,0 +1,42 @@
from .. import models, utils
from . import v1
from flask import request
import flask
import hmac
from functools import wraps
from flask_restx import abort
from sqlalchemy.sql.expression import label
def fqdn_in_use(name):
d = models.db.session.query(label('name', models.Domain.name))
a = models.db.session.query(label('name', models.Alternative.name))
r = models.db.session.query(label('name', models.Relay.name))
u = d.union_all(a).union_all(r).filter_by(name=name)
if models.db.session.query(u.exists()).scalar():
return True
return False
""" Decorator for validating api token for authentication """
def api_token_authorization(func):
@wraps(func)
def decorated_function(*args, **kwds):
client_ip = flask.request.headers.get('X-Real-IP', flask.request.remote_addr)
if utils.limiter.should_rate_limit_ip(client_ip):
abort(429, 'Too many attempts from your IP (rate-limit)' )
if not request.headers.get('Authorization'):
abort(401, 'A valid Bearer token is expected which is provided as request header')
#Client provides 'Authentication: Bearer <token>'
if (' ' in request.headers.get('Authorization')
and not hmac.compare_digest(request.headers.get('Authorization'), 'Bearer ' + v1.api_token)):
utils.limiter.rate_limit_ip(client_ip)
flask.current_app.logger.warn(f'Invalid API token provided by {client_ip}.')
abort(403, 'A valid Bearer token is expected which is provided as request header')
#Client provides 'Authentication: <token>'
elif (' ' not in request.headers.get('Authorization')
and not hmac.compare_digest(request.headers.get('Authorization'), v1.api_token)):
utils.limiter.rate_limit_ip(client_ip)
flask.current_app.logger.warn(f'Invalid API token provided by {client_ip}.')
abort(403, 'A valid Bearer token is expected which is provided as request header')
flask.current_app.logger.info(f'Valid API token provided by {client_ip}.')
return func(*args, **kwds)
return decorated_function

@ -0,0 +1,43 @@
from flask import Blueprint
from flask_restx import Api, fields
VERSION = 1.0
api_token = None
blueprint = Blueprint(f'api_v{int(VERSION)}', __name__)
authorization = {
'Bearer': {
'type': 'apiKey',
'in': 'header',
'name': 'Authorization'
}
}
api = Api(
blueprint, version=f'{VERSION:.1f}',
title='Mailu API', default_label='Mailu',
validate=True,
authorizations=authorization,
security='Bearer',
doc='/'
)
response_fields = api.model('Response', {
'code': fields.Integer,
'message': fields.String,
})
error_fields = api.model('Error', {
'errors': fields.Nested(api.model('Error_Key', {
'key': fields.String,
'message':fields.String
})),
'message': fields.String,
})
from . import domains
from . import alias
from . import relay
from . import user

@ -0,0 +1,126 @@
from flask_restx import Resource, fields, marshal
from . import api, response_fields
from .. import common
from ... import models
db = models.db
alias = api.namespace('alias', description='Alias operations')
alias_fields_update = alias.model('AliasUpdate', {
'comment': fields.String(description='a comment'),
'destination': fields.List(fields.String(description='alias email address', example='user@example.com')),
'wildcard': fields.Boolean(description='enable SQL Like wildcard syntax')
})
alias_fields = alias.inherit('Alias',alias_fields_update, {
'email': fields.String(description='the alias email address', example='user@example.com', required=True),
'destination': fields.List(fields.String(description='alias email address', example='user@example.com', required=True)),
})
@alias.route('')
class Aliases(Resource):
@alias.doc('list_alias')
@alias.marshal_with(alias_fields, as_list=True, skip_none=True, mask=None)
@alias.doc(security='Bearer')
@common.api_token_authorization
def get(self):
""" List aliases """
return models.Alias.query.all()
@alias.doc('create_alias')
@alias.expect(alias_fields)
@alias.response(200, 'Success', response_fields)
@alias.response(400, 'Input validation exception', response_fields)
@alias.response(409, 'Duplicate alias', response_fields)
@alias.doc(security='Bearer')
@common.api_token_authorization
def post(self):
""" Create a new alias """
data = api.payload
alias_found = models.Alias.query.filter_by(email = data['email']).first()
if alias_found:
return { 'code': 409, 'message': f'Duplicate alias {data["email"]}'}, 409
alias_model = models.Alias(email=data["email"],destination=data['destination'])
if 'comment' in data:
alias_model.comment = data['comment']
if 'wildcard' in data:
alias_model.wildcard = data['wildcard']
db.session.add(alias_model)
db.session.commit()
return {'code': 200, 'message': f'Alias {data["email"]} to destination {data["destination"]} has been created'}, 200
@alias.route('/<string:alias>')
class Alias(Resource):
@alias.doc('find_alias')
@alias.response(200, 'Success', alias_fields)
@alias.response(404, 'Alias not found', response_fields)
@alias.doc(security='Bearer')
@common.api_token_authorization
def get(self, alias):
""" Find alias """
alias_found = models.Alias.query.filter_by(email = alias).first()
if alias_found is None:
return { 'code': 404, 'message': f'Alias {alias} cannot be found'}, 404
else:
return marshal(alias_found,alias_fields), 200
@alias.doc('update_alias')
@alias.expect(alias_fields_update)
@alias.response(200, 'Success', response_fields)
@alias.response(404, 'Alias not found', response_fields)
@alias.response(400, 'Input validation exception', response_fields)
@alias.doc(security='Bearer')
@common.api_token_authorization
def patch(self, alias):
""" Update alias """
data = api.payload
alias_found = models.Alias.query.filter_by(email = alias).first()
if alias_found is None:
return { 'code': 404, 'message': f'Alias {alias} cannot be found'}, 404
if 'comment' in data:
alias_found.comment = data['comment']
if 'destination' in data:
alias_found.destination = data['destination']
if 'wildcard' in data:
alias_found.wildcard = data['wildcard']
db.session.add(alias_found)
db.session.commit()
return {'code': 200, 'message': f'Alias {alias} has been updated'}
@alias.doc('delete_alias')
@alias.response(200, 'Success', response_fields)
@alias.response(404, 'Alias not found', response_fields)
@alias.doc(security='Bearer')
@common.api_token_authorization
def delete(self, alias):
""" Delete alias """
alias_found = models.Alias.query.filter_by(email = alias).first()
if alias_found is None:
return { 'code': 404, 'message': f'Alias {alias} cannot be found'}, 404
db.session.delete(alias_found)
db.session.commit()
return {'code': 200, 'message': f'Alias {alias} has been deleted'}, 200
@alias.route('/destination/<string:domain>')
class AliasWithDest(Resource):
@alias.doc('find_alias_filter_domain')
@alias.response(200, 'Success', alias_fields)
@alias.response(404, 'Alias or domain not found', response_fields)
@alias.doc(security='Bearer')
@common.api_token_authorization
def get(self, domain):
""" Find aliases of domain """
domain_found = models.Domain.query.filter_by(name=domain).first()
if domain_found is None:
return { 'code': 404, 'message': f'Domain {domain} cannot be found'}, 404
aliases_found = domain_found.aliases
if aliases_found.count == 0:
return { 'code': 404, 'message': f'No alias can be found for domain {domain}'}, 404
else:
return marshal(aliases_found, alias_fields), 200

@ -0,0 +1,410 @@
import validators
from flask_restx import Resource, fields, marshal
from . import api, response_fields, user
from .. import common
from ... import models
db = models.db
dom = api.namespace('domain', description='Domain operations')
alt = api.namespace('alternative', description='Alternative operations')
domain_fields = api.model('Domain', {
'name': fields.String(description='FQDN (e.g. example.com)', example='example.com', required=True),
'comment': fields.String(description='a comment'),
'max_users': fields.Integer(description='maximum number of users', min=-1, default=-1),
'max_aliases': fields.Integer(description='maximum number of aliases', min=-1, default=-1),
'max_quota_bytes': fields.Integer(description='maximum quota for mailbox', min=0),
'signup_enabled': fields.Boolean(description='allow signup'),
'alternatives': fields.List(fields.String(attribute='name', description='FQDN', example='example2.com')),
})
domain_fields_update = api.model('DomainUpdate', {
'comment': fields.String(description='a comment'),
'max_users': fields.Integer(description='maximum number of users', min=-1, default=-1),
'max_aliases': fields.Integer(description='maximum number of aliases', min=-1, default=-1),
'max_quota_bytes': fields.Integer(description='maximum quota for mailbox', min=0),
'signup_enabled': fields.Boolean(description='allow signup'),
'alternatives': fields.List(fields.String(attribute='name', description='FQDN', example='example2.com')),
})
domain_fields_get = api.model('DomainGet', {
'name': fields.String(description='FQDN (e.g. example.com)', example='example.com', required=True),
'comment': fields.String(description='a comment'),
'max_users': fields.Integer(description='maximum number of users', min=-1, default=-1),
'max_aliases': fields.Integer(description='maximum number of aliases', min=-1, default=-1),
'max_quota_bytes': fields.Integer(description='maximum quota for mailbox', min=0),
'signup_enabled': fields.Boolean(description='allow signup'),
'alternatives': fields.List(fields.String(attribute='name', description='FQDN', example='example2.com')),
'dns_autoconfig': fields.List(fields.String(description='DNS client auto-configuration entry')),
'dns_mx': fields.String(Description='MX record for domain'),
'dns_spf': fields.String(Description='SPF record for domain'),
'dns_dkim': fields.String(Description='DKIM record for domain'),
'dns_dmarc': fields.String(Description='DMARC record for domain'),
'dns_dmarc_report': fields.String(Description='DMARC report record for domain'),
'dns_tlsa': fields.String(Description='TLSA record for domain'),
})
domain_fields_dns = api.model('DomainDNS', {
'dns_autoconfig': fields.List(fields.String(description='DNS client auto-configuration entry')),
'dns_mx': fields.String(Description='MX record for domain'),
'dns_spf': fields.String(Description='SPF record for domain'),
'dns_dkim': fields.String(Description='DKIM record for domain'),
'dns_dmarc': fields.String(Description='DMARC record for domain'),
'dns_dmarc_report': fields.String(Description='DMARC report record for domain'),
'dns_tlsa': fields.String(Description='TLSA record for domain'),
})
manager_fields = api.model('Manager', {
'domain_name': fields.String(description='domain managed by manager'),
'user_email': fields.String(description='email address of manager'),
})
manager_fields_create = api.model('ManagerCreate', {
'user_email': fields.String(description='email address of manager', required=True),
})
alternative_fields_update = api.model('AlternativeDomainUpdate', {
'domain': fields.String(description='domain FQDN', example='example.com', required=False),
})
alternative_fields = api.model('AlternativeDomain', {
'name': fields.String(description='alternative FQDN', example='example2.com', required=True),
'domain': fields.String(description='domain FQDN', example='example.com', required=True),
})
@dom.route('')
class Domains(Resource):
@dom.doc('list_domain')
@dom.marshal_with(domain_fields_get, as_list=True, skip_none=True, mask=None)
@dom.doc(security='Bearer')
@common.api_token_authorization
def get(self):
""" List domains """
return models.Domain.query.all()
@dom.doc('create_domain')
@dom.expect(domain_fields)
@dom.response(200, 'Success', response_fields)
@dom.response(400, 'Input validation exception', response_fields)
@dom.response(409, 'Duplicate domain/alternative name', response_fields)
@dom.doc(security='Bearer')
@common.api_token_authorization
def post(self):
""" Create a new domain """
data = api.payload
if not validators.domain(data['name']):
return { 'code': 400, 'message': f'Domain {data["name"]} is not a valid domain'}, 400
if common.fqdn_in_use(data['name']):
return { 'code': 409, 'message': f'Duplicate domain name {data["name"]}'}, 409
if 'alternatives' in data:
#check if duplicate alternatives are supplied
if [x for x in data['alternatives'] if data['alternatives'].count(x) >= 2]:
return { 'code': 409, 'message': f'Duplicate alternative domain names in request' }, 409
for item in data['alternatives']:
if common.fqdn_in_use(item):
return { 'code': 409, 'message': f'Duplicate alternative domain name {item}' }, 409
if not validators.domain(item):
return { 'code': 400, 'message': f'Alternative domain {item} is not a valid domain'}, 400
for item in data['alternatives']:
alternative = models.Alternative(name=item, domain_name=data['name'])
models.db.session.add(alternative)
domain_new = models.Domain(name=data['name'])
if 'comment' in data:
domain_new.comment = data['comment']
if 'max_users' in data:
domain_new.comment = data['max_users']
if 'max_aliases' in data:
domain_new.comment = data['max_aliases']
if 'max_quota_bytes' in data:
domain_new.comment = data['max_quota_bytes']
if 'signup_enabled' in data:
domain_new.comment = data['signup_enabled']
models.db.session.add(domain_new)
#apply the changes
db.session.commit()
return {'code': 200, 'message': f'Domain {data["name"]} has been created'}, 200
@dom.route('/<domain>')
class Domain(Resource):
@dom.doc('find_domain')
@dom.response(200, 'Success', domain_fields)
@dom.response(404, 'Domain not found', response_fields)
@dom.doc(security='Bearer')
@common.api_token_authorization
def get(self, domain):
""" Find domain by name """
if not validators.domain(domain):
return { 'code': 400, 'message': f'Domain {domain} is not a valid domain'}, 400
domain_found = models.Domain.query.get(domain)
if not domain_found:
return { 'code': 404, 'message': f'Domain {domain} does not exist'}, 404
return marshal(domain_found, domain_fields_get), 200
@dom.doc('update_domain')
@dom.expect(domain_fields_update)
@dom.response(200, 'Success', response_fields)
@dom.response(400, 'Input validation exception', response_fields)
@dom.response(404, 'Domain not found', response_fields)
@dom.response(409, 'Duplicate domain/alternative name', response_fields)
@dom.doc(security='Bearer')
@common.api_token_authorization
def patch(self, domain):
""" Update an existing domain """
if not validators.domain(domain):
return { 'code': 400, 'message': f'Domain {domain} is not a valid domain'}, 400
domain_found = models.Domain.query.get(domain)
if not domain:
return { 'code': 404, 'message': f'Domain {data["name"]} does not exist'}, 404
data = api.payload
if 'alternatives' in data:
#check if duplicate alternatives are supplied
if [x for x in data['alternatives'] if data['alternatives'].count(x) >= 2]:
return { 'code': 409, 'message': f'Duplicate alternative domain names in request' }, 409
for item in data['alternatives']:
if common.fqdn_in_use(item):
return { 'code': 409, 'message': f'Duplicate alternative domain name {item}' }, 409
if not validators.domain(item):
return { 'code': 400, 'message': f'Alternative domain {item} is not a valid domain'}, 400
for item in data['alternatives']:
alternative = models.Alternative(name=item, domain_name=data['name'])
models.db.session.add(alternative)
if 'comment' in data:
domain_found.comment = data['comment']
if 'max_users' in data:
domain_found.comment = data['max_users']
if 'max_aliases' in data:
domain_found.comment = data['max_aliases']
if 'max_quota_bytes' in data:
domain_found.comment = data['max_quota_bytes']
if 'signup_enabled' in data:
domain_found.comment = data['signup_enabled']
models.db.session.add(domain_found)
#apply the changes
db.session.commit()
return {'code': 200, 'message': f'Domain {domain} has been updated'}, 200
@dom.doc('delete_domain')
@dom.response(200, 'Success', response_fields)
@dom.response(400, 'Input validation exception', response_fields)
@dom.response(404, 'Domain not found', response_fields)
@dom.doc(security='Bearer')
@common.api_token_authorization
def delete(self, domain):
""" Delete domain """
if not validators.domain(domain):
return { 'code': 400, 'message': f'Domain {domain} is not a valid domain'}, 400
domain_found = models.Domain.query.get(domain)
if not domain:
return { 'code': 404, 'message': f'Domain {domain} does not exist'}, 404
db.session.delete(domain_found)
db.session.commit()
return {'code': 200, 'message': f'Domain {domain} has been deleted'}, 200
@dom.route('/<domain>/dkim')
class Domain(Resource):
@dom.doc('generate_dkim')
@dom.response(200, 'Success', response_fields)
@dom.response(400, 'Input validation exception', response_fields)
@dom.response(404, 'Domain not found', response_fields)
@dom.doc(security='Bearer')
@common.api_token_authorization
def post(self, domain):
""" Generate new DKIM/DMARC keys for domain """
if not validators.domain(domain):
return { 'code': 400, 'message': f'Domain {domain} is not a valid domain'}, 400
domain_found = models.Domain.query.get(domain)
if not domain_found:
return { 'code': 404, 'message': f'Domain {domain} does not exist'}, 404
domain_found.generate_dkim_key()
domain_found.save_dkim_key()
return {'code': 200, 'message': f'DKIM/DMARC keys have been generated for domain {domain}'}, 200
@dom.route('/<domain>/manager')
class Manager(Resource):
@dom.doc('list_managers')
@dom.marshal_with(manager_fields, as_list=True, skip_none=True, mask=None)
@dom.response(400, 'Input validation exception', response_fields)
@dom.response(404, 'domain not found', response_fields)
@dom.doc(security='Bearer')
@common.api_token_authorization
def get(self, domain):
""" List managers of domain """
if not validators.domain(domain):
return { 'code': 400, 'message': f'Domain {domain} is not a valid domain'}, 400
if not domain:
return { 'code': 404, 'message': f'Domain {domain} does not exist'}, 404
domain = models.Domain.query.filter_by(name=domain)
return domain.managers
@dom.doc('create_manager')
@dom.expect(manager_fields_create)
@dom.response(200, 'Success', response_fields)
@dom.response(400, 'Input validation exception', response_fields)
@dom.response(404, 'User or domain not found', response_fields)
@dom.response(409, 'Duplicate domain manager', response_fields)
@dom.doc(security='Bearer')
@common.api_token_authorization
def post(self, domain):
""" Create a new domain manager """
data = api.payload
if not validators.email(data['user_email']):
return {'code': 400, 'message': f'Invalid email address {data["user_email"]}'}, 400
if not validators.domain(domain):
return { 'code': 400, 'message': f'Domain {domain} is not a valid domain'}, 400
domain = models.Domain.query.get(domain)
if not domain:
return { 'code': 404, 'message': f'Domain {domain} does not exist'}, 404
user = models.User.query.get(data['user_email'])
if not user:
return { 'code': 404, 'message': f'User {data["user_email"]} does not exist'}, 404
if user in domain.managers:
return {'code': 409, 'message': f'User {data["user_email"]} is already a manager of the domain {domain} '}, 409
domain.managers.append(user)
models.db.session.commit()
return {'code': 200, 'message': f'User {data["user_email"]} has been added as manager of the domain {domain} '},200
@dom.route('/<domain>/manager/<email>')
class Domain(Resource):
@dom.doc('find_manager')
@dom.response(200, 'Success', manager_fields)
@dom.response(404, 'Manager not found', response_fields)
@dom.doc(security='Bearer')
@common.api_token_authorization
def get(self, domain, email):
""" Find manager by email address """
if not validators.email(email):
return {'code': 400, 'message': f'Invalid email address {email}'}, 400
if not validators.domain(domain):
return { 'code': 400, 'message': f'Domain {domain} is not a valid domain'}, 400
domain = models.Domain.query.get(domain)
if not domain:
return { 'code': 404, 'message': f'Domain {domain} does not exist'}, 404
user = models.User.query.get(email)
if not user:
return { 'code': 404, 'message': f'User {email} does not exist'}, 404
if user in domain.managers:
for manager in domain.managers:
if manager.email == email:
return marshal(manager, manager_fields),200
else:
return { 'code': 404, 'message': f'User {email} is not a manager of the domain {domain}'}, 404
@dom.doc('delete_manager')
@dom.response(200, 'Success', response_fields)
@dom.response(400, 'Input validation exception', response_fields)
@dom.response(404, 'Manager not found', response_fields)
@dom.doc(security='Bearer')
@common.api_token_authorization
def delete(self, domain, email):
if not validators.email(email):
return {'code': 400, 'message': f'Invalid email address {email}'}, 400
if not validators.domain(domain):
return { 'code': 400, 'message': f'Domain {domain} is not a valid domain'}, 400
domain = models.Domain.query.get(domain)
if not domain:
return { 'code': 404, 'message': f'Domain {domain} does not exist'}, 404
user = models.User.query.get(email)
if not user:
return { 'code': 404, 'message': f'User {email} does not exist'}, 404
if user in domain.managers:
domain.managers.remove(user)
models.db.session.commit()
return {'code': 200, 'message': f'User {email} has been removed as a manager of the domain {domain} '},200
else:
return { 'code': 404, 'message': f'User {email} is not a manager of the domain {domain}'}, 404
@dom.route('/<domain>/users')
class User(Resource):
@dom.doc('list_user_domain')
@dom.marshal_with(user.user_fields_get, as_list=True, skip_none=True, mask=None)
@dom.response(400, 'Input validation exception', response_fields)
@dom.response(404, 'Domain not found', response_fields)
@dom.doc(security='Bearer')
@common.api_token_authorization
def get(self, domain):
""" List users from domain """
if not validators.domain(domain):
return { 'code': 400, 'message': f'Domain {domain} is not a valid domain'}, 400
domain_found = models.Domain.query.get(domain)
if not domain_found:
return { 'code': 404, 'message': f'Domain {domain} does not exist'}, 404
return models.User.query.filter_by(domain=domain_found).all()
@alt.route('')
class Alternatives(Resource):
@alt.doc('list_alternative')
@alt.marshal_with(alternative_fields, as_list=True, skip_none=True, mask=None)
@alt.doc(security='Bearer')
@common.api_token_authorization
def get(self):
""" List alternatives """
return models.Alternative.query.all()
@alt.doc('create_alternative')
@alt.expect(alternative_fields)
@alt.response(200, 'Success', response_fields)
@alt.response(400, 'Input validation exception', response_fields)
@alt.response(404, 'Domain not found or missing', response_fields)
@alt.response(409, 'Duplicate alternative domain name', response_fields)
@alt.doc(security='Bearer')
@common.api_token_authorization
def post(self):
""" Create new alternative (for domain) """
data = api.payload
if not validators.domain(data['name']):
return { 'code': 400, 'message': f'Alternative domain {data["name"]} is not a valid domain'}, 400
if not validators.domain(data['domain']):
return { 'code': 400, 'message': f'Domain {data["domain"]} is not a valid domain'}, 400
domain = models.Domain.query.get(data['domain'])
if not domain:
return { 'code': 404, 'message': f'Domain {data["domain"]} does not exist'}, 404
if common.fqdn_in_use(data['name']):
return { 'code': 409, 'message': f'Duplicate alternative domain name {data["name"]}'}, 409
alternative = models.Alternative(name=data['name'], domain_name=data['domain'])
models.db.session.add(alternative)
db.session.commit()
return {'code': 200, 'message': f'Alternative {data["name"]} for domain {data["domain"]} has been created'}, 200
@alt.route('/<string:alt>')
class Alternative(Resource):
@alt.doc('find_alternative')
@alt.doc(security='Bearer')
@common.api_token_authorization
def get(self, alt):
""" Find alternative (of domain) """
if not validators.domain(alt):
return { 'code': 400, 'message': f'Alternative domain {alt} is not a valid domain'}, 400
alternative = models.Alternative.query.filter_by(name=alt).first()
if not alternative:
return{ 'code': 404, 'message': f'Alternative domain {alt} does not exist'}, 404
return marshal(alternative, alternative_fields), 200
@alt.doc('delete_alternative')
@alt.response(200, 'Success', response_fields)
@alt.response(400, 'Input validation exception', response_fields)
@alt.response(404, 'Alternative/Domain not found or missing', response_fields)
@alt.response(409, 'Duplicate domain name', response_fields)
@alt.doc(security='Bearer')
@common.api_token_authorization
def delete(self, alt):
""" Delete alternative (for domain) """
if not validators.domain(alt):
return { 'code': 400, 'message': f'Alternative domain {alt} is not a valid domain'}, 400
alternative = models.Alternative.query.filter_by(name=alt).scalar()
if not alternative:
return { 'code': 404, 'message': f'Alternative domain {alt} does not exist'}, 404
domain = alternative.domain_name
db.session.delete(alternative)
db.session.commit()
return {'code': 200, 'message': f'Alternative {alt} for domain {domain} has been deleted'}, 200

@ -0,0 +1,118 @@
from flask_restx import Resource, fields, marshal
import validators
from . import api, response_fields
from .. import common
from ... import models
db = models.db
relay = api.namespace('relay', description='Relay operations')
relay_fields = api.model('Relay', {
'name': fields.String(description='relayed domain name', example='example.com', required=True),
'smtp': fields.String(description='remote host', example='example.com', required=False),
'comment': fields.String(description='a comment', required=False)
})
relay_fields_update = api.model('RelayUpdate', {
'smtp': fields.String(description='remote host', example='example.com', required=False),
'comment': fields.String(description='a comment', required=False)
})
@relay.route('')
class Relays(Resource):
@relay.doc('list_relays')
@relay.marshal_with(relay_fields, as_list=True, skip_none=True, mask=None)
@relay.doc(security='Bearer')
@common.api_token_authorization
def get(self):
"List relays"
return models.Relay.query.all()
@relay.doc('create_relay')
@relay.expect(relay_fields)
@relay.response(200, 'Success', response_fields)
@relay.response(400, 'Input validation exception')
@relay.response(409, 'Duplicate relay', response_fields)
@relay.doc(security='Bearer')
@common.api_token_authorization
def post(self):
""" Create relay """
data = api.payload
if not validators.domain(name):
return { 'code': 400, 'message': f'Relayed domain {name} is not a valid domain'}, 400
if common.fqdn_in_use(data['name']):
return { 'code': 409, 'message': f'Duplicate domain {data["name"]}'}, 409
relay_model = models.Relay(name=data['name'])
if 'smtp' in data:
relay_model.smtp = data['smtp']
if 'comment' in data:
relay_model.comment = data['comment']
db.session.add(relay_model)
db.session.commit()
return {'code': 200, 'message': f'Relayed domain {data["name"]} has been created'}, 200
@relay.route('/<string:name>')
class Relay(Resource):
@relay.doc('find_relay')
@relay.response(400, 'Input validation exception', response_fields)
@relay.response(404, 'Relay not found', response_fields)
@relay.doc(security='Bearer')
@common.api_token_authorization
def get(self, name):
""" Find relay """
if not validators.domain(name):
return { 'code': 400, 'message': f'Relayed domain {name} is not a valid domain'}, 400
relay_found = models.Relay.query.filter_by(name=name).first()
if relay_found is None:
return { 'code': 404, 'message': f'Relayed domain {name} cannot be found'}, 404
return marshal(relay_found, relay_fields), 200
@relay.doc('update_relay')
@relay.expect(relay_fields_update)
@relay.response(200, 'Success', response_fields)
@relay.response(400, 'Input validation exception', response_fields)
@relay.response(404, 'Relay not found', response_fields)
@relay.response(409, 'Duplicate relay', response_fields)
@relay.doc(security='Bearer')
@common.api_token_authorization
def patch(self, name):
""" Update relay """
data = api.payload
if not validators.domain(name):
return { 'code': 400, 'message': f'Relayed domain {name} is not a valid domain'}, 400
relay_found = models.Relay.query.filter_by(name=name).first()
if relay_found is None:
return { 'code': 404, 'message': f'Relayed domain {name} cannot be found'}, 404
if 'smtp' in data:
relay_found.smtp = data['smtp']
if 'comment' in data:
relay_found.comment = data['comment']
db.session.add(relay_found)
db.session.commit()
return { 'code': 200, 'message': f'Relayed domain {name} has been updated'}, 200
@relay.doc('delete_relay')
@relay.response(200, 'Success', response_fields)
@relay.response(400, 'Input validation exception', response_fields)
@relay.response(404, 'Relay not found', response_fields)
@relay.doc(security='Bearer')
@common.api_token_authorization
def delete(self, name):
""" Delete relay """
if not validators.domain(name):
return { 'code': 400, 'message': f'Relayed domain {name} is not a valid domain'}, 400
relay_found = models.Relay.query.filter_by(name=name).first()
if relay_found is None:
return { 'code': 404, 'message': f'Relayed domain {name} cannot be found'}, 404
db.session.delete(relay_found)
db.session.commit()
return { 'code': 200, 'message': f'Relayed domain {name} has been deleted'}, 200

@ -0,0 +1,262 @@
from flask_restx import Resource, fields, marshal
import validators, datetime
from . import api, response_fields
from .. import common
from ... import models
db = models.db
user = api.namespace('user', description='User operations')
user_fields_get = api.model('UserGet', {
'email': fields.String(description='The email address of the user', example='John.Doe@example.com', attribute='_email'),
'password': fields.String(description="Hash of the user's password; Example='$bcrypt-sha256$v=2,t=2b,r=12$fmsAdJbYAD1gGQIE5nfJq.$zLkQUEs2XZfTpAEpcix/1k5UTNPm0jO'"),
'comment': fields.String(description='A description for the user. This description is shown on the Users page', example='my comment'),
'quota_bytes': fields.Integer(description='The maximum quota for the users email box in bytes', example='1000000000'),
'global_admin': fields.Boolean(description='Make the user a global administrator'),
'enabled': fields.Boolean(description='Enable the user. When an user is disabled, the user is unable to login to the Admin GUI or webmail or access his email via IMAP/POP3 or send mail'),
'enable_imap': fields.Boolean(description='Allow email retrieval via IMAP'),
'enable_pop': fields.Boolean(description='Allow email retrieval via POP3'),
'allow_spoofing': fields.Boolean(description='Allow the user to spoof the sender (send email as anyone)'),
'forward_enabled': fields.Boolean(description='Enable auto forwarding'),
'forward_destination': fields.List(fields.String(description='Email address to forward emails to'), example='Other@example.com'),
'forward_keep': fields.Boolean(description='Keep a copy of the forwarded email in the inbox'),
'reply_enabled': fields.Boolean(description='Enable automatic replies. This is also known as out of office (ooo) or out of facility (oof) replies'),
'reply_subject': fields.String(description='Optional subject for the automatic reply', example='Out of office'),
'reply_body': fields.String(description='The body of the automatic reply email', example='Hello, I am out of office. I will respond when I am back.'),
'reply_startdate': fields.Date(description='Start date for automatic replies in YYYY-MM-DD format.', example='2022-02-10'),
'reply_enddate': fields.Date(description='End date for automatic replies in YYYY-MM-DD format.', example='2022-02-22'),
'displayed_name': fields.String(description='The display name of the user within the Admin GUI', example='John Doe'),
'spam_enabled': fields.Boolean(description='Enable the spam filter'),
'spam_mark_as_read': fields.Boolean(description='Enable marking spam mails as read'),
'spam_threshold': fields.Integer(description='The user defined spam filter tolerance', example='80'),
})
user_fields_post = api.model('UserCreate', {
'email': fields.String(description='The email address of the user', example='John.Doe@example.com', attribute='_email', required=True),
'raw_password': fields.String(description='The raw (plain text) password of the user. Mailu will hash the password using BCRYPT-SHA256', example='secret', required=True),
'comment': fields.String(description='A description for the user. This description is shown on the Users page', example='my comment'),
'quota_bytes': fields.Integer(description='The maximum quota for the users email box in bytes', example='1000000000'),
'global_admin': fields.Boolean(description='Make the user a global administrator'),
'enabled': fields.Boolean(description='Enable the user. When an user is disabled, the user is unable to login to the Admin GUI or webmail or access his email via IMAP/POP3 or send mail'),
'enable_imap': fields.Boolean(description='Allow email retrieval via IMAP'),
'enable_pop': fields.Boolean(description='Allow email retrieval via POP3'),
'allow_spoofing': fields.Boolean(description='Allow the user to spoof the sender (send email as anyone)'),
'forward_enabled': fields.Boolean(description='Enable auto forwarding'),
'forward_destination': fields.List(fields.String(description='Email address to forward emails to'), example='Other@example.com'),
'forward_keep': fields.Boolean(description='Keep a copy of the forwarded email in the inbox'),
'reply_enabled': fields.Boolean(description='Enable automatic replies. This is also known as out of office (ooo) or out of facility (oof) replies'),
'reply_subject': fields.String(description='Optional subject for the automatic reply', example='Out of office'),
'reply_body': fields.String(description='The body of the automatic reply email', example='Hello, I am out of office. I will respond when I am back.'),
'reply_startdate': fields.Date(description='Start date for automatic replies in YYYY-MM-DD format.', example='2022-02-10'),
'reply_enddate': fields.Date(description='End date for automatic replies in YYYY-MM-DD format.', example='2022-02-22'),
'displayed_name': fields.String(description='The display name of the user within the Admin GUI', example='John Doe'),
'spam_enabled': fields.Boolean(description='Enable the spam filter'),
'spam_mark_as_read': fields.Boolean(description='Enable marking spam mails as read'),
'spam_threshold': fields.Integer(description='The user defined spam filter tolerance', example='80'),
})
user_fields_put = api.model('UserUpdate', {
'raw_password': fields.String(description='The raw (plain text) password of the user. Mailu will hash the password using BCRYPT-SHA256', example='secret'),
'comment': fields.String(description='A description for the user. This description is shown on the Users page', example='my comment'),
'quota_bytes': fields.Integer(description='The maximum quota for the users email box in bytes', example='1000000000'),
'global_admin': fields.Boolean(description='Make the user a global administrator'),
'enabled': fields.Boolean(description='Enable the user. When an user is disabled, the user is unable to login to the Admin GUI or webmail or access his email via IMAP/POP3 or send mail'),
'enable_imap': fields.Boolean(description='Allow email retrieval via IMAP'),
'enable_pop': fields.Boolean(description='Allow email retrieval via POP3'),
'allow_spoofing': fields.Boolean(description='Allow the user to spoof the sender (send email as anyone)'),
'forward_enabled': fields.Boolean(description='Enable auto forwarding'),
'forward_destination': fields.List(fields.String(description='Email address to forward emails to'), example='Other@example.com'),
'forward_keep': fields.Boolean(description='Keep a copy of the forwarded email in the inbox'),
'reply_enabled': fields.Boolean(description='Enable automatic replies. This is also known as out of office (ooo) or out of facility (oof) replies'),
'reply_subject': fields.String(description='Optional subject for the automatic reply', example='Out of office'),
'reply_body': fields.String(description='The body of the automatic reply email', example='Hello, I am out of office. I will respond when I am back.'),
'reply_startdate': fields.Date(description='Start date for automatic replies in YYYY-MM-DD format.', example='2022-02-10'),
'reply_enddate': fields.Date(description='End date for automatic replies in YYYY-MM-DD format.', example='2022-02-22'),
'displayed_name': fields.String(description='The display name of the user within the Admin GUI', example='John Doe'),
'spam_enabled': fields.Boolean(description='Enable the spam filter'),
'spam_mark_as_read': fields.Boolean(description='Enable marking spam mails as read'),
'spam_threshold': fields.Integer(description='The user defined spam filter tolerance', example='80'),
})
@user.route('')
class Users(Resource):
@user.doc('list_users')
@user.marshal_with(user_fields_get, as_list=True, skip_none=True, mask=None)
@user.doc(security='Bearer')
@common.api_token_authorization
def get(self):
"List users"
return models.User.query.all()
@user.doc('create_user')
@user.expect(user_fields_post)
@user.response(200, 'Success', response_fields)
@user.response(400, 'Input validation exception')
@user.response(409, 'Duplicate user', response_fields)
@user.doc(security='Bearer')
@common.api_token_authorization
def post(self):
""" Create user """
data = api.payload
if not validators.email(data['email']):
return { 'code': 400, 'message': f'Provided email address {data["email"]} is not a valid email address'}, 400
localpart, domain_name = data['email'].lower().rsplit('@', 1)
domain_found = models.Domain.query.get(domain_name)
if not domain_found:
return { 'code': 404, 'message': f'Domain {domain_name} does not exist'}, 404
user_new = models.User(email=data['email'])
if 'raw_password' in data:
user_new.set_password(data['raw_password'])
if 'comment' in data:
user_new.comment = data['comment']
if 'quota_bytes' in data:
user_new.quota_bytes = data['quota_bytes']
if 'global_admin' in data:
user_new.global_admin = data['global_admin']
if 'enabled' in data:
user_new.enabled = data['enabled']
if 'enable_imap' in data:
user_new.enable_imap = data['enable_imap']
if 'enable_pop' in data:
user_new.enable_pop = data['enable_pop']
if 'allow_spoofing' in data:
user_new.allow_spoofing = data['allow_spoofing']
if 'forward_enabled' in data:
user_new.forward_enabled = data['forward_enabled']
if 'forward_destination' in data:
user_new.forward_destination = data['forward_destination']
if 'forward_keep' in data:
user_new.forward_keep = data['forward_keep']
if 'reply_enabled' in data:
user_new.reply_enabled = data['reply_enabled']
if 'reply_subject' in data:
user_new.reply_subject = data['reply_subject']
if 'reply_body' in data:
user_new.reply_body = data['reply_body']
if 'reply_startdate' in data:
year, month, day = data['reply_startdate'].split('-')
date = datetime.datetime(int(year), int(month), int(day))
user_new.reply_startdate = date
if 'reply_enddate' in data:
year, month, day = data['reply_enddate'].split('-')
date = datetime.datetime(int(year), int(month), int(day))
user_new.reply_enddate = date
if 'displayed_name' in data:
user_new.displayed_name = data['displayed_name']
if 'spam_enabled' in data:
user_new.spam_enabled = data['spam_enabled']
if 'spam_mark_as_read' in data:
user_new.spam_mark_as_read = data['spam_mark_as_read']
if 'spam_threshold' in data:
user_new.spam_threshold = data['spam_threshold']
db.session.add(user_new)
db.session.commit()
return {'code': 200,'message': f'User {data["email"]} has been created'}, 200
@user.route('/<string:email>')
class User(Resource):
@user.doc('find_user')
@user.response(400, 'Input validation exception', response_fields)
@user.response(404, 'User not found', response_fields)
@user.doc(security='Bearer')
@common.api_token_authorization
def get(self, email):
""" Find user """
if not validators.email(email):
return { 'code': 400, 'message': f'Provided email address {email} is not a valid email address'}, 400
email_found = models.User.query.filter_by(email=email).first()
if email_found is None:
return { 'code': 404, 'message': f'User {email} cannot be found'}, 404
return marshal(email_found, user_fields_get), 200
@user.doc('update_user')
@user.expect(user_fields_put)
@user.response(200, 'Success', response_fields)
@user.response(400, 'Input validation exception', response_fields)
@user.response(404, 'User not found', response_fields)
@user.response(409, 'Duplicate user', response_fields)
@user.doc(security='Bearer')
@common.api_token_authorization
def patch(self, email):
""" Update user """
data = api.payload
if not validators.email(email):
return { 'code': 400, 'message': f'Provided email address {data["email"]} is not a valid email address'}, 400
user_found = models.User.query.get(email)
if not user_found:
return {'code': 404, 'message': f'User {email} cannot be found'}, 404
if 'raw_password' in data:
user_found.set_password(data['raw_password'])
if 'comment' in data:
user_found.comment = data['comment']
if 'quota_bytes' in data:
user_found.quota_bytes = data['quota_bytes']
if 'global_admin' in data:
user_found.global_admin = data['global_admin']
if 'enabled' in data:
user_found.enabled = data['enabled']
if 'enable_imap' in data:
user_found.enable_imap = data['enable_imap']
if 'enable_pop' in data:
user_found.enable_pop = data['enable_pop']
if 'allow_spoofing' in data:
user_found.allow_spoofing = data['allow_spoofing']
if 'forward_enabled' in data:
user_found.forward_enabled = data['forward_enabled']
if 'forward_destination' in data:
user_found.forward_destination = data['forward_destination']
if 'forward_keep' in data:
user_found.forward_keep = data['forward_keep']
if 'reply_enabled' in data:
user_found.reply_enabled = data['reply_enabled']
if 'reply_subject' in data:
user_found.reply_subject = data['reply_subject']
if 'reply_body' in data:
user_found.reply_body = data['reply_body']
if 'reply_startdate' in data:
year, month, day = data['reply_startdate'].split('-')
date = datetime.datetime(int(year), int(month), int(day))
user_found.reply_startdate = date
if 'reply_enddate' in data:
year, month, day = data['reply_enddate'].split('-')
date = datetime.datetime(int(year), int(month), int(day))
user_found.reply_enddate = date
if 'displayed_name' in data:
user_found.displayed_name = data['displayed_name']
if 'spam_enabled' in data:
user_found.spam_enabled = data['spam_enabled']
if 'spam_mark_as_read' in data:
user_found.spam_mark_as_read = data['spam_mark_as_read']
if 'spam_threshold' in data:
user_found.spam_threshold = data['spam_threshold']
db.session.add(user_found)
db.session.commit()
return {'code': 200,'message': f'User {email} has been updated'}, 200
@user.doc('delete_user')
@user.response(200, 'Success', response_fields)
@user.response(400, 'Input validation exception', response_fields)
@user.response(404, 'User not found', response_fields)
@user.doc(security='Bearer')
@common.api_token_authorization
def delete(self, email):
""" Delete user """
if not validators.email(email):
return { 'code': 400, 'message': f'Provided email address {email} is not a valid email address'}, 400
email_found = models.User.query.filter_by(email=email).first()
if email_found is None:
return { 'code': 404, 'message': f'User {email} cannot be found'}, 404
db.session.delete(email_found)
db.session.commit()
return { 'code': 200, 'message': f'User {email} has been deleted'}, 200

@ -17,7 +17,7 @@ DEFAULT_CONFIG = {
'DOMAIN_REGISTRATION': False,
'TEMPLATES_AUTO_RELOAD': True,
'MEMORY_SESSIONS': False,
'FETCHMAIL_ENABLED': False,
'FETCHMAIL_ENABLED': True,
# Database settings
'DB_FLAVOR': None,
'DB_USER': 'mailu',
@ -70,6 +70,9 @@ DEFAULT_CONFIG = {
'LOGO_URL': None,
'LOGO_BACKGROUND': None,
# Advanced settings
'API': False,
'WEB_API': '/api',
'API_TOKEN': None,
'LOG_LEVEL': 'WARNING',
'SESSION_KEY_BITS': 128,
'SESSION_TIMEOUT': 3600,
@ -83,7 +86,7 @@ DEFAULT_CONFIG = {
'PROXY_AUTH_HEADER': 'X-Auth-Email',
'PROXY_AUTH_CREATE': False,
'SUBNET': '192.168.203.0/24',
'SUBNET6': None
'SUBNET6': None,
}
class ConfigManager:
@ -93,7 +96,7 @@ class ConfigManager:
DB_TEMPLATES = {
'sqlite': 'sqlite:////{SQLITE_DATABASE_FILE}',
'postgresql': 'postgresql://{DB_USER}:{DB_PW}@{DB_HOST}/{DB_NAME}',
'mysql': 'mysql+mysqlconnector://{DB_USER}:{DB_PW}@{DB_HOST}/{DB_NAME}'
'mysql': 'mysql+mysqlconnector://{DB_USER}:{DB_PW}@{DB_HOST}/{DB_NAME}',
}
def __init__(self):
@ -157,4 +160,3 @@ class ConfigManager:
# update the app config
app.config.update(self.config)

@ -17,7 +17,7 @@ def dovecot_passdb_dict(user_email):
return flask.jsonify({
"password": None,
"nopassword": "Y",
"allow_nets": ",".join(allow_nets)
"allow_real_nets": ",".join(allow_nets)
})
@internal.route("/dovecot/userdb/")

@ -75,7 +75,7 @@ class CommaSeparatedList(db.TypeDecorator):
""" Stores a list as a comma-separated string, compatible with Postfix.
"""
impl = db.String
impl = db.String(255)
cache_ok = True
python_type = list
@ -96,7 +96,7 @@ class JSONEncoded(db.TypeDecorator):
""" Represents an immutable structure as a json-encoded string.
"""
impl = db.String
impl = db.String(255)
cache_ok = True
python_type = str
@ -421,7 +421,7 @@ class Email(object):
""" send an email to the address """
try:
f_addr = f'{app.config["POSTMASTER"]}@{idna.encode(app.config["DOMAIN"]).decode("ascii")}'
with smtplib.LMTP(ip=app.config['IMAP_ADDRESS'], port=2525) as lmtp:
with smtplib.LMTP(host=app.config['IMAP_ADDRESS'], port=2525) as lmtp:
to_address = f'{self.localpart}@{idna.encode(self.domain_name).decode("ascii")}'
msg = text.MIMEText(body)
msg['Subject'] = subject

@ -5,6 +5,7 @@ from copy import deepcopy
from collections import Counter
from datetime import timezone
import inspect
import json
import logging
import yaml
@ -669,20 +670,15 @@ class Storage:
context = {}
def _bind(self, key, bind):
if bind is True:
return (self.__class__, key)
if isinstance(bind, str):
return (get_schema(self.recall(bind).__class__), key)
return (bind, key)
def store(self, key, value, bind=None):
def store(self, key, value):
""" store value under key """
self.context.setdefault('_track', {})[self._bind(key, bind)]= value
key = f'{self.__class__.__name__}.{key}'
self.context.setdefault('_track', {})[key] = value
def recall(self, key, bind=None):
def recall(self, key):
""" recall value from key """
return self.context['_track'][self._bind(key, bind)]
key = f'{self.__class__.__name__}.{key}'
return self.context['_track'][key]
class BaseOpts(SQLAlchemyAutoSchemaOpts):
""" Option class with sqla session
@ -790,10 +786,16 @@ class BaseSchema(ma.SQLAlchemyAutoSchema, Storage):
for key, value in data.items()
}
def _call_and_store(self, *args, **kwargs):
""" track current parent field for pruning """
self.store('field', kwargs['field_name'], True)
return super()._call_and_store(*args, **kwargs)
def get_parent(self):
""" helper to determine parent of current object """
for x in inspect.stack():
loc = x[0].f_locals
if 'ret_d' in loc:
if isinstance(loc['self'], MailuSchema):
return self.context.get('config'), loc['attr_name']
else:
return loc['self'].get_instance(loc['ret_d']), loc['attr_name']
return None, None
# this is only needed to work around the declared attr "email" primary key in model
def get_instance(self, data):
@ -803,9 +805,13 @@ class BaseSchema(ma.SQLAlchemyAutoSchema, Storage):
if keys := getattr(self.Meta, 'primary_keys', None):
filters = {key: data.get(key) for key in keys}
if None not in filters.values():
res= self.session.query(self.opts.model).filter_by(**filters).first()
try:
res = self.session.query(self.opts.model).filter_by(**filters).first()
except sqlalchemy.exc.StatementError as exc:
raise ValidationError(f'Invalid {keys[0]}: {data.get(keys[0])!r}', data.get(keys[0])) from exc
else:
return res
res= super().get_instance(data)
res = super().get_instance(data)
return res
@pre_load(pass_many=True)
@ -829,6 +835,10 @@ class BaseSchema(ma.SQLAlchemyAutoSchema, Storage):
want_prune = []
def patch(count, data):
# we only process objects here
if type(data) is not dict:
raise ValidationError(f'Invalid item. {self.Meta.model.__tablename__.title()} needs to be an object.', f'{data!r}')
# don't allow __delete__ coming from input
if '__delete__' in data:
raise ValidationError('Unknown field.', f'{count}.__delete__')
@ -882,10 +892,10 @@ class BaseSchema(ma.SQLAlchemyAutoSchema, Storage):
]
# remember if prune was requested for _prune_items@post_load
self.store('prune', bool(want_prune), True)
self.store('prune', bool(want_prune))
# remember original items to stabilize password-changes in _add_instance@post_load
self.store('original', items, True)
self.store('original', items)
return items
@ -909,23 +919,18 @@ class BaseSchema(ma.SQLAlchemyAutoSchema, Storage):
# stabilize import of auto-increment primary keys (not required),
# by matching import data to existing items and setting primary key
if not self._primary in data:
parent = self.recall('parent')
parent, field = self.get_parent()
if parent is not None:
for item in getattr(parent, self.recall('field', 'parent')):
for item in getattr(parent, field):
existing = self.dump(item, many=False)
this = existing.pop(self._primary)
if data == existing:
instance = item
self.instance = item
data[self._primary] = this
break
# try to load instance
instance = self.instance or self.get_instance(data)
# remember instance as parent for pruning siblings
if not self.Meta.sibling and self.context.get('update'):
self.store('parent', instance)
if instance is None:
if '__delete__' in data:
@ -1001,7 +1006,7 @@ class BaseSchema(ma.SQLAlchemyAutoSchema, Storage):
return items
# get prune flag from _patch_many@pre_load
want_prune = self.recall('prune', True)
want_prune = self.recall('prune')
# prune: determine if existing items in db need to be added or marked for deletion
add_items = False
@ -1018,16 +1023,17 @@ class BaseSchema(ma.SQLAlchemyAutoSchema, Storage):
del_items = True
if add_items or del_items:
parent = self.recall('parent')
parent, field = self.get_parent()
if parent is not None:
existing = {item[self._primary] for item in items if self._primary in item}
for item in getattr(parent, self.recall('field', 'parent')):
for item in getattr(parent, field):
key = getattr(item, self._primary)
if key not in existing:
if add_items:
items.append({self._primary: key})
else:
items.append({self._primary: key, '__delete__': '?'})
if self.context.get('update'):
self.opts.sqla_session.delete(self.instance or self.get_instance({self._primary: key}))
return items
@ -1048,7 +1054,7 @@ class BaseSchema(ma.SQLAlchemyAutoSchema, Storage):
# did we hash a new plaintext password?
original = None
pkey = getattr(item, self._primary)
for data in self.recall('original', True):
for data in self.recall('original'):
if 'hash_password' in data and data.get(self._primary) == pkey:
original = data['password']
break
@ -1244,12 +1250,6 @@ class MailuSchema(Schema, Storage):
if field in fieldlist:
fieldlist[field] = fieldlist.pop(field)
def _call_and_store(self, *args, **kwargs):
""" track current parent and field for pruning """
self.store('field', kwargs['field_name'], True)
self.store('parent', self.context.get('config'))
return super()._call_and_store(*args, **kwargs)
@pre_load
def _clear_config(self, data, many, **kwargs): # pylint: disable=unused-argument
""" create config object in context if missing

@ -36,7 +36,9 @@
<td>
<a href="{{ url_for('.user_settings', user_email=user.email) }}" title="{% trans %}Settings{% endtrans %}"><i class="fa fa-wrench"></i></a>&nbsp;
<a href="{{ url_for('.user_reply', user_email=user.email) }}" title="{% trans %}Auto-reply{% endtrans %}"><i class="fa fa-plane"></i></a>&nbsp;
{%- if config["FETCHMAIL_ENABLED"] -%}
<a href="{{ url_for('.fetch_list', user_email=user.email) }}" title="{% trans %}Fetched accounts{% endtrans %}"><i class="fa fa-download"></i></a>&nbsp;
{%- endif -%}
</td>
<td>{{ user }}</td>
<td data-sort="{{ user.allow_spoofing*4 + user.enable_imap*2 + user.enable_pop }}">

@ -11,6 +11,7 @@ DEV_LISTEN="${DEV_LISTEN:-127.0.0.1:8080}"
[[ "${DEV_LISTEN}" == *:* ]] || DEV_LISTEN="127.0.0.1:${DEV_LISTEN}"
DEV_ADMIN="${DEV_ADMIN:-admin@example.com}"
DEV_PASSWORD="${DEV_PASSWORD:-letmein}"
DEV_ARGS=( "$@" )
### MAIN
@ -90,7 +91,8 @@ EOF
# build
chmod -R u+rwX,go+rX .
"${docker}" build --tag "${DEV_NAME}:latest" .
echo Running: "${docker/*\/}" build --tag "${DEV_NAME}:latest" "${DEV_ARGS[@]}" .
"${docker}" build --tag "${DEV_NAME}:latest" "${DEV_ARGS[@]}" .
# gather volumes to map into container
volumes=()
@ -110,6 +112,7 @@ done
cat <<EOF
=============================================================================
The "${DEV_NAME}" container was built using this configuration:
DEV_NAME="${DEV_NAME}"
@ -118,19 +121,34 @@ DEV_PROFILER="${DEV_PROFILER}"
DEV_LISTEN="${DEV_LISTEN}"
DEV_ADMIN="${DEV_ADMIN}"
DEV_PASSWORD="${DEV_PASSWORD}"
=============================================================================
DEV_ARGS=( ${DEV_ARGS[*]} )
=============================================================================
You can start the container later using this commandline:
You can start the container later using this command:
${docker/*\/} run --rm -it --name "${DEV_NAME}" --publish ${DEV_LISTEN}:8080$(printf " %q" "${volumes[@]}") "${DEV_NAME}"
=============================================================================
=============================================================================
Enter the running container using this command:
${docker/*\/} exec -it "${DEV_NAME}" /bin/bash
=============================================================================
To update requirements-prod.txt you can build (and test) using:
${docker/*\/} build --tag "${DEV_NAME}:latest" --build-arg MAILU_DEPS=dev .
And then fetch the new dependencies with:
${docker/*\/} exec "${DEV_NAME}" pip freeze >$(realpath "${base}")/requirements-new.txt
=============================================================================
The Mailu UI can be found here: http://${DEV_LISTEN}/sso/login
EOF
[[ -z "${DEV_DB}" ]] && echo "You can log in with user ${DEV_ADMIN} and password ${DEV_PASSWORD}"
cat <<EOF
=============================================================================
Starting mailu dev environment...

@ -13,27 +13,9 @@ RUN set -euxo pipefail \
; addgroup -Sg ${MAILU_GID} mailu \
; adduser -Sg ${MAILU_UID} -G mailu -h /app -g "mailu app" -s /bin/bash mailu \
; apk add --no-cache bash ca-certificates curl python3 tzdata libcap \
; machine="$(uname -m)" \
; ! [[ "${machine}" == x86_64 ]] \
; ! [[ "$(uname -m)" == x86_64 ]] \
|| apk add --no-cache --repository=http://dl-cdn.alpinelinux.org/alpine/edge/testing hardened-malloc==11-r0
ENV \
LD_PRELOAD="/usr/lib/libhardened_malloc.so" \
CXXFLAGS="-g -O2 -fdebug-prefix-map=/app=. -fstack-protector-strong -Wformat -Werror=format-security -fstack-clash-protection -fexceptions" \
CFLAGS="-g -O2 -fdebug-prefix-map=/app=. -fstack-protector-strong -Wformat -Werror=format-security -fstack-clash-protection -fexceptions" \
CPPFLAGS="-Wdate-time -D_FORTIFY_SOURCE=2" \
LDFLAGS="-Wl,-z,noexecstack -Wl,-z,relro -Wl,-z,now" \
ADMIN_ADDRESS="admin" \
FRONT_ADDRESS="front" \
SMTP_ADDRESS="smtp" \
IMAP_ADDRESS="imap" \
OLETOOLS_ADDRESS="oletools" \
REDIS_ADDRESS="redis" \
ANTIVIRUS_ADDRESS="antivirus" \
ANTISPAM_ADDRESS="antispam" \
WEBMAIL_ADDRESS="webmail" \
WEBDAV_ADDRESS="webdav"
WORKDIR /app
CMD /bin/bash
@ -43,6 +25,7 @@ CMD /bin/bash
FROM system as build
ARG MAILU_DEPS=prod
ARG SNUFFLEUPAGUS_VERSION=0.9.0
ENV VIRTUAL_ENV=/app/venv
@ -55,13 +38,16 @@ RUN set -euxo pipefail \
; apk del -r py3-pip \
; rm -f /tmp/*.pem
ENV PATH="${VIRTUAL_ENV}/bin:${PATH}"
COPY requirements-${MAILU_DEPS}.txt ./
COPY libs/ libs/
ARG SNUFFLEUPAGUS_VERSION=0.8.3
ENV SNUFFLEUPAGUS_URL https://github.com/jvoisin/snuffleupagus/archive/refs/tags/v$SNUFFLEUPAGUS_VERSION.tar.gz
ENV \
PATH="${VIRTUAL_ENV}/bin:${PATH}" \
CXXFLAGS="-g -O2 -fdebug-prefix-map=/app=. -fstack-protector-strong -Wformat -Werror=format-security -fstack-clash-protection -fexceptions" \
CFLAGS="-g -O2 -fdebug-prefix-map=/app=. -fstack-protector-strong -Wformat -Werror=format-security -fstack-clash-protection -fexceptions" \
CPPFLAGS="-Wdate-time -D_FORTIFY_SOURCE=2" \
LDFLAGS="-Wl,-z,noexecstack -Wl,-z,relro -Wl,-z,now" \
SNUFFLEUPAGUS_URL="https://github.com/jvoisin/snuffleupagus/archive/refs/tags/v${SNUFFLEUPAGUS_VERSION}.tar.gz"
RUN set -euxo pipefail \
; machine="$(uname -m)" \
@ -74,7 +60,7 @@ RUN set -euxo pipefail \
git clone --bare https://github.com/rust-lang/crates.io-index.git /root/.cargo/registry/index/github.com-1285ae84e5963aae \
; pip install -r requirements-${MAILU_DEPS}.txt \
; curl -sL ${SNUFFLEUPAGUS_URL} | tar xz \
; cd snuffleupagus-$SNUFFLEUPAGUS_VERSION \
; cd snuffleupagus-${SNUFFLEUPAGUS_VERSION} \
; rm -rf src/tests/*php7*/ src/tests/*session*/ src/tests/broken_configuration/ src/tests/*cookie* src/tests/upload_validation/ \
; apk add --virtual .build-deps php81-dev php81-cgi php81-simplexml php81-xml pcre-dev build-base php81-pear php81-openssl re2c \
; pecl install vld-beta \
@ -89,5 +75,17 @@ COPY --from=build /app/venv/ /app/venv/
COPY --chown=root:root --from=build /app/snuffleupagus.so /usr/lib/php81/modules/
RUN setcap 'cap_net_bind_service=+ep' /app/venv/bin/gunicorn 'cap_net_bind_service=+ep' /usr/bin/python3.10
ENV VIRTUAL_ENV=/app/venv
ENV PATH="${VIRTUAL_ENV}/bin:${PATH}"
ENV \
VIRTUAL_ENV=/app/venv \
PATH="/app/venv/bin:${PATH}" \
LD_PRELOAD="/usr/lib/libhardened_malloc.so" \
ADMIN_ADDRESS="admin" \
FRONT_ADDRESS="front" \
SMTP_ADDRESS="smtp" \
IMAP_ADDRESS="imap" \
OLETOOLS_ADDRESS="oletools" \
REDIS_ADDRESS="redis" \
ANTIVIRUS_ADDRESS="antivirus" \
ANTISPAM_ADDRESS="antispam" \
WEBMAIL_ADDRESS="webmail" \
WEBDAV_ADDRESS="webdav"

@ -1,3 +1,3 @@
pip==22.3
setuptools==65.5.0
wheel==0.37.1
pip==22.3.1
setuptools==65.6.3
wheel==0.38.4

@ -15,6 +15,7 @@ Flask-DebugToolbar
Flask-Login
flask-marshmallow
Flask-Migrate
Flask-RESTX
Flask-SQLAlchemy<3
Flask-WTF
gunicorn

@ -7,16 +7,18 @@ attrs==22.1.0
Babel==2.11.0
bcrypt==4.0.1
blinker==1.5
certifi==2022.9.24
certifi==2022.12.7
cffi==1.15.1
charset-normalizer==2.1.1
click==8.1.3
colorclass==2.2.2
cryptography==38.0.3
decorator==5.1.1
defusedxml==0.7.1
Deprecated==1.2.13
dnspython==2.2.1
dominate==2.7.0
easygui==0.98.3
email-validator==1.3.0
Flask==2.2.2
Flask-Babel==2.0.0
@ -25,6 +27,7 @@ Flask-DebugToolbar==0.13.1
Flask-Login==0.6.2
flask-marshmallow==0.14.0
Flask-Migrate==3.1.0
Flask-RESTX==1.0.5
Flask-SQLAlchemy==2.5.1
Flask-WTF==1.0.1
frozenlist==1.3.1
@ -40,14 +43,17 @@ Mako==1.2.3
MarkupSafe==2.1.1
marshmallow==3.18.0
marshmallow-sqlalchemy==0.28.1
msoffcrypto-tool==5.0.0
multidict==6.0.2
oletools==0.60.1
mysql-connector-python==8.0.29
olefile==0.46
oletools==0.60.1
packaging==21.3
passlib==1.7.4
pcodedmp==1.2.6
podop @ file:///app/libs/podop
postfix-mta-sts-resolver==1.1.4
protobuf==3.20.1
protobuf==3.20.2
psycopg2-binary==2.9.5
pycares==4.2.2
pycparser==2.21
@ -56,7 +62,6 @@ pyOpenSSL==22.1.0
pyparsing==2.4.7
python-dateutil==2.8.2
python-magic==0.4.27
python-dateutil==2.8.2
pytz==2022.6
PyYAML==6.0
Radicale==3.1.8

@ -11,6 +11,8 @@ default_internal_user = dovecot
default_login_user = mail
default_internal_group = dovecot
haproxy_trusted_networks = {{ SUBNET }} {{ SUBNET6 }}
###############
# Mailboxes
###############
@ -116,6 +118,7 @@ protocol pop3 {
service imap-login {
inet_listener imap {
port = 143
haproxy = yes
}
}
@ -139,10 +142,16 @@ service lmtp {
service managesieve-login {
inet_listener sieve {
port = 4190
haproxy = yes
}
}
protocol sieve {
ssl = no
}
service managesieve {
process_limit = 1024
}
plugin {

@ -17,7 +17,8 @@ ARG VERSION
LABEL version=$VERSION
RUN set -euxo pipefail \
; apk add --no-cache certbot nginx nginx-mod-mail openssl
; apk add --no-cache certbot nginx nginx-mod-http-brotli nginx-mod-stream nginx-mod-mail openssl \
; rm /etc/nginx/conf.d/stream.conf
COPY conf/ /conf/
COPY --from=static /static/ /static/
@ -25,7 +26,8 @@ COPY *.py /
RUN echo $VERSION >/version
EXPOSE 80/tcp 443/tcp 110/tcp 143/tcp 465/tcp 587/tcp 993/tcp 995/tcp 25/tcp 10025/tcp 10143/tcp
EXPOSE 80/tcp 443/tcp 110/tcp 143/tcp 465/tcp 587/tcp 993/tcp 995/tcp 25/tcp
# EXPOSE 10025/tcp 10143/tcp 14190/tcp
HEALTHCHECK --start-period=60s CMD curl -skfLo /dev/null http://localhost/health
VOLUME ["/certs", "/overrides"]

@ -1,9 +1,11 @@
# Basic configuration
user nginx;
worker_processes auto;
pcre_jit on;
error_log /dev/stderr notice;
pid /var/run/nginx.pid;
load_module "modules/ngx_mail_module.so";
load_module "modules/ngx_stream_module.so";
events {
worker_connections 1024;
@ -244,6 +246,13 @@ http {
{% endif %}
{% endif %}
{% if API %}
location ~ {{ WEB_API or '/api' }} {
include /etc/nginx/proxy.conf;
proxy_pass http://$admin;
}
{% endif %}
location /internal {
internal;
@ -275,6 +284,25 @@ http {
include /etc/nginx/conf.d/*.conf;
}
stream {
log_format main '$remote_addr [$time_local] '
'$protocol $status $bytes_sent $bytes_received '
'$session_time "$upstream_addr" '
'"$upstream_bytes_sent" "$upstream_bytes_received" "$upstream_connect_time"';
access_log /dev/stdout main;
# managesieve
server {
listen 14190;
resolver {{ RESOLVER }} valid=30s;
proxy_connect_timeout 1s;
proxy_timeout 1m;
proxy_protocol on;
proxy_pass {{ IMAP_ADDRESS }}:4190;
}
}
mail {
server_name {{ HOSTNAMES.split(",")[0] }};
auth_http http://127.0.0.1:8000/auth/email;
@ -306,6 +334,8 @@ mail {
protocol imap;
smtp_auth plain;
auth_http_header Auth-Port 10143;
# ensure we talk HAPROXY protocol to the backends
proxy_protocol on;
}
# SMTP is always enabled, to avoid losing emails when TLS is failing
@ -340,6 +370,8 @@ mail {
protocol imap;
imap_auth plain;
auth_http_header Auth-Port 143;
# ensure we talk HAPROXY protocol to the backends
proxy_protocol on;
}
server {
@ -351,6 +383,8 @@ mail {
protocol pop3;
pop3_auth plain;
auth_http_header Auth-Port 110;
# ensure we talk HAPROXY protocol to the backends
proxy_protocol on;
}
server {
@ -379,6 +413,8 @@ mail {
protocol imap;
imap_auth plain;
auth_http_header Auth-Port 993;
# ensure we talk HAPROXY protocol to the backends
proxy_protocol on;
}
server {
@ -387,6 +423,8 @@ mail {
protocol pop3;
pop3_auth plain;
auth_http_header Auth-Port 995;
# ensure we talk HAPROXY protocol to the backends
proxy_protocol on;
}
{% endif %}
{% endif %}

@ -14,7 +14,7 @@ RUN set -euxo pipefail \
RUN echo $VERSION >/version
HEALTHCHECK --start-period=60s CMD echo PING|nc -q1 127.0.0.1 11343|grep "PONG"
EXPOSE 11343/tcp
#EXPOSE 11343/tcp
USER nobody:nobody

@ -14,8 +14,8 @@ COPY start.py /
RUN echo $VERSION >/version
EXPOSE 25/tcp 10025/tcp
HEALTHCHECK --start-period=350s CMD echo QUIT|nc localhost 25|grep "220 .* ESMTP Postfix"
#EXPOSE 25/tcp 10025/tcp
HEALTHCHECK --start-period=350s CMD /usr/sbin/postfix status
VOLUME ["/queue"]

@ -14,7 +14,7 @@ queue_directory = /queue
message_size_limit = {{ MESSAGE_SIZE_LIMIT }}
# Relayed networks
mynetworks = 127.0.0.1/32 [::1]/128 {{ SUBNET }} {% if RELAYNETS %}{{ RELAYNETS.split(",") | join(' ') }}{% endif %}
mynetworks = 127.0.0.1/32 [::1]/128 {{ SUBNET }} {% if SUBNET6 %}{{ "[{}]/{}".format(*SUBNET6.split("/")) }}{% endif %} {% if RELAYNETS %}{{ RELAYNETS.split(",") | join(" ") }}{% endif %}
# Empty alias list to override the configuration variable and disable NIS
alias_maps =
@ -22,6 +22,9 @@ alias_maps =
# Podop configuration
podop = socketmap:unix:/tmp/podop.socket:
postscreen_upstream_proxy_protocol = haproxy
compatibility_level=3.6
# Only accept virtual emails
mydestination =
@ -37,10 +40,6 @@ smtp_sasl_tls_security_options = noanonymous
# Recipient delimiter for extended addresses
recipient_delimiter = {{ RECIPIENT_DELIMITER }}
# Only the front server is allowed to perform xclient
# In kubernetes and Docker swarm, such address cannot be determined using the hostname. Allow for the whole Mailu subnet instead.
smtpd_authorized_xclient_hosts={{ SUBNET }}
###############
# TLS
###############
@ -122,6 +121,8 @@ smtpd_relay_restrictions =
unverified_recipient_reject_reason = Address lookup failure
smtpd_authorized_xclient_hosts={{ SUBNET }}{% if SUBNET6 %},[{{ SUBNET6 }}]{% endif %}
###############
# Milter
###############

@ -2,10 +2,10 @@
# (yes) (yes) (yes) (never) (100)
# Exposed SMTP service
smtp inet n - n - - smtpd
smtp inet n - n - 1 smtpd
# Internal SMTP service
10025 inet n - n - - smtpd
10025 inet n - n - 1 smtpd
-o smtpd_sasl_auth_enable=yes
-o smtpd_discard_ehlo_keywords=pipelining
-o smtpd_client_restrictions=$check_ratelimit,reject_unlisted_sender,reject_authenticated_sender_login_mismatch,permit
@ -44,6 +44,7 @@ verify unix - - n - 1 verify
flush unix n - n 1000? 0 flush
proxymap unix - - n - - proxymap
smtp unix - - n - - smtp
smtpd pass - - n - - smtpd
relay unix - - n - - smtp
error unix - - n - - error
retry unix - - n - - error
@ -52,4 +53,3 @@ lmtp unix - - n - - lmtp
anvil unix - - n - 1 anvil
scache unix - - n - 1 scache
postlog unix-dgram n - n - 1 postlogd

@ -15,7 +15,7 @@ COPY start.py /
RUN echo $VERSION >/version
EXPOSE 11332/tcp 11334/tcp 11335/tcp
#EXPOSE 11332/tcp 11334/tcp 11335/tcp
HEALTHCHECK --start-period=350s CMD curl -skfLo /dev/null http://localhost:11334/
VOLUME ["/var/lib/rspamd"]

@ -2,3 +2,6 @@ type = "controller";
bind_socket = "*:11334";
password = "mailu";
secure_ip = "{{ SUBNET }}";
{%- if SUBNET6 %}
secure_ip = "{{ SUBNET6 }}";
{%- endif %}

@ -0,0 +1,33 @@
Mailu RESTful API
=================
Mailu offers a RESTful API for changing the Mailu configuration.
Anything that can be configured via the Mailu web administration interface,
can also be configured via the API.
The Mailu API can be configured via the setup utility (setup.mailu.io).
It can also be manually configured via mailu.env:
* ``API`` - Expose the API interface (value: true, false)
* ``WEB_API`` - Path to the API interface
* ``API_TOKEN`` - API token for authentication
For more information refer to the detailed descriptions in the
:ref:`configuration reference <advanced_settings>`.
Swagger.json
------------
The swagger.json file can be retrieved via: https://myserver/api/v1/swagger.json
(WEB_API=/api)
The swagger.json file can be consumed in programs such as Postman for generating all API calls.
In-built SwaggerUI
------------------
The Mailu API comes with an in-built SwaggerUI. It is a web client that allows
anyone to visualize and interact with the Mailu API.
Assuming ``/api`` is configured as value for ``WEB_API``, it
is accessible via the URL: https://myserver/api/

@ -1,7 +1,7 @@
Mailu command line
==================
Managing users and aliases can be done from CLI using commands:
Managing domains, users and aliases can be done from CLI using the commands:
* alias
* alias-delete

@ -110,8 +110,9 @@ to reject emails containing documents with malicious macros. Under the hood, it
.. _fetchmail:
When ``FETCHMAIL_ENABLED`` is set to ``True``, the fetchmail functionality is enabled in the admin interface.
The container itself still needs to be deployed manually. ``FETCHMAIL_ENABLED`` defaults to ``True``.
When ``FETCHMAIL_ENABLED`` is set to ``True``, the fetchmail functionality is enabled and
shown in the admin interface. The container itself still needs to be deployed manually.
``FETCHMAIL_ENABLED`` defaults to ``True``.
The ``FETCHMAIL_DELAY`` is a delay (in seconds) for the fetchmail service to
go and fetch new email if available. Do not use too short delays if you do not
@ -140,13 +141,15 @@ Web settings
- ``WEB_WEBMAIL`` contains the path to the Web email client.
- ``WEB_API`` contains the path to the RESTful API.
- ``WEBROOT_REDIRECT`` redirects all non-found queries to the set path.
An empty ``WEBROOT_REDIRECT`` value disables redirecting and enables
classic behavior of a 404 result when not found.
Alternatively, ``WEBROOT_REDIRECT`` can be set to ``none`` if you
are using an Nginx override for ``location /``.
All three options need a leading slash (``/``) to work.
All four options need a leading slash (``/``) to work.
.. note:: ``WEBROOT_REDIRECT`` has to point to a valid path on the webserver.
This means it cannot point to any services which are not enabled.
@ -157,7 +160,7 @@ in the admin interface, while ``SITENAME`` is a customization option for
every Web interface.
- ``LOGO_BACKGROUND`` sets a custom background colour for the brand logo
in the top left of the main admin interface.
in the topleft of the main admin interface.
For a list of colour codes refer to this page of `w3schools`_.
- ``LOGO_URL`` sets a URL for a custom logo. This logo replaces the Mailu
@ -198,11 +201,15 @@ An example:
Depending on your particular deployment you most probably will want to change the default.
.. _advanced_cfg:
.. _advanced_settings:
Advanced settings
-----------------
The ``API_TOKEN`` (default: None) configures the authentication token.
This token must be passed as request header to the API as authentication token.
This is a mandatory setting for using the RESTful API.
The ``CREDENTIAL_ROUNDS`` (default: 12) setting is the number of rounds used by the
password hashing scheme. The number of rounds can be reduced in case faster
authentication is needed or increased when additional protection is desired.

@ -591,7 +591,7 @@ follow these steps:
maxretry = 10
action = docker-action
The above will block flagged IPs for a week, you can of course change it to you needs.
The above will block flagged IPs for a week, you can of course change it to your needs.
4. In the mailu docker-compose set the logging driver of the Admin container to journald; and set the tag to mailu-admin
@ -625,9 +625,34 @@ The above will block flagged IPs for a week, you can of course change it to you
maxretry = 10
action = docker-action
The above will block flagged IPs for a week, you can of course change it to you needs.
The above will block flagged IPs for a week, you can of course change it to your needs.
7. Add the /etc/fail2ban/action.d/docker-action.conf
7. Add the /etc/fail2ban/filter.d/bad-auth-api.conf
.. code-block:: bash
# Fail2Ban configuration file
[Definition]
failregex = .* Invalid API token provided by <HOST>.
ignoreregex =
journalmatch = CONTAINER_TAG=mailu-admin
8. Add the /etc/fail2ban/jail.d/bad-auth-api.conf
.. code-block:: bash
[bad-auth-api]
enabled = true
backend = systemd
filter = bad-auth-api
bantime = 604800
findtime = 300
maxretry = 10
action = docker-action
The above will block flagged IPs for a week, you can of course change it to your needs.
9. Add the /etc/fail2ban/action.d/docker-action.conf
Option 1: Use plain iptables
@ -665,7 +690,7 @@ Using iptables with ipset might reduce the system load in such attacks significa
[Definition]
actionstart = actionstart = ipset --create f2b-bad-auth iphash
actionstart = ipset --create f2b-bad-auth iphash
iptables -I DOCKER-USER -m set --match-set f2b-bad-auth src -j DROP
actionstop = iptables -D DOCKER-USER -m set --match-set f2b-bad-auth src -j DROP
@ -678,7 +703,7 @@ Using iptables with ipset might reduce the system load in such attacks significa
Using DOCKER-USER chain ensures that the blocked IPs are processed in the correct order with Docker. See more in: https://docs.docker.com/network/iptables/
1. Configure and restart the Fail2Ban service
10. Configure and restart the Fail2Ban service
Make sure Fail2Ban is started after the Docker service by adding a partial override which appends this to the existing configuration.

@ -70,6 +70,7 @@ the version of Mailu that you are running.
webadministration
antispam
cli
api
.. toctree::
:maxdepth: 2

@ -1,5 +1,5 @@
recommonmark
Sphinx
sphinx-autobuild
sphinx-rtd-theme
recommonmark==0.7.1
Sphinx==5.2.0
sphinx-autobuild==2021.3.14
sphinx-rtd-theme==1.0.0
docutils==0.16

@ -162,6 +162,7 @@ You can add a fetched account by clicking on the `Add an account` button on the
* Folders. A comma separated list of folders to fetch from the server. This is optional, by default only the INBOX will be pulled.
Click the submit button to apply settings. With the default polling interval, fetchmail will start polling the email account after ``FETCHMAIL_DELAY``.
Make sure ``FETCHMAIL_ENABLED`` is set to ``true`` in ``mailu.env`` to enable fetching and showing fetchmail in the admin interface.
Authentication tokens

@ -14,7 +14,7 @@ COPY start.py /
RUN echo $VERSION >/version
EXPOSE 3310/tcp
#EXPOSE 3310/tcp
HEALTHCHECK --start-period=350s CMD echo PING|nc localhost 3310|grep "PONG"
VOLUME ["/data"]

@ -10,7 +10,7 @@ COPY radicale.conf /
RUN echo $VERSION >/version
EXPOSE 5232/tcp
#EXPOSE 5232/tcp
HEALTHCHECK CMD curl -f -L http://localhost:5232/ || exit 1
VOLUME ["/data"]

@ -18,7 +18,7 @@ COPY start.py /
RUN echo $VERSION >/version
EXPOSE 53/udp 53/tcp
#EXPOSE 53/udp 53/tcp
HEALTHCHECK CMD dig @127.0.0.1 || exit 1
CMD /start.py

@ -36,6 +36,9 @@ services:
- "{{ bind6 }}:{{ port }}:{{ port }}"
{% endif %}
{% endfor %}
networks:
- default
- webmail
volumes:
- "{{ root }}/certs:/certs"
- "{{ root }}/overrides/nginx:/overrides:ro"
@ -169,12 +172,8 @@ services:
env_file: {{ env }}
volumes:
- "{{ root }}/dav:/data"
{% if resolver_enabled %}
depends_on:
- resolver
dns:
- {{ dns }}
{% endif %}
networks:
- radicale
{% endif %}
{% if fetchmail_enabled %}
@ -204,13 +203,10 @@ services:
volumes:
- "{{ root }}/webmail:/data"
- "{{ root }}/overrides/{{ webmail_type }}:/overrides:ro"
networks:
- webmail
depends_on:
- imap
{% if resolver_enabled %}
- resolver
dns:
- {{ dns }}
{% endif %}
- front
{% endif %}
networks:
@ -226,6 +222,14 @@ networks:
{% if ipv6_enabled %}
- subnet: {{ subnet6 }}
{% endif %}
{% if webdav_enabled %}
radicale:
driver: bridge
{% endif %}
{% if webmail_type != 'none' %}
webmail:
driver: bridge
{% endif %}
{% if oletools_enabled %}
noinet:
driver: bridge

@ -52,6 +52,9 @@ ADMIN={{ admin_enabled or 'false' }}
# Choose which webmail to run if any (values: roundcube, snappymail, none)
WEBMAIL={{ webmail_type }}
# Expose the API interface (value: true, false)
API={{ api_enabled or 'false' }}
# Dav server implementation (value: radicale, none)
WEBDAV={{ webdav_enabled or 'none' }}
@ -82,7 +85,7 @@ RELAYNETS=
# Will relay all outgoing mails if configured
RELAYHOST={{ relayhost }}
# Show fetchmail functionality in admin interface
# Enable fetchmail
FETCHMAIL_ENABLED={{ fetchmail_enabled or 'False' }}
# Fetchmail delay
@ -131,6 +134,9 @@ WEB_WEBMAIL=/
WEB_WEBMAIL={{ webmail_path }}
{% endif %}
# Path to the API interface if enabled
WEB_API={{ api_path }}
# Website name
SITENAME={{ site_name }}
@ -182,6 +188,10 @@ TZ=Etc/UTC
# Default spam threshold used for new users
DEFAULT_SPAM_THRESHOLD=80
# API token required for authenticating to the RESTful API.
# This is a mandatory setting for using the RESTful API.
API_TOKEN={{ api_token }}
###################################
# Database settings
###################################

@ -1,18 +1,28 @@
//API_TOKEN generator
var chars = "0123456789abcdefghijklmnopqrstuvwxyz!@#$%^&*()ABCDEFGHIJKLMNOPQRSTUVWXYZ";
var tokenLength = 12;
var token = "";
for (var i = 0; i <= tokenLength; i++) {
var randomNumber = Math.floor(Math.random() * chars.length);
token += chars.substring(randomNumber, randomNumber +1);
}
$(document).ready(function() {
if ($("#webmail").val() == 'none') {
$("#webmail_path").hide();
$("#webmail_path").attr("value", "");
$("#webmail_path").val("");
} else {
$("#webmail_path").show();
$("#webmail_path").attr("value", "/webmail");
$("#webmail_path").val("/webmail");
}
$("#webmail").click(function() {
if (this.value == 'none') {
$("#webmail_path").hide();
$("#webmail_path").attr("value", "");
$("#webmail_path").val("");
} else {
$("#webmail_path").show();
$("#webmail_path").attr("value", "/webmail");
$("#webmail_path").val("/webmail");
}
});
});
@ -20,15 +30,50 @@ $(document).ready(function() {
$(document).ready(function() {
if ($('#admin').prop('checked')) {
$("#admin_path").show();
$("#admin_path").attr("value", "/admin");
$("#admin_path").val("/admin");
}
$("#admin").change(function() {
if ($(this).is(":checked")) {
$("#admin_path").show();
$("#admin_path").attr("value", "/admin");
$("#admin_path").val("/admin");
} else {
$("#admin_path").hide();
$("#admin_path").attr("value", "");
$("#admin_path").val("");
}
});
});
$(document).ready(function() {
if ($('#api').prop('checked')) {
$("#api_path").show();
$("#api_path").val("/api")
$("#api_token").show();
$("#api_token").prop('required',true);
$("#api_token").val(token);
$("#api_token_label").show();
} else {
$("#api_path").hide();
$("#api_path").val("/api")
$("#api_token").hide();
$("#api_token").prop('required',false);
$("#api_token").val("");
$("#api_token_label").hide();
}
$("#api").change(function() {
if ($(this).is(":checked")) {
$("#api_path").show();
$("#api_path").val("/api");
$("#api_token").show();
$("#api_token").prop('required',true);
$("#api_token").val(token)
$("#api_token_label").show();
} else {
$("#api_path").hide();
$("#api_path").val("/api")
$("#api_token").hide();
$("#api_token").prop('required',false);
$("#api_token").val("");
$("#api_token_label").hide();
}
});
});

@ -87,6 +87,19 @@ manage your email domains, users, etc.</p>
<input class="form-control" type="text" name="admin_path" id="admin_path" style="display: none">
</div>
<p>The API interface is a RESTful API for changing the Mailu configuration.
Anything that can be configured via the Mailu web administration interface,
can also be configured via the RESTful API. For enabling the API, an API token must be configured.
It is not possible to use the API without an API token.</p>
<div class="form-group">
<input type="checkbox" name="api_enabled" value="false" id="api" >
<label>Enable the API (and path to the API)</label>
<input class="form-control" type="text" name="api_path" id="api_path" style="display: none">
<label name="api_token_label" id="api_token_label">API token</label>
<input class="form-control" type="text" name="api_token" id="api_token" style="display: none">
</div>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
<script type="text/javascript" src="{{ url_for('static', filename='render.js') }}"></script>

@ -83,6 +83,9 @@ RELAYNETS=
# Will relay all outgoing mails if configured
RELAYHOST=
# Show fetchmail functionality in admin interface
FETCHMAIL_ENABLED=false
# Fetchmail delay
FETCHMAIL_DELAY=600

@ -83,8 +83,11 @@ RELAYNETS=
# Will relay all outgoing mails if configured
RELAYHOST=
# Show fetchmail functionality in admin interface
FETCHMAIL_ENABLED=true
# Fetchmail delay
FETCHMAIL_DELAY=600
FETCHMAIL_DELAY=15
# Recipient delimiter, character used to delimiter localpart from custom address part
RECIPIENT_DELIMITER=+

@ -83,6 +83,9 @@ RELAYNETS=
# Will relay all outgoing mails if configured
RELAYHOST=
# Show fetchmail functionality in admin interface
FETCHMAIL_ENABLED=false
# Fetchmail delay
FETCHMAIL_DELAY=600

@ -83,6 +83,9 @@ RELAYNETS=
# Will relay all outgoing mails if configured
RELAYHOST=
# Show fetchmail functionality in admin interface
FETCHMAIL_ENABLED=false
# Fetchmail delay
FETCHMAIL_DELAY=600

@ -83,6 +83,9 @@ RELAYNETS=
# Will relay all outgoing mails if configured
RELAYHOST=
# Show fetchmail functionality in admin interface
FETCHMAIL_ENABLED=false
# Fetchmail delay
FETCHMAIL_DELAY=600

@ -0,0 +1 @@
Speak HAPROXY protocol in between front and smtp and front and imap. This ensures the backend is aware of the real client IP and whether TLS was used.

@ -0,0 +1 @@
Don't talk haproxy to postfix yet.

@ -0,0 +1 @@
Isolate radicale and webmail on their own network. This ensures they don't have privileged access to any of the other containers.

@ -0,0 +1 @@
Upgrade to snuffleupagus 0.9.0

@ -0,0 +1,2 @@
Introduction of the Mailu RESTful API. The full Mailu config can be changed via the Mailu API.
See the section Mailu RESTful API & the section configuration reference in the documentation for more information.

@ -86,7 +86,7 @@ COPY php-webmail.conf /etc/php81/php-fpm.d/
COPY nginx-webmail.conf /conf/
COPY snuffleupagus.rules /etc/snuffleupagus.rules.tpl
EXPOSE 80/tcp
# EXPOSE 80/tcp
VOLUME /data
VOLUME /overrides

@ -28,7 +28,7 @@ $config['default_host'] = '{{ FRONT_ADDRESS or "front" }}';
$config['default_port'] = '10143';
// Sieve script management
$config['managesieve_host'] = '{{ IMAP_ADDRESS or "imap" }}';
$config['managesieve_host'] = '{{ FRONT_ADDRESS or "front" }}:14190';
// We access the IMAP and SMTP servers locally with internal names, SSL
// will obviously fail but this sounds better than allowing insecure login

@ -32,8 +32,8 @@
"usePhpMail": false
},
"Sieve": {
"host": "{{ IMAP_ADDRESS }}",
"port": 4190,
"host": "{{ FRONT_ADDRESS }}",
"port": 14190,
"secure": 0,
"shortLogin": false,
"ssl": {

@ -98,7 +98,7 @@ sp.disable_function.function("is_callable").param("value").value("eval").drop();
sp.disable_function.function("is_callable").param("value").value("exec").drop();
sp.disable_function.function("is_callable").param("value").value("system").drop();
sp.disable_function.function("is_callable").param("value").value("shell_exec").drop();
sp.disable_function.function("is_callable").filename_r("^/var/www/snappymail/snappymail/v/\d+\.\d+\.\d+/app/libraries/snappymail/pgp/gpg\.php$").param("value").value("proc_open").allow();
sp.disable_function.function("is_callable").filename_r("^/var/www/snappymail/snappymail/v/[0-9]+\.[0-9]+\.[0-9]+/app/libraries/snappymail/pgp/gpg\.php$").param("value").value("proc_open").allow();
sp.disable_function.function("is_callable").param("value").value("proc_open").drop();
sp.disable_function.function("is_callable").param("value").value("passthru").drop();
@ -130,5 +130,4 @@ sp.cookie.name("roundcube_sessid").samesite("strict");
sp.ini_protection.policy_silent_fail();
# roundcube uses unserialize() everywhere.
# This should do the job until https://github.com/jvoisin/snuffleupagus/issues/438 is implemented.
sp.disable_function.function("unserialize").param("data").value_r("[cCoO]:\d+:[\"{]").drop();
sp.unserialize_noclass.enable();

Loading…
Cancel
Save