Merge #2464
2464: Introduce RESTful API r=mergify[bot] a=Diman0 ## What type of PR? Feature ## What does this PR do? Introduces a RESTful API for changing the complete Mailu config. Anything that can be configured in the web administration interface, can also be configured via the Mailu RESTful API. Via the swagger.json endpoint the complete OpenAPI specification can be retrieved. Via the endpoint swaggerui, a web client is available which shows all the endpoints, data models and allows you to submit requests. See docs/api.rst and docs/configuration.rst for details for enabling it. ### Related issue(s) - closes #445 ## Prerequisites Before we can consider review and merge, please make sure the following list is done and checked. If an entry in not applicable, you can check it or remove it from the list. - [x] In case of feature or enhancement: documentation updated accordingly - [x] Unless it's docs or a minor change: add [changelog](https://mailu.io/master/contributors/workflow.html#changelog) entry file. Co-authored-by: Alexander Graf <ghostwheel42@users.noreply.github.com> Co-authored-by: Dimitri Huisman <diman@huisman.xyz>main
commit
bbf0ac5d47
@ -0,0 +1,24 @@
|
||||
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']
|
||||
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
|
@ -0,0 +1,42 @@
|
||||
from .. import models, utils
|
||||
from . import v1
|
||||
from flask import request
|
||||
import flask
|
||||
import hmac
|
||||
from functools import wraps
|
||||
from flask_restx import abort
|
||||
from sqlalchemy.sql.expression import label
|
||||
|
||||
def fqdn_in_use(name):
|
||||
d = models.db.session.query(label('name', models.Domain.name))
|
||||
a = models.db.session.query(label('name', models.Alternative.name))
|
||||
r = models.db.session.query(label('name', models.Relay.name))
|
||||
u = d.union_all(a).union_all(r).filter_by(name=name)
|
||||
if models.db.session.query(u.exists()).scalar():
|
||||
return True
|
||||
return False
|
||||
|
||||
""" Decorator for validating api token for authentication """
|
||||
def api_token_authorization(func):
|
||||
@wraps(func)
|
||||
def decorated_function(*args, **kwds):
|
||||
client_ip = flask.request.headers.get('X-Real-IP', flask.request.remote_addr)
|
||||
if utils.limiter.should_rate_limit_ip(client_ip):
|
||||
abort(429, 'Too many attempts from your IP (rate-limit)' )
|
||||
if not request.headers.get('Authorization'):
|
||||
abort(401, 'A valid Bearer token is expected which is provided as request header')
|
||||
#Client provides 'Authentication: Bearer <token>'
|
||||
if (' ' in request.headers.get('Authorization')
|
||||
and not hmac.compare_digest(request.headers.get('Authorization'), 'Bearer ' + v1.api_token)):
|
||||
utils.limiter.rate_limit_ip(client_ip)
|
||||
flask.current_app.logger.warn(f'Invalid API token provided by {client_ip}.')
|
||||
abort(403, 'A valid Bearer token is expected which is provided as request header')
|
||||
#Client provides 'Authentication: <token>'
|
||||
elif (' ' not in request.headers.get('Authorization')
|
||||
and not hmac.compare_digest(request.headers.get('Authorization'), v1.api_token)):
|
||||
utils.limiter.rate_limit_ip(client_ip)
|
||||
flask.current_app.logger.warn(f'Invalid API token provided by {client_ip}.')
|
||||
abort(403, 'A valid Bearer token is expected which is provided as request header')
|
||||
flask.current_app.logger.info(f'Valid API token provided by {client_ip}.')
|
||||
return func(*args, **kwds)
|
||||
return decorated_function
|
@ -0,0 +1,43 @@
|
||||
from flask import Blueprint
|
||||
from flask_restx import Api, fields
|
||||
|
||||
|
||||
VERSION = 1.0
|
||||
api_token = None
|
||||
|
||||
blueprint = Blueprint(f'api_v{int(VERSION)}', __name__)
|
||||
|
||||
authorization = {
|
||||
'Bearer': {
|
||||
'type': 'apiKey',
|
||||
'in': 'header',
|
||||
'name': 'Authorization'
|
||||
}
|
||||
}
|
||||
|
||||
api = Api(
|
||||
blueprint, version=f'{VERSION:.1f}',
|
||||
title='Mailu API', default_label='Mailu',
|
||||
validate=True,
|
||||
authorizations=authorization,
|
||||
security='Bearer',
|
||||
doc='/'
|
||||
)
|
||||
|
||||
response_fields = api.model('Response', {
|
||||
'code': fields.Integer,
|
||||
'message': fields.String,
|
||||
})
|
||||
|
||||
error_fields = api.model('Error', {
|
||||
'errors': fields.Nested(api.model('Error_Key', {
|
||||
'key': fields.String,
|
||||
'message':fields.String
|
||||
})),
|
||||
'message': fields.String,
|
||||
})
|
||||
|
||||
from . import domains
|
||||
from . import alias
|
||||
from . import relay
|
||||
from . import user
|
@ -0,0 +1,126 @@
|
||||
from flask_restx import Resource, fields, marshal
|
||||
from . import api, response_fields
|
||||
from .. import common
|
||||
from ... import models
|
||||
|
||||
db = models.db
|
||||
|
||||
alias = api.namespace('alias', description='Alias operations')
|
||||
|
||||
alias_fields_update = alias.model('AliasUpdate', {
|
||||
'comment': fields.String(description='a comment'),
|
||||
'destination': fields.List(fields.String(description='alias email address', example='user@example.com')),
|
||||
'wildcard': fields.Boolean(description='enable SQL Like wildcard syntax')
|
||||
})
|
||||
|
||||
alias_fields = alias.inherit('Alias',alias_fields_update, {
|
||||
'email': fields.String(description='the alias email address', example='user@example.com', required=True),
|
||||
'destination': fields.List(fields.String(description='alias email address', example='user@example.com', required=True)),
|
||||
|
||||
})
|
||||
|
||||
|
||||
@alias.route('')
|
||||
class Aliases(Resource):
|
||||
@alias.doc('list_alias')
|
||||
@alias.marshal_with(alias_fields, as_list=True, skip_none=True, mask=None)
|
||||
@alias.doc(security='Bearer')
|
||||
@common.api_token_authorization
|
||||
def get(self):
|
||||
""" List aliases """
|
||||
return models.Alias.query.all()
|
||||
|
||||
@alias.doc('create_alias')
|
||||
@alias.expect(alias_fields)
|
||||
@alias.response(200, 'Success', response_fields)
|
||||
@alias.response(400, 'Input validation exception', response_fields)
|
||||
@alias.response(409, 'Duplicate alias', response_fields)
|
||||
@alias.doc(security='Bearer')
|
||||
@common.api_token_authorization
|
||||
def post(self):
|
||||
""" Create a new alias """
|
||||
data = api.payload
|
||||
|
||||
alias_found = models.Alias.query.filter_by(email = data['email']).first()
|
||||
if alias_found:
|
||||
return { 'code': 409, 'message': f'Duplicate alias {data["email"]}'}, 409
|
||||
|
||||
alias_model = models.Alias(email=data["email"],destination=data['destination'])
|
||||
if 'comment' in data:
|
||||
alias_model.comment = data['comment']
|
||||
if 'wildcard' in data:
|
||||
alias_model.wildcard = data['wildcard']
|
||||
db.session.add(alias_model)
|
||||
db.session.commit()
|
||||
|
||||
return {'code': 200, 'message': f'Alias {data["email"]} to destination {data["destination"]} has been created'}, 200
|
||||
|
||||
@alias.route('/<string:alias>')
|
||||
class Alias(Resource):
|
||||
@alias.doc('find_alias')
|
||||
@alias.response(200, 'Success', alias_fields)
|
||||
@alias.response(404, 'Alias not found', response_fields)
|
||||
@alias.doc(security='Bearer')
|
||||
@common.api_token_authorization
|
||||
def get(self, alias):
|
||||
""" Find alias """
|
||||
alias_found = models.Alias.query.filter_by(email = alias).first()
|
||||
if alias_found is None:
|
||||
return { 'code': 404, 'message': f'Alias {alias} cannot be found'}, 404
|
||||
else:
|
||||
return marshal(alias_found,alias_fields), 200
|
||||
|
||||
@alias.doc('update_alias')
|
||||
@alias.expect(alias_fields_update)
|
||||
@alias.response(200, 'Success', response_fields)
|
||||
@alias.response(404, 'Alias not found', response_fields)
|
||||
@alias.response(400, 'Input validation exception', response_fields)
|
||||
@alias.doc(security='Bearer')
|
||||
@common.api_token_authorization
|
||||
def patch(self, alias):
|
||||
""" Update alias """
|
||||
data = api.payload
|
||||
alias_found = models.Alias.query.filter_by(email = alias).first()
|
||||
if alias_found is None:
|
||||
return { 'code': 404, 'message': f'Alias {alias} cannot be found'}, 404
|
||||
if 'comment' in data:
|
||||
alias_found.comment = data['comment']
|
||||
if 'destination' in data:
|
||||
alias_found.destination = data['destination']
|
||||
if 'wildcard' in data:
|
||||
alias_found.wildcard = data['wildcard']
|
||||
db.session.add(alias_found)
|
||||
db.session.commit()
|
||||
return {'code': 200, 'message': f'Alias {alias} has been updated'}
|
||||
|
||||
@alias.doc('delete_alias')
|
||||
@alias.response(200, 'Success', response_fields)
|
||||
@alias.response(404, 'Alias not found', response_fields)
|
||||
@alias.doc(security='Bearer')
|
||||
@common.api_token_authorization
|
||||
def delete(self, alias):
|
||||
""" Delete alias """
|
||||
alias_found = models.Alias.query.filter_by(email = alias).first()
|
||||
if alias_found is None:
|
||||
return { 'code': 404, 'message': f'Alias {alias} cannot be found'}, 404
|
||||
db.session.delete(alias_found)
|
||||
db.session.commit()
|
||||
return {'code': 200, 'message': f'Alias {alias} has been deleted'}, 200
|
||||
|
||||
@alias.route('/destination/<string:domain>')
|
||||
class AliasWithDest(Resource):
|
||||
@alias.doc('find_alias_filter_domain')
|
||||
@alias.response(200, 'Success', alias_fields)
|
||||
@alias.response(404, 'Alias or domain not found', response_fields)
|
||||
@alias.doc(security='Bearer')
|
||||
@common.api_token_authorization
|
||||
def get(self, domain):
|
||||
""" Find aliases of domain """
|
||||
domain_found = models.Domain.query.filter_by(name=domain).first()
|
||||
if domain_found is None:
|
||||
return { 'code': 404, 'message': f'Domain {domain} cannot be found'}, 404
|
||||
aliases_found = domain_found.aliases
|
||||
if aliases_found.count == 0:
|
||||
return { 'code': 404, 'message': f'No alias can be found for domain {domain}'}, 404
|
||||
else:
|
||||
return marshal(aliases_found, alias_fields), 200
|
@ -0,0 +1,410 @@
|
||||
import validators
|
||||
from flask_restx import Resource, fields, marshal
|
||||
from . import api, response_fields, user
|
||||
from .. import common
|
||||
from ... import models
|
||||
|
||||
db = models.db
|
||||
|
||||
dom = api.namespace('domain', description='Domain operations')
|
||||
alt = api.namespace('alternative', description='Alternative operations')
|
||||
|
||||
domain_fields = api.model('Domain', {
|
||||
'name': fields.String(description='FQDN (e.g. example.com)', example='example.com', required=True),
|
||||
'comment': fields.String(description='a comment'),
|
||||
'max_users': fields.Integer(description='maximum number of users', min=-1, default=-1),
|
||||
'max_aliases': fields.Integer(description='maximum number of aliases', min=-1, default=-1),
|
||||
'max_quota_bytes': fields.Integer(description='maximum quota for mailbox', min=0),
|
||||
'signup_enabled': fields.Boolean(description='allow signup'),
|
||||
'alternatives': fields.List(fields.String(attribute='name', description='FQDN', example='example2.com')),
|
||||
})
|
||||
|
||||
domain_fields_update = api.model('DomainUpdate', {
|
||||
'comment': fields.String(description='a comment'),
|
||||
'max_users': fields.Integer(description='maximum number of users', min=-1, default=-1),
|
||||
'max_aliases': fields.Integer(description='maximum number of aliases', min=-1, default=-1),
|
||||
'max_quota_bytes': fields.Integer(description='maximum quota for mailbox', min=0),
|
||||
'signup_enabled': fields.Boolean(description='allow signup'),
|
||||
'alternatives': fields.List(fields.String(attribute='name', description='FQDN', example='example2.com')),
|
||||
})
|
||||
|
||||
domain_fields_get = api.model('DomainGet', {
|
||||
'name': fields.String(description='FQDN (e.g. example.com)', example='example.com', required=True),
|
||||
'comment': fields.String(description='a comment'),
|
||||
'max_users': fields.Integer(description='maximum number of users', min=-1, default=-1),
|
||||
'max_aliases': fields.Integer(description='maximum number of aliases', min=-1, default=-1),
|
||||
'max_quota_bytes': fields.Integer(description='maximum quota for mailbox', min=0),
|
||||
'signup_enabled': fields.Boolean(description='allow signup'),
|
||||
'alternatives': fields.List(fields.String(attribute='name', description='FQDN', example='example2.com')),
|
||||
'dns_autoconfig': fields.List(fields.String(description='DNS client auto-configuration entry')),
|
||||
'dns_mx': fields.String(Description='MX record for domain'),
|
||||
'dns_spf': fields.String(Description='SPF record for domain'),
|
||||
'dns_dkim': fields.String(Description='DKIM record for domain'),
|
||||
'dns_dmarc': fields.String(Description='DMARC record for domain'),
|
||||
'dns_dmarc_report': fields.String(Description='DMARC report record for domain'),
|
||||
'dns_tlsa': fields.String(Description='TLSA record for domain'),
|
||||
})
|
||||
|
||||
domain_fields_dns = api.model('DomainDNS', {
|
||||
'dns_autoconfig': fields.List(fields.String(description='DNS client auto-configuration entry')),
|
||||
'dns_mx': fields.String(Description='MX record for domain'),
|
||||
'dns_spf': fields.String(Description='SPF record for domain'),
|
||||
'dns_dkim': fields.String(Description='DKIM record for domain'),
|
||||
'dns_dmarc': fields.String(Description='DMARC record for domain'),
|
||||
'dns_dmarc_report': fields.String(Description='DMARC report record for domain'),
|
||||
'dns_tlsa': fields.String(Description='TLSA record for domain'),
|
||||
})
|
||||
|
||||
manager_fields = api.model('Manager', {
|
||||
'domain_name': fields.String(description='domain managed by manager'),
|
||||
'user_email': fields.String(description='email address of manager'),
|
||||
})
|
||||
|
||||
manager_fields_create = api.model('ManagerCreate', {
|
||||
'user_email': fields.String(description='email address of manager', required=True),
|
||||
})
|
||||
|
||||
alternative_fields_update = api.model('AlternativeDomainUpdate', {
|
||||
'domain': fields.String(description='domain FQDN', example='example.com', required=False),
|
||||
})
|
||||
|
||||
alternative_fields = api.model('AlternativeDomain', {
|
||||
'name': fields.String(description='alternative FQDN', example='example2.com', required=True),
|
||||
'domain': fields.String(description='domain FQDN', example='example.com', required=True),
|
||||
})
|
||||
|
||||
|
||||
@dom.route('')
|
||||
class Domains(Resource):
|
||||
@dom.doc('list_domain')
|
||||
@dom.marshal_with(domain_fields_get, as_list=True, skip_none=True, mask=None)
|
||||
@dom.doc(security='Bearer')
|
||||
@common.api_token_authorization
|
||||
def get(self):
|
||||
""" List domains """
|
||||
return models.Domain.query.all()
|
||||
|
||||
@dom.doc('create_domain')
|
||||
@dom.expect(domain_fields)
|
||||
@dom.response(200, 'Success', response_fields)
|
||||
@dom.response(400, 'Input validation exception', response_fields)
|
||||
@dom.response(409, 'Duplicate domain/alternative name', response_fields)
|
||||
@dom.doc(security='Bearer')
|
||||
@common.api_token_authorization
|
||||
def post(self):
|
||||
""" Create a new domain """
|
||||
data = api.payload
|
||||
if not validators.domain(data['name']):
|
||||
return { 'code': 400, 'message': f'Domain {data["name"]} is not a valid domain'}, 400
|
||||
|
||||
if common.fqdn_in_use(data['name']):
|
||||
return { 'code': 409, 'message': f'Duplicate domain name {data["name"]}'}, 409
|
||||
if 'alternatives' in data:
|
||||
#check if duplicate alternatives are supplied
|
||||
if [x for x in data['alternatives'] if data['alternatives'].count(x) >= 2]:
|
||||
return { 'code': 409, 'message': f'Duplicate alternative domain names in request' }, 409
|
||||
for item in data['alternatives']:
|
||||
if common.fqdn_in_use(item):
|
||||
return { 'code': 409, 'message': f'Duplicate alternative domain name {item}' }, 409
|
||||
if not validators.domain(item):
|
||||
return { 'code': 400, 'message': f'Alternative domain {item} is not a valid domain'}, 400
|
||||
for item in data['alternatives']:
|
||||
alternative = models.Alternative(name=item, domain_name=data['name'])
|
||||
models.db.session.add(alternative)
|
||||
domain_new = models.Domain(name=data['name'])
|
||||
if 'comment' in data:
|
||||
domain_new.comment = data['comment']
|
||||
if 'max_users' in data:
|
||||
domain_new.comment = data['max_users']
|
||||
if 'max_aliases' in data:
|
||||
domain_new.comment = data['max_aliases']
|
||||
if 'max_quota_bytes' in data:
|
||||
domain_new.comment = data['max_quota_bytes']
|
||||
if 'signup_enabled' in data:
|
||||
domain_new.comment = data['signup_enabled']
|
||||
models.db.session.add(domain_new)
|
||||
#apply the changes
|
||||
db.session.commit()
|
||||
return {'code': 200, 'message': f'Domain {data["name"]} has been created'}, 200
|
||||
|
||||
@dom.route('/<domain>')
|
||||
class Domain(Resource):
|
||||
|
||||
@dom.doc('find_domain')
|
||||
@dom.response(200, 'Success', domain_fields)
|
||||
@dom.response(404, 'Domain not found', response_fields)
|
||||
@dom.doc(security='Bearer')
|
||||
@common.api_token_authorization
|
||||
def get(self, domain):
|
||||
""" Find domain by name """
|
||||
if not validators.domain(domain):
|
||||
return { 'code': 400, 'message': f'Domain {domain} is not a valid domain'}, 400
|
||||
domain_found = models.Domain.query.get(domain)
|
||||
if not domain_found:
|
||||
return { 'code': 404, 'message': f'Domain {domain} does not exist'}, 404
|
||||
return marshal(domain_found, domain_fields_get), 200
|
||||
|
||||
@dom.doc('update_domain')
|
||||
@dom.expect(domain_fields_update)
|
||||
@dom.response(200, 'Success', response_fields)
|
||||
@dom.response(400, 'Input validation exception', response_fields)
|
||||
@dom.response(404, 'Domain not found', response_fields)
|
||||
@dom.response(409, 'Duplicate domain/alternative name', response_fields)
|
||||
@dom.doc(security='Bearer')
|
||||
@common.api_token_authorization
|
||||
def patch(self, domain):
|
||||
""" Update an existing domain """
|
||||
if not validators.domain(domain):
|
||||
return { 'code': 400, 'message': f'Domain {domain} is not a valid domain'}, 400
|
||||
domain_found = models.Domain.query.get(domain)
|
||||
if not domain:
|
||||
return { 'code': 404, 'message': f'Domain {data["name"]} does not exist'}, 404
|
||||
data = api.payload
|
||||
|
||||
if 'alternatives' in data:
|
||||
#check if duplicate alternatives are supplied
|
||||
if [x for x in data['alternatives'] if data['alternatives'].count(x) >= 2]:
|
||||
return { 'code': 409, 'message': f'Duplicate alternative domain names in request' }, 409
|
||||
for item in data['alternatives']:
|
||||
if common.fqdn_in_use(item):
|
||||
return { 'code': 409, 'message': f'Duplicate alternative domain name {item}' }, 409
|
||||
if not validators.domain(item):
|
||||
return { 'code': 400, 'message': f'Alternative domain {item} is not a valid domain'}, 400
|
||||
for item in data['alternatives']:
|
||||
alternative = models.Alternative(name=item, domain_name=data['name'])
|
||||
models.db.session.add(alternative)
|
||||
|
||||
if 'comment' in data:
|
||||
domain_found.comment = data['comment']
|
||||
if 'max_users' in data:
|
||||
domain_found.comment = data['max_users']
|
||||
if 'max_aliases' in data:
|
||||
domain_found.comment = data['max_aliases']
|
||||
if 'max_quota_bytes' in data:
|
||||
domain_found.comment = data['max_quota_bytes']
|
||||
if 'signup_enabled' in data:
|
||||
domain_found.comment = data['signup_enabled']
|
||||
models.db.session.add(domain_found)
|
||||
|
||||
#apply the changes
|
||||
db.session.commit()
|
||||
return {'code': 200, 'message': f'Domain {domain} has been updated'}, 200
|
||||
|
||||
@dom.doc('delete_domain')
|
||||
@dom.response(200, 'Success', response_fields)
|
||||
@dom.response(400, 'Input validation exception', response_fields)
|
||||
@dom.response(404, 'Domain not found', response_fields)
|
||||
@dom.doc(security='Bearer')
|
||||
@common.api_token_authorization
|
||||
def delete(self, domain):
|
||||
""" Delete domain """
|
||||
if not validators.domain(domain):
|
||||
return { 'code': 400, 'message': f'Domain {domain} is not a valid domain'}, 400
|
||||
domain_found = models.Domain.query.get(domain)
|
||||
if not domain:
|
||||
return { 'code': 404, 'message': f'Domain {domain} does not exist'}, 404
|
||||
db.session.delete(domain_found)
|
||||
db.session.commit()
|
||||
return {'code': 200, 'message': f'Domain {domain} has been deleted'}, 200
|
||||
|
||||
@dom.route('/<domain>/dkim')
|
||||
class Domain(Resource):
|
||||
@dom.doc('generate_dkim')
|
||||
@dom.response(200, 'Success', response_fields)
|
||||
@dom.response(400, 'Input validation exception', response_fields)
|
||||
@dom.response(404, 'Domain not found', response_fields)
|
||||
@dom.doc(security='Bearer')
|
||||
@common.api_token_authorization
|
||||
def post(self, domain):
|
||||
""" Generate new DKIM/DMARC keys for domain """
|
||||
if not validators.domain(domain):
|
||||
return { 'code': 400, 'message': f'Domain {domain} is not a valid domain'}, 400
|
||||
domain_found = models.Domain.query.get(domain)
|
||||
if not domain_found:
|
||||
return { 'code': 404, 'message': f'Domain {domain} does not exist'}, 404
|
||||
domain_found.generate_dkim_key()
|
||||
domain_found.save_dkim_key()
|
||||
return {'code': 200, 'message': f'DKIM/DMARC keys have been generated for domain {domain}'}, 200
|
||||
|
||||
@dom.route('/<domain>/manager')
|
||||
class Manager(Resource):
|
||||
@dom.doc('list_managers')
|
||||
@dom.marshal_with(manager_fields, as_list=True, skip_none=True, mask=None)
|
||||
@dom.response(400, 'Input validation exception', response_fields)
|
||||
@dom.response(404, 'domain not found', response_fields)
|
||||
@dom.doc(security='Bearer')
|
||||
@common.api_token_authorization
|
||||
def get(self, domain):
|
||||
""" List managers of domain """
|
||||
if not validators.domain(domain):
|
||||
return { 'code': 400, 'message': f'Domain {domain} is not a valid domain'}, 400
|
||||
if not domain:
|
||||
return { 'code': 404, 'message': f'Domain {domain} does not exist'}, 404
|
||||
domain = models.Domain.query.filter_by(name=domain)
|
||||
return domain.managers
|
||||
|
||||
@dom.doc('create_manager')
|
||||
@dom.expect(manager_fields_create)
|
||||
@dom.response(200, 'Success', response_fields)
|
||||
@dom.response(400, 'Input validation exception', response_fields)
|
||||
@dom.response(404, 'User or domain not found', response_fields)
|
||||
@dom.response(409, 'Duplicate domain manager', response_fields)
|
||||
@dom.doc(security='Bearer')
|
||||
@common.api_token_authorization
|
||||
def post(self, domain):
|
||||
""" Create a new domain manager """
|
||||
data = api.payload
|
||||
if not validators.email(data['user_email']):
|
||||
return {'code': 400, 'message': f'Invalid email address {data["user_email"]}'}, 400
|
||||
if not validators.domain(domain):
|
||||
return { 'code': 400, 'message': f'Domain {domain} is not a valid domain'}, 400
|
||||
domain = models.Domain.query.get(domain)
|
||||
if not domain:
|
||||
return { 'code': 404, 'message': f'Domain {domain} does not exist'}, 404
|
||||
user = models.User.query.get(data['user_email'])
|
||||
if not user:
|
||||
return { 'code': 404, 'message': f'User {data["user_email"]} does not exist'}, 404
|
||||
if user in domain.managers:
|
||||
return {'code': 409, 'message': f'User {data["user_email"]} is already a manager of the domain {domain} '}, 409
|
||||
domain.managers.append(user)
|
||||
models.db.session.commit()
|
||||
return {'code': 200, 'message': f'User {data["user_email"]} has been added as manager of the domain {domain} '},200
|
||||
|
||||
@dom.route('/<domain>/manager/<email>')
|
||||
class Domain(Resource):
|
||||
@dom.doc('find_manager')
|
||||
@dom.response(200, 'Success', manager_fields)
|
||||
@dom.response(404, 'Manager not found', response_fields)
|
||||
@dom.doc(security='Bearer')
|
||||
@common.api_token_authorization
|
||||
def get(self, domain, email):
|
||||
""" Find manager by email address """
|
||||
if not validators.email(email):
|
||||
return {'code': 400, 'message': f'Invalid email address {email}'}, 400
|
||||
if not validators.domain(domain):
|
||||
return { 'code': 400, 'message': f'Domain {domain} is not a valid domain'}, 400
|
||||
domain = models.Domain.query.get(domain)
|
||||
if not domain:
|
||||
return { 'code': 404, 'message': f'Domain {domain} does not exist'}, 404
|
||||
user = models.User.query.get(email)
|
||||
if not user:
|
||||
return { 'code': 404, 'message': f'User {email} does not exist'}, 404
|
||||
if user in domain.managers:
|
||||
for manager in domain.managers:
|
||||
if manager.email == email:
|
||||
return marshal(manager, manager_fields),200
|
||||
else:
|
||||
return { 'code': 404, 'message': f'User {email} is not a manager of the domain {domain}'}, 404
|
||||
|
||||
|
||||
@dom.doc('delete_manager')
|
||||
@dom.response(200, 'Success', response_fields)
|
||||
@dom.response(400, 'Input validation exception', response_fields)
|
||||
@dom.response(404, 'Manager not found', response_fields)
|
||||
@dom.doc(security='Bearer')
|
||||
@common.api_token_authorization
|
||||
def delete(self, domain, email):
|
||||
if not validators.email(email):
|
||||
return {'code': 400, 'message': f'Invalid email address {email}'}, 400
|
||||
if not validators.domain(domain):
|
||||
return { 'code': 400, 'message': f'Domain {domain} is not a valid domain'}, 400
|
||||
domain = models.Domain.query.get(domain)
|
||||
if not domain:
|
||||
return { 'code': 404, 'message': f'Domain {domain} does not exist'}, 404
|
||||
user = models.User.query.get(email)
|
||||
if not user:
|
||||
return { 'code': 404, 'message': f'User {email} does not exist'}, 404
|
||||
if user in domain.managers:
|
||||
domain.managers.remove(user)
|
||||
models.db.session.commit()
|
||||
return {'code': 200, 'message': f'User {email} has been removed as a manager of the domain {domain} '},200
|
||||
else:
|
||||
return { 'code': 404, 'message': f'User {email} is not a manager of the domain {domain}'}, 404
|
||||
|
||||
@dom.route('/<domain>/users')
|
||||
class User(Resource):
|
||||
@dom.doc('list_user_domain')
|
||||
@dom.marshal_with(user.user_fields_get, as_list=True, skip_none=True, mask=None)
|
||||
@dom.response(400, 'Input validation exception', response_fields)
|
||||
@dom.response(404, 'Domain not found', response_fields)
|
||||
@dom.doc(security='Bearer')
|
||||
@common.api_token_authorization
|
||||
def get(self, domain):
|
||||
""" List users from domain """
|
||||
if not validators.domain(domain):
|
||||
return { 'code': 400, 'message': f'Domain {domain} is not a valid domain'}, 400
|
||||
domain_found = models.Domain.query.get(domain)
|
||||
if not domain_found:
|
||||
return { 'code': 404, 'message': f'Domain {domain} does not exist'}, 404
|
||||
return models.User.query.filter_by(domain=domain_found).all()
|
||||
|
||||
@alt.route('')
|
||||
class Alternatives(Resource):
|
||||
|
||||
@alt.doc('list_alternative')
|
||||
@alt.marshal_with(alternative_fields, as_list=True, skip_none=True, mask=None)
|
||||
@alt.doc(security='Bearer')
|
||||
@common.api_token_authorization
|
||||
def get(self):
|
||||
""" List alternatives """
|
||||
return models.Alternative.query.all()
|
||||
|
||||
|
||||
@alt.doc('create_alternative')
|
||||
@alt.expect(alternative_fields)
|
||||
@alt.response(200, 'Success', response_fields)
|
||||
@alt.response(400, 'Input validation exception', response_fields)
|
||||
@alt.response(404, 'Domain not found or missing', response_fields)
|
||||
@alt.response(409, 'Duplicate alternative domain name', response_fields)
|
||||
@alt.doc(security='Bearer')
|
||||
@common.api_token_authorization
|
||||
def post(self):
|
||||
""" Create new alternative (for domain) """
|
||||
data = api.payload
|
||||
if not validators.domain(data['name']):
|
||||
return { 'code': 400, 'message': f'Alternative domain {data["name"]} is not a valid domain'}, 400
|
||||
if not validators.domain(data['domain']):
|
||||
return { 'code': 400, 'message': f'Domain {data["domain"]} is not a valid domain'}, 400
|
||||
domain = models.Domain.query.get(data['domain'])
|
||||
if not domain:
|
||||
return { 'code': 404, 'message': f'Domain {data["domain"]} does not exist'}, 404
|
||||
if common.fqdn_in_use(data['name']):
|
||||
return { 'code': 409, 'message': f'Duplicate alternative domain name {data["name"]}'}, 409
|
||||
|
||||
alternative = models.Alternative(name=data['name'], domain_name=data['domain'])
|
||||
models.db.session.add(alternative)
|
||||
db.session.commit()
|
||||
return {'code': 200, 'message': f'Alternative {data["name"]} for domain {data["domain"]} has been created'}, 200
|
||||
|
||||
@alt.route('/<string:alt>')
|
||||
class Alternative(Resource):
|
||||
@alt.doc('find_alternative')
|
||||
@alt.doc(security='Bearer')
|
||||
@common.api_token_authorization
|
||||
def get(self, alt):
|
||||
""" Find alternative (of domain) """
|
||||
if not validators.domain(alt):
|
||||
return { 'code': 400, 'message': f'Alternative domain {alt} is not a valid domain'}, 400
|
||||
alternative = models.Alternative.query.filter_by(name=alt).first()
|
||||
if not alternative:
|
||||
return{ 'code': 404, 'message': f'Alternative domain {alt} does not exist'}, 404
|
||||
return marshal(alternative, alternative_fields), 200
|
||||
|
||||
@alt.doc('delete_alternative')
|
||||
@alt.response(200, 'Success', response_fields)
|
||||
@alt.response(400, 'Input validation exception', response_fields)
|
||||
@alt.response(404, 'Alternative/Domain not found or missing', response_fields)
|
||||
@alt.response(409, 'Duplicate domain name', response_fields)
|
||||
@alt.doc(security='Bearer')
|
||||
@common.api_token_authorization
|
||||
def delete(self, alt):
|
||||
""" Delete alternative (for domain) """
|
||||
if not validators.domain(alt):
|
||||
return { 'code': 400, 'message': f'Alternative domain {alt} is not a valid domain'}, 400
|
||||
alternative = models.Alternative.query.filter_by(name=alt).scalar()
|
||||
if not alternative:
|
||||
return { 'code': 404, 'message': f'Alternative domain {alt} does not exist'}, 404
|
||||
domain = alternative.domain_name
|
||||
db.session.delete(alternative)
|
||||
db.session.commit()
|
||||
return {'code': 200, 'message': f'Alternative {alt} for domain {domain} has been deleted'}, 200
|
@ -0,0 +1,118 @@
|
||||
from flask_restx import Resource, fields, marshal
|
||||
import validators
|
||||
|
||||
from . import api, response_fields
|
||||
from .. import common
|
||||
from ... import models
|
||||
|
||||
db = models.db
|
||||
|
||||
relay = api.namespace('relay', description='Relay operations')
|
||||
|
||||
relay_fields = api.model('Relay', {
|
||||
'name': fields.String(description='relayed domain name', example='example.com', required=True),
|
||||
'smtp': fields.String(description='remote host', example='example.com', required=False),
|
||||
'comment': fields.String(description='a comment', required=False)
|
||||
})
|
||||
|
||||
relay_fields_update = api.model('RelayUpdate', {
|
||||
'smtp': fields.String(description='remote host', example='example.com', required=False),
|
||||
'comment': fields.String(description='a comment', required=False)
|
||||
})
|
||||
|
||||
@relay.route('')
|
||||
class Relays(Resource):
|
||||
@relay.doc('list_relays')
|
||||
@relay.marshal_with(relay_fields, as_list=True, skip_none=True, mask=None)
|
||||
@relay.doc(security='Bearer')
|
||||
@common.api_token_authorization
|
||||
def get(self):
|
||||
"List relays"
|
||||
return models.Relay.query.all()
|
||||
|
||||
@relay.doc('create_relay')
|
||||
@relay.expect(relay_fields)
|
||||
@relay.response(200, 'Success', response_fields)
|
||||
@relay.response(400, 'Input validation exception')
|
||||
@relay.response(409, 'Duplicate relay', response_fields)
|
||||
@relay.doc(security='Bearer')
|
||||
@common.api_token_authorization
|
||||
def post(self):
|
||||
""" Create relay """
|
||||
data = api.payload
|
||||
|
||||
if not validators.domain(name):
|
||||
return { 'code': 400, 'message': f'Relayed domain {name} is not a valid domain'}, 400
|
||||
|
||||
if common.fqdn_in_use(data['name']):
|
||||
return { 'code': 409, 'message': f'Duplicate domain {data["name"]}'}, 409
|
||||
relay_model = models.Relay(name=data['name'])
|
||||
if 'smtp' in data:
|
||||
relay_model.smtp = data['smtp']
|
||||
if 'comment' in data:
|
||||
relay_model.comment = data['comment']
|
||||
db.session.add(relay_model)
|
||||
db.session.commit()
|
||||
return {'code': 200, 'message': f'Relayed domain {data["name"]} has been created'}, 200
|
||||
|
||||
@relay.route('/<string:name>')
|
||||
class Relay(Resource):
|
||||
@relay.doc('find_relay')
|
||||
@relay.response(400, 'Input validation exception', response_fields)
|
||||
@relay.response(404, 'Relay not found', response_fields)
|
||||
@relay.doc(security='Bearer')
|
||||
@common.api_token_authorization
|
||||
def get(self, name):
|
||||
""" Find relay """
|
||||
if not validators.domain(name):
|
||||
return { 'code': 400, 'message': f'Relayed domain {name} is not a valid domain'}, 400
|
||||
|
||||
relay_found = models.Relay.query.filter_by(name=name).first()
|
||||
if relay_found is None:
|
||||
return { 'code': 404, 'message': f'Relayed domain {name} cannot be found'}, 404
|
||||
return marshal(relay_found, relay_fields), 200
|
||||
|
||||
@relay.doc('update_relay')
|
||||
@relay.expect(relay_fields_update)
|
||||
@relay.response(200, 'Success', response_fields)
|
||||
@relay.response(400, 'Input validation exception', response_fields)
|
||||
@relay.response(404, 'Relay not found', response_fields)
|
||||
@relay.response(409, 'Duplicate relay', response_fields)
|
||||
@relay.doc(security='Bearer')
|
||||
@common.api_token_authorization
|
||||
def patch(self, name):
|
||||
""" Update relay """
|
||||
data = api.payload
|
||||
|
||||
if not validators.domain(name):
|
||||
return { 'code': 400, 'message': f'Relayed domain {name} is not a valid domain'}, 400
|
||||
|
||||
relay_found = models.Relay.query.filter_by(name=name).first()
|
||||
if relay_found is None:
|
||||
return { 'code': 404, 'message': f'Relayed domain {name} cannot be found'}, 404
|
||||
|
||||
if 'smtp' in data:
|
||||
relay_found.smtp = data['smtp']
|
||||
if 'comment' in data:
|
||||
relay_found.comment = data['comment']
|
||||
db.session.add(relay_found)
|
||||
db.session.commit()
|
||||
return { 'code': 200, 'message': f'Relayed domain {name} has been updated'}, 200
|
||||
|
||||
|
||||
@relay.doc('delete_relay')
|
||||
@relay.response(200, 'Success', response_fields)
|
||||
@relay.response(400, 'Input validation exception', response_fields)
|
||||
@relay.response(404, 'Relay not found', response_fields)
|
||||
@relay.doc(security='Bearer')
|
||||
@common.api_token_authorization
|
||||
def delete(self, name):
|
||||
""" Delete relay """
|
||||
if not validators.domain(name):
|
||||
return { 'code': 400, 'message': f'Relayed domain {name} is not a valid domain'}, 400
|
||||
relay_found = models.Relay.query.filter_by(name=name).first()
|
||||
if relay_found is None:
|
||||
return { 'code': 404, 'message': f'Relayed domain {name} cannot be found'}, 404
|
||||
db.session.delete(relay_found)
|
||||
db.session.commit()
|
||||
return { 'code': 200, 'message': f'Relayed domain {name} has been deleted'}, 200
|
@ -0,0 +1,31 @@
|
||||
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.
|
||||
|
||||
It is accessible via the URL: https://myserver/api/v1/swaggerui
|
@ -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
|
||||
|
@ -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