mirror of
https://github.com/Mailu/Mailu.git
synced 2024-12-04 10:24:41 +02:00
Introduction of the Mailu RESTful API.
Anything that can be configured in the web administration interface, can also be configured via the Mailu RESTful API. See the section Advanced configuration in the configuration reference for the relevant settings in mailu.env for enabling the API. (API, WEB_API, API_TOKEN).
This commit is contained in:
parent
866ad89dfc
commit
5c9cdfe1de
1
.gitignore
vendored
1
.gitignore
vendored
@ -10,6 +10,7 @@ pip-selfcheck.json
|
||||
/docs/include
|
||||
/docs/_build
|
||||
/.env
|
||||
/.venv
|
||||
/docker-compose.yml
|
||||
/.idea
|
||||
/.vscode
|
||||
|
@ -74,8 +74,8 @@ def create_app_from_config(config):
|
||||
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')
|
||||
if app.config.get('API'):
|
||||
api.register(app)
|
||||
if app.config.get('API_TOKEN'):
|
||||
api.register(app, web_api=app.config.get('WEB_API'))
|
||||
return app
|
||||
|
||||
|
||||
|
@ -1,21 +1,20 @@
|
||||
from flask import redirect, url_for
|
||||
|
||||
# import api version(s)
|
||||
from flask_restx import apidoc
|
||||
from . import v1
|
||||
|
||||
# api
|
||||
ROOT='/api'
|
||||
ACTIVE=v1
|
||||
|
||||
# patch url for swaggerui static assets
|
||||
from flask_restx.apidoc import apidoc
|
||||
apidoc.static_url_path = f'{ROOT}/swaggerui'
|
||||
|
||||
def register(app):
|
||||
def register(app, web_api):
|
||||
|
||||
ACTIVE=v1
|
||||
ROOT=web_api
|
||||
v1.app = app
|
||||
# register api bluprint(s)
|
||||
apidoc.apidoc.url_prefix = f'{ROOT}/v{int(v1.VERSION)}'
|
||||
v1.api_token = app.config['API_TOKEN']
|
||||
app.register_blueprint(v1.blueprint, url_prefix=f'{ROOT}/v{int(v1.VERSION)}')
|
||||
|
||||
|
||||
|
||||
# add redirect to current api version
|
||||
@app.route(f'{ROOT}/')
|
||||
def redir():
|
||||
@ -25,14 +24,4 @@ def register(app):
|
||||
app.config.SWAGGER_UI_DOC_EXPANSION = 'list'
|
||||
app.config.SWAGGER_UI_OPERATION_ID = True
|
||||
app.config.SWAGGER_UI_REQUEST_DURATION = True
|
||||
|
||||
# TODO: remove patch of static assets for debugging
|
||||
import os
|
||||
if 'STATIC_ASSETS' in os.environ:
|
||||
app.blueprints['ui'].static_folder = os.environ['STATIC_ASSETS']
|
||||
|
||||
# TODO: authentication via username + password
|
||||
# TODO: authentication via api token
|
||||
# TODO: api access for all users (via token)
|
||||
# TODO: use permissions from "manager_of"
|
||||
# TODO: switch to marshmallow, as parser is deprecated. use flask_accepts?
|
||||
app.config.RESTX_MASK_SWAGGER = False
|
||||
|
@ -1,4 +1,9 @@
|
||||
from .. import models
|
||||
from .. import models, utils
|
||||
from . import v1
|
||||
from flask import request
|
||||
import flask
|
||||
from functools import wraps
|
||||
from flask_restx import abort
|
||||
|
||||
def fqdn_in_use(*names):
|
||||
for name in names:
|
||||
@ -6,3 +11,19 @@ def fqdn_in_use(*names):
|
||||
if model.query.get(name):
|
||||
return model
|
||||
return None
|
||||
|
||||
""" 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 request.args.get('api_token') != v1.api_token:
|
||||
utils.limiter.rate_limit_ip(client_ip)
|
||||
flask.current_app.logger.warn(f'Invalid API token provided by {client_ip}.')
|
||||
abort(401, 'A valid API token is expected as query string parameter')
|
||||
else:
|
||||
flask.current_app.logger.info(f'Valid API token provided by {client_ip}.')
|
||||
return func(*args, **kwds)
|
||||
return decorated_function
|
||||
|
@ -1,14 +1,28 @@
|
||||
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 = {
|
||||
'apikey': {
|
||||
'type': 'apiKey',
|
||||
'in': 'query',
|
||||
'name': 'api_token'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
api = Api(
|
||||
blueprint, version=f'{VERSION:.1f}',
|
||||
title='Mailu API', default_label='Mailu',
|
||||
validate=True
|
||||
validate=True,
|
||||
authorizations=authorization,
|
||||
security='apikey',
|
||||
doc='/swaggerui/'
|
||||
)
|
||||
|
||||
response_fields = api.model('Response', {
|
||||
@ -25,3 +39,6 @@ error_fields = api.model('Error', {
|
||||
})
|
||||
|
||||
from . import domains
|
||||
from . import alias
|
||||
from . import relay
|
||||
from . import user
|
||||
|
128
core/admin/mailu/api/v1/alias.py
Normal file
128
core/admin/mailu/api/v1/alias.py
Normal file
@ -0,0 +1,128 @@
|
||||
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 = api.model('Alias', {
|
||||
'email': fields.String(description='the alias email address', example='user@example.com', required=True),
|
||||
'comment': fields.String(description='a comment'),
|
||||
'destination': fields.List(fields.String(description='alias email address', example='user@example.com', required=True)),
|
||||
'wildcard': fields.Boolean(description='enable SQL Like wildcard syntax')
|
||||
})
|
||||
|
||||
alias_fields_update = api.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.route('')
|
||||
class Aliases(Resource):
|
||||
@alias.doc('list_alias')
|
||||
@alias.marshal_with(alias_fields, as_list=True, skip_none=True, mask=None)
|
||||
@alias.doc(security='apikey')
|
||||
@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='apikey')
|
||||
@common.api_token_authorization
|
||||
def post(self):
|
||||
""" Create a new alias """
|
||||
data = api.payload
|
||||
|
||||
alias_found = models.Alias.query.filter_by(email = data['email']).all()
|
||||
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='apikey')
|
||||
@common.api_token_authorization
|
||||
def get(self, alias):
|
||||
""" Find alias """
|
||||
alias_found = models.Alias.query.filter_by(email = alias).all()
|
||||
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='apikey')
|
||||
@common.api_token_authorization
|
||||
def put(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:
|
||||
destination_csl = ",".join(data['destination'])
|
||||
alias_found.destination = destination_csl
|
||||
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='apikey')
|
||||
@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='apikey')
|
||||
@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
|
@ -1,6 +1,6 @@
|
||||
from flask_restx import Resource, fields, abort
|
||||
|
||||
from . import api, response_fields, error_fields
|
||||
import validators
|
||||
from flask_restx import Resource, fields, marshal
|
||||
from . import api, response_fields, user
|
||||
from .. import common
|
||||
from ... import models
|
||||
|
||||
@ -10,35 +10,76 @@ dom = api.namespace('domain', description='Domain operations')
|
||||
alt = api.namespace('alternative', description='Alternative operations')
|
||||
|
||||
domain_fields = api.model('Domain', {
|
||||
'name': fields.String(description='FQDN', example='example.com', required=True),
|
||||
'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'),
|
||||
# 'dkim_key': fields.String,
|
||||
'alternatives': fields.List(fields.String(attribute='name', description='FQDN', example='example.com')),
|
||||
'alternatives': fields.List(fields.String(attribute='name', description='FQDN', example='example2.com')),
|
||||
})
|
||||
# TODO - name ist required on creation but immutable on change
|
||||
# TODO - name and alteranatives need to be checked to be a fqdn (regex)
|
||||
|
||||
domain_parser = api.parser()
|
||||
domain_parser.add_argument('max_users', type=int, help='maximum number of users')
|
||||
# TODO ... add more (or use marshmallow)
|
||||
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')),
|
||||
})
|
||||
|
||||
alternative_fields = api.model('Domain', {
|
||||
'name': fields.String(description='alternative FQDN', example='example.com', required=True),
|
||||
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),
|
||||
'dkim_key': fields.String,
|
||||
})
|
||||
# TODO: domain and name are not always required and can't be changed
|
||||
|
||||
|
||||
@dom.route('')
|
||||
class Domains(Resource):
|
||||
|
||||
@dom.doc('list_domain')
|
||||
@dom.marshal_with(domain_fields, as_list=True, skip_none=True, mask=['dkim_key'])
|
||||
@dom.marshal_with(domain_fields_get, as_list=True, skip_none=True, mask=None)
|
||||
@dom.doc(security='apikey')
|
||||
@common.api_token_authorization
|
||||
def get(self):
|
||||
""" List domains """
|
||||
return models.Domain.query.all()
|
||||
@ -46,138 +87,324 @@ class Domains(Resource):
|
||||
@dom.doc('create_domain')
|
||||
@dom.expect(domain_fields)
|
||||
@dom.response(200, 'Success', response_fields)
|
||||
@dom.response(400, 'Input validation exception', error_fields)
|
||||
@dom.response(409, 'Duplicate domain name', error_fields)
|
||||
@dom.response(400, 'Input validation exception', response_fields)
|
||||
@dom.response(409, 'Duplicate domain/alternative name', response_fields)
|
||||
@dom.doc(security='apikey')
|
||||
@common.api_token_authorization
|
||||
def post(self):
|
||||
""" Create a new domain """
|
||||
data = api.payload
|
||||
if common.fqdn_in_use(data['name']):
|
||||
abort(409, f'Duplicate domain name {data["name"]!r}', errors={
|
||||
'name': data['name'],
|
||||
})
|
||||
for item, created in models.Domain.from_dict(data):
|
||||
if not created:
|
||||
abort(409, f'Duplicate domain name {item.name!r}', errors={
|
||||
'alternatives': item.name,
|
||||
})
|
||||
db.session.add(item)
|
||||
db.session.commit()
|
||||
if not validators.domain(data['name']):
|
||||
return { 'code': 400, 'message': f'Domain {data["name"]} is not a valid domain'}, 400
|
||||
|
||||
@dom.route('/<name>')
|
||||
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('get_domain')
|
||||
@dom.doc('find_domain')
|
||||
@dom.response(200, 'Success', domain_fields)
|
||||
@dom.response(404, 'Domain not found')
|
||||
@dom.marshal_with(domain_fields)
|
||||
def get(self, name):
|
||||
@dom.response(404, 'Domain not found', response_fields)
|
||||
@dom.doc(security='apikey')
|
||||
@common.api_token_authorization
|
||||
def get(self, domain):
|
||||
""" Find domain by name """
|
||||
domain = models.Domain.query.get(name)
|
||||
if not domain:
|
||||
abort(404)
|
||||
return 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 marshal(domain_found, domain_fields_get), 200
|
||||
|
||||
@dom.doc('update_domain')
|
||||
@dom.expect(domain_fields)
|
||||
@dom.expect(domain_fields_update)
|
||||
@dom.response(200, 'Success', response_fields)
|
||||
@dom.response(400, 'Input validation exception', error_fields)
|
||||
@dom.response(404, 'Domain not found')
|
||||
def put(self, name):
|
||||
@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='apikey')
|
||||
@common.api_token_authorization
|
||||
def put(self, domain):
|
||||
""" Update an existing domain """
|
||||
domain = models.Domain.query.get(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:
|
||||
abort(404)
|
||||
return { 'code': 404, 'message': f'Domain {data["name"]} does not exist'}, 404
|
||||
data = api.payload
|
||||
data['name'] = name
|
||||
for item, created in models.Domain.from_dict(data):
|
||||
if created is True:
|
||||
db.session.add(item)
|
||||
db.session.commit()
|
||||
|
||||
@dom.doc('modify_domain')
|
||||
@dom.expect(domain_parser)
|
||||
@dom.response(200, 'Success', response_fields)
|
||||
@dom.response(400, 'Input validation exception', error_fields)
|
||||
@dom.response(404, 'Domain not found')
|
||||
def post(self, name=None):
|
||||
""" Updates domain with form data """
|
||||
domain = models.Domain.query.get(name)
|
||||
if not domain:
|
||||
abort(404)
|
||||
data = dict(domain_parser.parse_args())
|
||||
data['name'] = name
|
||||
for item, created in models.Domain.from_dict(data):
|
||||
if created is True:
|
||||
db.session.add(item)
|
||||
# TODO: flush?
|
||||
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(404, 'Domain not found')
|
||||
def delete(self, name=None):
|
||||
@dom.response(400, 'Input validation exception', response_fields)
|
||||
@dom.response(404, 'Domain not found', response_fields)
|
||||
@dom.doc(security='apikey')
|
||||
@common.api_token_authorization
|
||||
def delete(self, domain):
|
||||
""" Delete domain """
|
||||
domain = models.Domain.query.get(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:
|
||||
abort(404)
|
||||
db.session.delete(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='apikey')
|
||||
@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='apikey')
|
||||
@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='apikey')
|
||||
@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='apikey')
|
||||
@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.route('/<name>/alternative')
|
||||
# @alt.route('')
|
||||
# class Alternatives(Resource):
|
||||
@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='apikey')
|
||||
@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
|
||||
|
||||
# @alt.doc('alternatives_list')
|
||||
# @alt.marshal_with(alternative_fields, as_list=True, skip_none=True, mask=['dkim_key'])
|
||||
# def get(self, name=None):
|
||||
# """ List alternatives (of domain) """
|
||||
# if name is None:
|
||||
# return models.Alternative.query.all()
|
||||
# else:
|
||||
# return models.Alternative.query.filter_by(domain_name = name).all()
|
||||
@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='apikey')
|
||||
@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.doc('alternative_create')
|
||||
# @alt.expect(alternative_fields)
|
||||
# @alt.response(200, 'Success', response_fields)
|
||||
# @alt.response(400, 'Input validation exception', error_fields)
|
||||
# @alt.response(404, 'Domain not found')
|
||||
# @alt.response(409, 'Duplicate domain name', error_fields)
|
||||
# def post(self, name=None):
|
||||
# """ Create new alternative (for domain) """
|
||||
# # abort(501)
|
||||
# data = api.payload
|
||||
# if name is not None:
|
||||
# data['name'] = name
|
||||
# domain = models.Domain.query.get(name)
|
||||
# if not domain:
|
||||
# abort(404)
|
||||
# if common.fqdn_in_use(data['name']):
|
||||
# abort(409, f'Duplicate domain name {data["name"]!r}', errors={
|
||||
# 'name': data['name'],
|
||||
# })
|
||||
# for item, created in models.Alternative.from_dict(data):
|
||||
# # TODO: handle creation of domain
|
||||
# if not created:
|
||||
# abort(409, f'Duplicate domain name {item.name!r}', errors={
|
||||
# 'alternatives': item.name,
|
||||
# })
|
||||
# # db.session.add(item)
|
||||
# # db.session.commit()
|
||||
@alt.route('')
|
||||
class Alternatives(Resource):
|
||||
|
||||
# @dom.route('/<name>/alternative/<alt>')
|
||||
# @alt.route('/<name>')
|
||||
# class Alternative(Resource):
|
||||
# def get(self, name, alt=None):
|
||||
# """ Find alternative (of domain) """
|
||||
# abort(501)
|
||||
# def put(self, name, alt=None):
|
||||
# """ Update alternative (of domain) """
|
||||
# abort(501)
|
||||
# def post(self, name, alt=None):
|
||||
# """ Update alternative (of domain) with form data """
|
||||
# abort(501)
|
||||
# def delete(self, name, alt=None):
|
||||
# """ Delete alternative (for domain) """
|
||||
# abort(501)
|
||||
@alt.doc('list_alternative')
|
||||
@alt.marshal_with(alternative_fields, as_list=True, skip_none=True, mask=None)
|
||||
@alt.doc(security='apikey')
|
||||
@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='apikey')
|
||||
@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='apikey')
|
||||
@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='apikey')
|
||||
@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).first
|
||||
if not alternative:
|
||||
return { 'code': 404, 'message': f'Alternative domain {alt} does not exist'}, 404
|
||||
domain = alternative.domain
|
||||
db.session.delete(alternative)
|
||||
db.session.commit()
|
||||
return {'code': 200, 'message': f'Alternative {alt} for domain {domain} has been deleted'}, 200
|
||||
|
119
core/admin/mailu/api/v1/relay.py
Normal file
119
core/admin/mailu/api/v1/relay.py
Normal file
@ -0,0 +1,119 @@
|
||||
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='apikey')
|
||||
@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='apikey')
|
||||
@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
|
||||
|
||||
relay_found = models.Relay.query.filter_by(name=data['name']).all()
|
||||
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='apikey')
|
||||
@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='apikey')
|
||||
@common.api_token_authorization
|
||||
def put(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='apikey')
|
||||
@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
|
255
core/admin/mailu/api/v1/user.py
Normal file
255
core/admin/mailu/api/v1/user.py
Normal file
@ -0,0 +1,255 @@
|
||||
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='PBKDF2-HMAC-SHA256 based password of the user. For more info see passlib.hash.pbkdf2_sha256', example='$pbkdf2-sha256$1$.6UI/S.nXIk8jcbdHx3Fhg$98jZicV16ODfEsEZeYPGHU3kbrUrvUEXOPimVSQDD44'),
|
||||
'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 user’s 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'),
|
||||
'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 PBKDF2-HMAC-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 user’s 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'),
|
||||
'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 PBKDF2-HMAC-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 user’s 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'),
|
||||
'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='apikey')
|
||||
@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='apikey')
|
||||
@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 '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='apikey')
|
||||
@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='apikey')
|
||||
@common.api_token_authorization
|
||||
def put(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.filter_by(email=email).first()
|
||||
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 '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='apikey')
|
||||
@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
|
@ -71,6 +71,9 @@ DEFAULT_CONFIG = {
|
||||
'LOGO_BACKGROUND': None,
|
||||
'API': False,
|
||||
# Advanced settings
|
||||
'API' : 'false',
|
||||
'WEB_API' : '/api',
|
||||
'API_TOKEN': None,
|
||||
'LOG_LEVEL': 'WARNING',
|
||||
'SESSION_KEY_BITS': 128,
|
||||
'SESSION_TIMEOUT': 3600,
|
||||
|
@ -244,6 +244,13 @@ http {
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if API == 'true' %}
|
||||
location ~ {{ WEB_API }} {
|
||||
include /etc/nginx/proxy.conf;
|
||||
proxy_pass http://$admin;
|
||||
}
|
||||
{% endif %}
|
||||
|
||||
location /internal {
|
||||
internal;
|
||||
|
||||
|
29
docs/api.rst
Normal file
29
docs/api.rst
Normal file
@ -0,0 +1,29 @@
|
||||
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 is disabled by default. It can be enabled and configured via
|
||||
the settings:
|
||||
|
||||
* `API`
|
||||
* `WEB_API`
|
||||
* `API_TOKEN`
|
||||
|
||||
For more information see the section :ref:`Advanced configuration <advanced_settings>`
|
||||
in the configuration reference.
|
||||
|
||||
|
||||
Swagger.json
|
||||
------------
|
||||
|
||||
The swagger.json file can be retrieved via: https://myserver/api/v1/swagger.json.
|
||||
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.
|
@ -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
|
||||
|
@ -37,7 +37,7 @@ The ``POSTMASTER`` is the local part of the postmaster email address. It is
|
||||
recommended to setup a generic value and later configure a mail alias for that
|
||||
address.
|
||||
|
||||
The ``WILDCARD_SENDERS`` setting is a comma delimited list of user email addresses
|
||||
The ``WILDCARD_SENDERS`` setting is a comma delimited list of user email addresses
|
||||
that are allowed to send emails from any existing address (spoofing the sender).
|
||||
|
||||
The ``AUTH_RATELIMIT_IP`` (default: 60/hour) holds a security setting for fighting
|
||||
@ -141,9 +141,9 @@ Web settings
|
||||
- ``WEB_WEBMAIL`` contains the path to the Web email client.
|
||||
|
||||
- ``WEBROOT_REDIRECT`` redirects all non-found queries to the set path.
|
||||
An empty ``WEBROOT_REDIRECT`` value disables redirecting and enables
|
||||
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
|
||||
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.
|
||||
@ -156,11 +156,11 @@ Both ``SITENAME`` and ``WEBSITE`` are customization options for the panel menu
|
||||
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.
|
||||
- ``LOGO_BACKGROUND`` sets a custom background colour for the brand logo
|
||||
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
|
||||
- ``LOGO_URL`` sets a URL for a custom logo. This logo replaces the Mailu
|
||||
logo in the topleft of the main admin interface.
|
||||
|
||||
.. _`w3schools`: https://www.w3schools.com/cssref/css_colors.asp
|
||||
@ -184,7 +184,7 @@ To have the account created automatically, you just need to define a few environ
|
||||
- ``ifmissing``: creates a new admin account when the admin account does not exist.
|
||||
- ``update``: creates a new admin account when it does not exist, or update the password of an existing admin account.
|
||||
|
||||
Note: It is recommended to set ``INITIAL_ADMIN_MODE`` to either ``update`` or ``ifmissing``. Leaving it with the
|
||||
Note: It is recommended to set ``INITIAL_ADMIN_MODE`` to either ``update`` or ``ifmissing``. Leaving it with the
|
||||
default value will cause an error when the system is restarted.
|
||||
|
||||
An example:
|
||||
@ -198,23 +198,32 @@ An example:
|
||||
|
||||
Depending on your particular deployment you most probably will want to change the default.
|
||||
|
||||
.. _advanced_cfg:
|
||||
.. _advanced_settings:
|
||||
|
||||
Advanced settings
|
||||
-----------------
|
||||
|
||||
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.
|
||||
Keep in mind that this is a mitigation against offline attacks on password hashes,
|
||||
The ``API`` (default: False) setting controls if the API endpoint is publicly
|
||||
reachable.
|
||||
|
||||
The ``WEB_API`` (default: /api) setting configures the endpoint that the API
|
||||
listens on publicly&interally. The path must always start with a leading slash.
|
||||
|
||||
The ``API_TOKEN`` (default: None) enables the API endpoint. This token must be
|
||||
passed as query parameter with requests to the API as authentication token.
|
||||
|
||||
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.
|
||||
Keep in mind that this is a mitigation against offline attacks on password hashes,
|
||||
aiming to prevent credential stuffing (due to password re-use) on other systems.
|
||||
|
||||
The ``SESSION_COOKIE_SECURE`` (default: True) setting controls the secure flag on
|
||||
the cookies of the administrative interface. It should only be turned off if you
|
||||
The ``SESSION_COOKIE_SECURE`` (default: True) setting controls the secure flag on
|
||||
the cookies of the administrative interface. It should only be turned off if you
|
||||
intend to access it over plain HTTP.
|
||||
|
||||
``SESSION_TIMEOUT`` (default: 3600) is the maximum amount of time in seconds between
|
||||
requests before a session is invalidated. ``PERMANENT_SESSION_LIFETIME`` (default: 108000)
|
||||
``SESSION_TIMEOUT`` (default: 3600) is the maximum amount of time in seconds between
|
||||
requests before a session is invalidated. ``PERMANENT_SESSION_LIFETIME`` (default: 108000)
|
||||
is the maximum amount of time in seconds a session can be kept alive for if it hasn't timed-out.
|
||||
|
||||
The ``LOG_LEVEL`` setting is used by the python start-up scripts as a logging threshold.
|
||||
@ -224,8 +233,8 @@ See the `python docs`_ for more information.
|
||||
|
||||
.. _`python docs`: https://docs.python.org/3.6/library/logging.html#logging-levels
|
||||
|
||||
The ``LETSENCRYPT_SHORTCHAIN`` (default: False) setting controls whether we send the
|
||||
ISRG Root X1 certificate in TLS handshakes. This is required for `android handsets older than 7.1.1`
|
||||
The ``LETSENCRYPT_SHORTCHAIN`` (default: False) setting controls whether we send the
|
||||
ISRG Root X1 certificate in TLS handshakes. This is required for `android handsets older than 7.1.1`
|
||||
but slows down the performance of modern devices.
|
||||
|
||||
.. _`android handsets older than 7.1.1`: https://community.letsencrypt.org/t/production-chain-changes/150739
|
||||
@ -234,11 +243,11 @@ The ``TLS_PERMISSIVE`` (default: true) setting controls whether ciphers and prot
|
||||
|
||||
.. _reverse_proxy_headers:
|
||||
|
||||
The ``REAL_IP_HEADER`` (default: unset) and ``REAL_IP_FROM`` (default: unset) settings
|
||||
controls whether HTTP headers such as ``X-Forwarded-For`` or ``X-Real-IP`` should be trusted.
|
||||
The former should be the name of the HTTP header to extract the client IP address from and the
|
||||
later a comma separated list of IP addresses designating which proxies to trust.
|
||||
If you are using Mailu behind a reverse proxy, you should set both. Setting the former without
|
||||
The ``REAL_IP_HEADER`` (default: unset) and ``REAL_IP_FROM`` (default: unset) settings
|
||||
controls whether HTTP headers such as ``X-Forwarded-For`` or ``X-Real-IP`` should be trusted.
|
||||
The former should be the name of the HTTP header to extract the client IP address from and the
|
||||
later a comma separated list of IP addresses designating which proxies to trust.
|
||||
If you are using Mailu behind a reverse proxy, you should set both. Setting the former without
|
||||
the later introduces a security vulnerability allowing a potential attacker to spoof his source address.
|
||||
|
||||
The ``TZ`` sets the timezone Mailu will use. The timezone naming convention usually uses a ``Region/City`` format. See `TZ database name`_ for a list of valid timezones This defaults to ``Etc/UTC``. Warning: if you are observing different timestamps in your log files you should change your hosts timezone to UTC instead of changing TZ to your local timezone. Using UTC allows easy log correlation with remote MTAs.
|
||||
@ -348,15 +357,15 @@ Mail log settings
|
||||
|
||||
By default, all services log directly to stdout/stderr. Logs can be collected by any docker log processing solution.
|
||||
|
||||
Postfix writes the logs to a syslog server which logs to stdout. This is used to filter
|
||||
out messages from the healthcheck. In some situations, a separate mail log is required
|
||||
(e.g. for legal reasons). The syslog server can be configured to write log files to a volume.
|
||||
Postfix writes the logs to a syslog server which logs to stdout. This is used to filter
|
||||
out messages from the healthcheck. In some situations, a separate mail log is required
|
||||
(e.g. for legal reasons). The syslog server can be configured to write log files to a volume.
|
||||
It can be configured with the following option:
|
||||
|
||||
- ``POSTFIX_LOG_FILE``: The file to log the mail log to. When enabled, the syslog server will also log to stdout.
|
||||
|
||||
When ``POSTFIX_LOG_FILE`` is enabled, the logrotate program will automatically rotate the
|
||||
logs every week and keep 52 logs. To override the logrotate configuration, create the file logrotate.conf
|
||||
When ``POSTFIX_LOG_FILE`` is enabled, the logrotate program will automatically rotate the
|
||||
logs every week and keep 52 logs. To override the logrotate configuration, create the file logrotate.conf
|
||||
with the desired configuration in the :ref:`Postfix overrides folder<override-label>`.
|
||||
|
||||
|
||||
|
79
docs/faq.rst
79
docs/faq.rst
@ -24,7 +24,7 @@ advice in the `Technical issues`_ section of this page.
|
||||
I think I found a bug!
|
||||
``````````````````````
|
||||
|
||||
If you did not manage to solve the issue using this FAQ and there are not any
|
||||
If you did not manage to solve the issue using this FAQ and there are not any
|
||||
`open issues`_ describing the same problem, you can open a
|
||||
`new issue`_ on GitHub.
|
||||
|
||||
@ -64,7 +64,7 @@ We currently maintain a strict work flow:
|
||||
#. We use Github actions for some very basic building and testing;
|
||||
#. The pull request needs to be code-reviewed and tested by at least two members
|
||||
from the contributors team.
|
||||
|
||||
|
||||
Please consider that this project is mostly developed in people their free time.
|
||||
We thank you for your understanding and patience.
|
||||
|
||||
@ -152,7 +152,7 @@ Lets start with quoting everything that's wrong:
|
||||
It was added later and, while it has come a long way, is still not as usable as one would want.
|
||||
Much discussion is still going on as to how IPv6 should be used in a containerized world;
|
||||
See the various GitHub issues linked below:
|
||||
|
||||
|
||||
- Giving each container a publicly routable address means all ports (even unexposed / unpublished ports) are suddenly
|
||||
reachable by everyone, if no additional filtering is done
|
||||
(`docker/docker#21614 <https://github.com/docker/docker/issues/21614>`_)
|
||||
@ -163,14 +163,14 @@ Lets start with quoting everything that's wrong:
|
||||
(which, for now, is enabled by default in Docker)
|
||||
- The userland proxy, however, seems to be on its way out
|
||||
(`docker/docker#14856 <https://github.com/docker/docker/issues/14856>`_) and has various issues, like:
|
||||
|
||||
|
||||
- It can use a lot of RAM (`docker/docker#11185 <https://github.com/docker/docker/issues/11185>`_)
|
||||
- Source IP addresses are rewritten, making it completely unusable for many purposes, e.g. mail servers
|
||||
- Source IP addresses are rewritten, making it completely unusable for many purposes, e.g. mail servers
|
||||
(`docker/docker#17666 <https://github.com/docker/docker/issues/17666>`_),
|
||||
(`docker/libnetwork#1099 <https://github.com/docker/libnetwork/issues/1099>`_).
|
||||
|
||||
|
||||
-- `Robbert Klarenbeek <https://github.com/robbertkl>`_ (docker-ipv6nat author)
|
||||
|
||||
|
||||
Okay, but I still want to use IPv6! Can I just use the installers IPv6 checkbox? **NO, YOU SHOULD NOT DO THAT!** Why you ask?
|
||||
Mailu has its own trusted IPv4 network, every container inside this network can use e.g. the SMTP container without further
|
||||
authentication. If you enabled IPv6 inside the setup assistant (and fixed the ports to also be exposed on IPv6) Docker will
|
||||
@ -223,7 +223,7 @@ For **service** HA, please see: `How does Mailu scale up?`_
|
||||
|
||||
*Issue reference:* `177`_, `591`_.
|
||||
|
||||
.. _`spam magnet`: https://web.archive.org/web/20130131032707/https://blog.zensoftware.co.uk/2012/07/02/why-we-tend-to-recommend-not-having-a-secondary-mx-these-days/
|
||||
.. _`spam magnet`: https://web.archive.org/web/20130131032707/https://blog.zensoftware.co.uk/2012/07/02/why-we-tend-to-recommend-not-having-a-secondary-mx-these-days/
|
||||
|
||||
Does Mailu run on Rancher?
|
||||
``````````````````````````
|
||||
@ -292,7 +292,7 @@ I want to integrate Nextcloud 15 (and newer) with Mailu
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
|
||||
If a domain name (e.g. example.com) is specified, then this makes sure that only users from this domain will be allowed to login.
|
||||
After successfull login the domain part will be stripped and the rest used as username in Nextcloud. e.g. 'username@example.com' will be 'username' in Nextcloud. Disable this behaviour by changing true (the fifth parameter) to false.
|
||||
@ -346,7 +346,7 @@ How do I use webdav (radicale)?
|
||||
|
|
||||
| Subsequently to use webdav (radicale), you can configure your carddav/caldav client to use the following url:
|
||||
| `https://mail.example.com/webdav/user@example.com`
|
||||
| As username you must provide the complete email address (user@example.com).
|
||||
| As username you must provide the complete email address (user@example.com).
|
||||
| As password you must provide the password of the email address.
|
||||
| The user must be an existing Mailu user.
|
||||
|
||||
@ -545,14 +545,14 @@ inside a container. The ``front`` container does use authentication rate limitin
|
||||
down brute force attacks. The same applies to login attempts via the single sign on page.
|
||||
|
||||
We *do* provide a possibility to export the logs from the ``front`` service and ``Admin`` service to the host.
|
||||
The ``front`` container logs failed logon attempts on SMTP, IMAP and POP3.
|
||||
The ``front`` container logs failed logon attempts on SMTP, IMAP and POP3.
|
||||
The ``Admin``container logs failed logon attempt on the single sign on page.
|
||||
For this you need to set ``LOG_DRIVER=journald`` or ``syslog``, depending on the log
|
||||
manager of the host. You will need to setup the proper Regex in the Fail2Ban configuration.
|
||||
Below an example how to do so.
|
||||
Below an example how to do so.
|
||||
|
||||
If you use a reverse proxy in front of Mailu, it is vital to set the environment variables REAL_IP_HEADER and REAL_IP_FROM.
|
||||
Without these environment variables, Mailu will not trust the remote client IP passed on by the reverse proxy and as a result your reverse proxy will be banned.
|
||||
Without these environment variables, Mailu will not trust the remote client IP passed on by the reverse proxy and as a result your reverse proxy will be banned.
|
||||
See the :ref:`[configuration reference <reverse_proxy_headers>` for more information.
|
||||
|
||||
|
||||
@ -591,12 +591,12 @@ 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
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
|
||||
logging:
|
||||
driver: journald
|
||||
options:
|
||||
@ -625,28 +625,53 @@ 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/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
|
||||
|
||||
7. Add the /etc/fail2ban/action.d/docker-action.conf
|
||||
|
||||
Option 1: Use plain iptables
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
[Definition]
|
||||
|
||||
|
||||
actionstart = iptables -N f2b-bad-auth
|
||||
iptables -A f2b-bad-auth -j RETURN
|
||||
iptables -I DOCKER-USER -j f2b-bad-auth
|
||||
|
||||
|
||||
actionstop = iptables -D DOCKER-USER -j f2b-bad-auth
|
||||
iptables -F f2b-bad-auth
|
||||
iptables -X f2b-bad-auth
|
||||
|
||||
|
||||
actioncheck = iptables -n -L DOCKER-USER | grep -q 'f2b-bad-auth[ \t]'
|
||||
|
||||
|
||||
actionban = iptables -I f2b-bad-auth 1 -s <ip> -j DROP
|
||||
|
||||
|
||||
actionunban = iptables -D f2b-bad-auth -s <ip> -j DROP
|
||||
|
||||
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/
|
||||
@ -657,7 +682,7 @@ IMPORTANT: You have to install ipset on the host system, eg. `apt-get install ip
|
||||
See ipset homepage for details on ipset, https://ipset.netfilter.org/.
|
||||
|
||||
ipset and iptables provide one big advantage over just using iptables: This setup reduces the overall iptable rules.
|
||||
There is just one rule for the bad authentications and the IPs are within the ipset.
|
||||
There is just one rule for the bad authentications and the IPs are within the ipset.
|
||||
Specially in larger setups with a high amount of brute force attacks this comes in handy.
|
||||
Using iptables with ipset might reduce the system load in such attacks significantly.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -727,7 +752,7 @@ In any case, using a dedicated DNS server will improve the performance of your m
|
||||
|
||||
Can I learn ham/spam messages from an already existing mailbox?
|
||||
```````````````````````````````````````````````````````````````
|
||||
Mailu supports automatic spam learning for messages moved to the Junk mailbox. Any email moved from the Junk Folder will learnt as ham.
|
||||
Mailu supports automatic spam learning for messages moved to the Junk mailbox. Any email moved from the Junk Folder will learnt as ham.
|
||||
|
||||
If you already have an existing mailbox and want Mailu to learn them all as ham messages, you might run rspamc from within the dovecot container:
|
||||
|
||||
@ -736,7 +761,7 @@ If you already have an existing mailbox and want Mailu to learn them all as ham
|
||||
rspamc -h antispam:11334 -P mailu -f 13 fuzzy_add /mail/user\@example.com/.Ham_Learn/cur/
|
||||
rspamc -h antispam:11334 -P mailu learn_ham /mail/user\@example.com/.Ham_Learn/cur/
|
||||
|
||||
This should learn every file located in the ``Ham_Learn`` folder from user@example.com
|
||||
This should learn every file located in the ``Ham_Learn`` folder from user@example.com
|
||||
|
||||
Likewise, to lean all messages within the folder ``Spam_Learn`` as spam messages :
|
||||
|
||||
|
@ -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
|
||||
|
2
towncrier/newsfragments/445.feature
Normal file
2
towncrier/newsfragments/445.feature
Normal file
@ -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.
|
Loading…
Reference in New Issue
Block a user