Merge branch 'master' of https://github.com/Mailu/Mailu into reduce-logging
commit
46f05cb651
@ -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
|
@ -1,3 +1,3 @@
|
|||||||
pip==22.3
|
pip==22.3.1
|
||||||
setuptools==65.5.0
|
setuptools==65.6.3
|
||||||
wheel==0.37.1
|
wheel==0.38.4
|
||||||
|
@ -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,5 +1,5 @@
|
|||||||
recommonmark
|
recommonmark==0.7.1
|
||||||
Sphinx
|
Sphinx==5.2.0
|
||||||
sphinx-autobuild
|
sphinx-autobuild==2021.3.14
|
||||||
sphinx-rtd-theme
|
sphinx-rtd-theme==1.0.0
|
||||||
docutils==0.16
|
docutils==0.16
|
||||||
|
@ -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.
|
Loading…
Reference in New Issue