diff --git a/.github/workflows/build_test_deploy.yml b/.github/workflows/build_test_deploy.yml index 9c2ff02d..1ce4f117 100644 --- a/.github/workflows/build_test_deploy.yml +++ b/.github/workflows/build_test_deploy.yml @@ -92,7 +92,7 @@ jobs: matrix: ${{ steps.targets.outputs.matrix }} steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Create matrix id: targets run: | @@ -114,7 +114,7 @@ jobs: contents: read packages: write steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Retrieve global variables shell: bash run: | @@ -123,13 +123,13 @@ jobs: echo "PINNED_MAILU_VERSION=${{ inputs.pinned_mailu_version }}" >> $GITHUB_ENV echo "DOCKER_ORG=${{ inputs.docker_org }}" >> $GITHUB_ENV - name: Set up QEMU - uses: docker/setup-qemu-action@v2 - - uses: crazy-max/ghaction-github-runtime@v2 + uses: docker/setup-qemu-action@v3 + - uses: crazy-max/ghaction-github-runtime@v3 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v3 - name: Helper to convert docker org to lowercase id: string - uses: ASzc/change-string-case-action@v5 + uses: ASzc/change-string-case-action@v6 with: string: ${{ github.repository_owner }} - name: Get uuid @@ -148,7 +148,7 @@ jobs: DOCKER_LOGIN: ${{ secrets.Docker_Login }} DOCKER_PASSW: ${{ secrets.Docker_Password }} BUILDX_NO_DEFAULT_ATTESTATIONS: 1 - uses: nick-fields/retry@v2 + uses: nick-fields/retry@v3 with: timeout_minutes: 20 retry_wait_seconds: 30 @@ -189,7 +189,7 @@ jobs: contents: read packages: write steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Retrieve global variables shell: bash run: | @@ -198,11 +198,11 @@ jobs: echo "PINNED_MAILU_VERSION=${{ inputs.pinned_mailu_version }}" >> $GITHUB_ENV echo "DOCKER_ORG=${{ inputs.docker_org }}" >> $GITHUB_ENV - name: Set up QEMU - uses: docker/setup-qemu-action@v2 - - uses: crazy-max/ghaction-github-runtime@v2 + uses: docker/setup-qemu-action@v3 + - uses: crazy-max/ghaction-github-runtime@v3 - name: Helper to convert docker org to lowercase id: string - uses: ASzc/change-string-case-action@v5 + uses: ASzc/change-string-case-action@v6 with: string: ${{ github.repository_owner }} #This is to prevent to shared runners from generating the same uuid @@ -222,7 +222,7 @@ jobs: DOCKER_LOGIN2: ${{ secrets.Docker_Login2 }} DOCKER_PASSW2: ${{ secrets.Docker_Password2 }} BUILDX_NO_DEFAULT_ATTESTATIONS: 1 - uses: nick-fields/retry@v2 + uses: nick-fields/retry@v3 with: timeout_minutes: 30 retry_wait_seconds: 30 @@ -266,7 +266,7 @@ jobs: contents: read packages: write steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Retrieve global variables shell: bash run: | @@ -275,13 +275,13 @@ jobs: echo "PINNED_MAILU_VERSION=${{ inputs.pinned_mailu_version }}" >> $GITHUB_ENV echo "DOCKER_ORG=${{ inputs.docker_org }}" >> $GITHUB_ENV - name: Set up QEMU - uses: docker/setup-qemu-action@v2 - - uses: crazy-max/ghaction-github-runtime@v2 + uses: docker/setup-qemu-action@v3 + - uses: crazy-max/ghaction-github-runtime@v3 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v3 - name: Helper to convert docker org to lowercase id: string - uses: ASzc/change-string-case-action@v5 + uses: ASzc/change-string-case-action@v6 with: string: ${{ github.repository_owner }} - name: Get uuid @@ -300,7 +300,7 @@ jobs: DOCKER_LOGIN: ${{ secrets.Docker_Login }} DOCKER_PASSW: ${{ secrets.Docker_Password }} BUILDX_NO_DEFAULT_ATTESTATIONS: 1 - uses: nick-fields/retry@v2 + uses: nick-fields/retry@v3 with: timeout_minutes: 20 retry_wait_seconds: 30 @@ -344,7 +344,7 @@ jobs: contents: read packages: write steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Retrieve global variables shell: bash run: | @@ -353,11 +353,11 @@ jobs: echo "PINNED_MAILU_VERSION=${{ inputs.pinned_mailu_version }}" >> $GITHUB_ENV echo "DOCKER_ORG=${{ inputs.docker_org }}" >> $GITHUB_ENV - name: Set up QEMU - uses: docker/setup-qemu-action@v2 - - uses: crazy-max/ghaction-github-runtime@v2 + uses: docker/setup-qemu-action@v3 + - uses: crazy-max/ghaction-github-runtime@v3 - name: Helper to convert docker org to lowercase id: string - uses: ASzc/change-string-case-action@v5 + uses: ASzc/change-string-case-action@v6 with: string: ${{ github.repository_owner }} #This is to prevent to shared runners from generating the same uuid @@ -377,7 +377,7 @@ jobs: DOCKER_LOGIN2: ${{ secrets.Docker_Login2 }} DOCKER_PASSW2: ${{ secrets.Docker_Password2 }} BUILDX_NO_DEFAULT_ATTESTATIONS: 1 - uses: nick-fields/retry@v2 + uses: nick-fields/retry@v3 with: timeout_minutes: 30 retry_wait_seconds: 30 @@ -418,7 +418,7 @@ jobs: strategy: fail-fast: false matrix: - target: ["core", "fetchmail", "filters", "webmail", "webdav"] + target: ["api", "core", "fetchmail", "filters", "webmail", "webdav"] time: ["2"] include: - target: "filters" @@ -427,7 +427,7 @@ jobs: - target: "filters" time: "2" steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Retrieve global variables shell: bash run: | @@ -436,19 +436,19 @@ jobs: echo "PINNED_MAILU_VERSION=${{ inputs.pinned_mailu_version }}" >> $GITHUB_ENV echo "DOCKER_ORG=${{ inputs.docker_org }}" >> $GITHUB_ENV - name: Set up QEMU - uses: docker/setup-qemu-action@v2 - - uses: crazy-max/ghaction-github-runtime@v2 + uses: docker/setup-qemu-action@v3 + - uses: crazy-max/ghaction-github-runtime@v3 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v3 - name: Login to GitHub Container Registry - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Helper to convert docker org to lowercase id: string - uses: ASzc/change-string-case-action@v5 + uses: ASzc/change-string-case-action@v6 with: string: ${{ github.repository_owner }} - name: Install python packages @@ -476,7 +476,7 @@ jobs: matrix: target: ["setup", "docs", "fetchmail", "webmail", "admin", "traefik-certdumper", "radicale", "rspamd", "oletools", "postfix", "dovecot", "unbound", "nginx"] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Retrieve global variables shell: bash run: | @@ -485,19 +485,19 @@ jobs: echo "PINNED_MAILU_VERSION=${{ inputs.pinned_mailu_version }}" >> $GITHUB_ENV echo "DOCKER_ORG=${{ inputs.docker_org }}" >> $GITHUB_ENV - name: Set up QEMU - uses: docker/setup-qemu-action@v2 - - uses: crazy-max/ghaction-github-runtime@v2 + uses: docker/setup-qemu-action@v3 + - uses: crazy-max/ghaction-github-runtime@v3 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v3 - name: Login to GitHub Container Registry - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Helper to convert docker org to lowercase id: string - uses: ASzc/change-string-case-action@v5 + uses: ASzc/change-string-case-action@v6 with: string: ${{ github.repository_owner }} - name: Push multiarch image to Github (ghcr.io) @@ -539,13 +539,13 @@ jobs: needs: - deploy steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: # fetch-depth 0 is required to also retrieve all tags. fetch-depth: 0 - # A bug in actions/checkout@v3 results in all files having mtime of the job running. + # A bug in actions/checkout@v4 results in all files having mtime of the job running. - name: Restore Timestamps - uses: chetan/git-restore-mtime-action@v1 + uses: chetan/git-restore-mtime-action@v2 - name: Retrieve global variables shell: bash run: | diff --git a/.github/workflows/multiarch.yml b/.github/workflows/multiarch.yml index 1b51bdb0..beb4a002 100644 --- a/.github/workflows/multiarch.yml +++ b/.github/workflows/multiarch.yml @@ -29,7 +29,7 @@ jobs: DEPLOY: ${{ env.DEPLOY }} RELEASE: ${{ env.RELEASE }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: # fetch-depth 0 is required to also retrieve all tags. fetch-depth: 0 diff --git a/AUTHORS.md b/AUTHORS.md index cdaff108..3e8bf0c1 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -21,6 +21,7 @@ Other contributors: - "ofthesun9" - French translation - "SunMar" - Dutch translation - "Marty Hou" - Chinese Simple translation + - "Spoooyders" - Belarusian translation - [Thomas Sänger](https://github.com/HorayNarea) - German translation - [Danylo Sydorenko](https://github.com/Prosta4okua) - Ukrainian translation - [Hossein Hosni](https://github.com/hosni) - [Contributions](https://github.com/Mailu/Mailu/commits?author=hosni) diff --git a/core/admin/mailu/__init__.py b/core/admin/mailu/__init__.py index 19731340..dc9dc97d 100644 --- a/core/admin/mailu/__init__.py +++ b/core/admin/mailu/__init__.py @@ -11,7 +11,14 @@ import logging import hmac class NoPingFilter(logging.Filter): + skipAccessLogs = False + + def __init__(self, filterAccessLogs=False): + self.skipAccessLogs = filterAccessLogs + def filter(self, record): + if self.skipAccessLogs and record.args['r'].endswith(' HTTP/1.1'): + return False if record.args['r'].endswith(' /ping HTTP/1.1'): return False if record.args['r'].endswith(' /internal/rspamd/local_domains HTTP/1.1'): @@ -24,7 +31,7 @@ class Logger(glogging.Logger): # Add filters to Gunicorn logger logger = logging.getLogger("gunicorn.access") - logger.addFilter(NoPingFilter()) + logger.addFilter(NoPingFilter(logger.getEffectiveLevel()>logging.DEBUG)) def create_app_from_config(config): """ Create a new application based on the given configuration diff --git a/core/admin/mailu/api/common.py b/core/admin/mailu/api/common.py index 331fdf4e..6dc75a88 100644 --- a/core/admin/mailu/api/common.py +++ b/core/admin/mailu/api/common.py @@ -24,19 +24,11 @@ def api_token_authorization(func): 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 ' - if (' ' in request.headers.get('Authorization') - and not hmac.compare_digest(request.headers.get('Authorization'), 'Bearer ' + v1.api_token)): + abort(401, 'A valid Authorization header is mandatory') + if len(v1.api_token) < 4 or not hmac.compare_digest(request.headers.get('Authorization').removeprefix('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: ' - 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') + abort(403, 'Invalid API token') flask.current_app.logger.info(f'Valid API token provided by {client_ip}.') return func(*args, **kwds) return decorated_function diff --git a/core/admin/mailu/api/v1/alias.py b/core/admin/mailu/api/v1/alias.py index 600ccc04..ffe0177d 100644 --- a/core/admin/mailu/api/v1/alias.py +++ b/core/admin/mailu/api/v1/alias.py @@ -2,6 +2,7 @@ from flask_restx import Resource, fields, marshal from . import api, response_fields from .. import common from ... import models +import validators db = models.db @@ -15,7 +16,7 @@ alias_fields_update = alias.model('AliasUpdate', { 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)), + 'destination': fields.List(fields.String(description='destination email address', example='user@example.com', required=True)), }) @@ -24,16 +25,19 @@ alias_fields = alias.inherit('Alias',alias_fields_update, { class Aliases(Resource): @alias.doc('list_alias') @alias.marshal_with(alias_fields, as_list=True, skip_none=True, mask=None) + @alias.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @alias.doc(security='Bearer') @common.api_token_authorization def get(self): - """ List aliases """ + """ List all 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.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) + @alias.response(404, 'Not found', response_fields) @alias.response(409, 'Duplicate alias', response_fields) @alias.doc(security='Bearer') @common.api_token_authorization @@ -41,6 +45,20 @@ class Aliases(Resource): """ Create a new alias """ data = api.payload + if not validators.email(data['email']): + return { 'code': 400, 'message': f'Provided alias {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 ({data["email"]})'}, 404 + if not domain_found.max_aliases == -1 and len(domain_found.aliases) >= domain_found.max_aliases: + return { 'code': 409, 'message': f'Too many aliases for domain {domain_name}'}, 409 + for dest in data['destination']: + if not validators.email(dest): + return { 'code': 400, 'message': f'Provided destination email address {dest} is not a valid email address'}, 400 + elif models.User.query.filter_by(email=dest).first() is None: + return { 'code': 404, 'message': f'Provided destination email address {dest} does not exist'}, 404 + alias_found = models.Alias.query.filter_by(email = data['email']).first() if alias_found: return { 'code': 409, 'message': f'Duplicate alias {data["email"]}'}, 409 @@ -53,17 +71,21 @@ class Aliases(Resource): db.session.add(alias_model) db.session.commit() - return {'code': 200, 'message': f'Alias {data["email"]} to destination {data["destination"]} has been created'}, 200 + return {'code': 200, 'message': f'Alias {data["email"]} to destination(s) {data["destination"]} has been created'}, 200 @alias.route('/') class Alias(Resource): @alias.doc('find_alias') @alias.response(200, 'Success', alias_fields) + @alias.response(400, 'Input validation exception', response_fields) + @alias.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @alias.response(404, 'Alias not found', response_fields) @alias.doc(security='Bearer') @common.api_token_authorization def get(self, alias): - """ Find alias """ + """ Look up the specified alias """ + if not validators.email(alias): + return { 'code': 400, 'message': f'Provided alias (email address) {alias} is not a valid email address'}, 400 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 @@ -73,13 +95,17 @@ class Alias(Resource): @alias.doc('update_alias') @alias.expect(alias_fields_update) @alias.response(200, 'Success', response_fields) + @alias.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @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 """ + """ Update the specfied alias """ data = api.payload + + if not validators.email(alias): + return { 'code': 400, 'message': f'Provided alias (email address) {alias} is not a valid email address'}, 400 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 @@ -87,6 +113,11 @@ class Alias(Resource): alias_found.comment = data['comment'] if 'destination' in data: alias_found.destination = data['destination'] + for dest in data['destination']: + if not validators.email(dest): + return { 'code': 400, 'message': f'Provided destination email address {dest} is not a valid email address'}, 400 + elif models.User.query.filter_by(email=dest).first() is None: + return { 'code': 404, 'message': f'Provided destination email address {dest} does not exist'}, 404 if 'wildcard' in data: alias_found.wildcard = data['wildcard'] db.session.add(alias_found) @@ -95,11 +126,15 @@ class Alias(Resource): @alias.doc('delete_alias') @alias.response(200, 'Success', response_fields) + @alias.response(400, 'Input validation exception', response_fields) + @alias.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @alias.response(404, 'Alias not found', response_fields) @alias.doc(security='Bearer') @common.api_token_authorization def delete(self, alias): - """ Delete alias """ + """ Delete the specified alias """ + if not validators.email(alias): + return { 'code': 400, 'message': f'Provided alias (email address) {alias} is not a valid email address'}, 400 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 @@ -111,11 +146,15 @@ class Alias(Resource): class AliasWithDest(Resource): @alias.doc('find_alias_filter_domain') @alias.response(200, 'Success', alias_fields) + @alias.response(400, 'Input validation exception', response_fields) + @alias.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @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 """ + """ Look up the aliases of the specified domain """ + if not validators.domain(domain): + return { 'code': 400, 'message': f'Domain {domain} is not a valid domain'}, 400 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 diff --git a/core/admin/mailu/api/v1/domain.py b/core/admin/mailu/api/v1/domain.py index 2edb10d2..f5048b5d 100644 --- a/core/admin/mailu/api/v1/domain.py +++ b/core/admin/mailu/api/v1/domain.py @@ -16,7 +16,7 @@ domain_fields = api.model('Domain', { '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')), + 'alternatives': fields.List(fields.String(attribute='name', description='FQDN'), example='["example.com"]'), }) domain_fields_update = api.model('DomainUpdate', { @@ -25,17 +25,18 @@ domain_fields_update = api.model('DomainUpdate', { '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')), + 'alternatives': fields.List(fields.String(attribute='name', description='FQDN'), example='["example.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'), + 'managers': fields.List(fields.String(attribute='email', description='manager of domain')), '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')), + 'alternatives': fields.List(fields.String(attribute='name', description='FQDN'), example='["example.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'), @@ -56,8 +57,7 @@ domain_fields_dns = api.model('DomainDNS', { }) manager_fields = api.model('Manager', { - 'domain_name': fields.String(description='domain managed by manager'), - 'user_email': fields.String(description='email address of manager'), + 'managers': fields.List(fields.String(attribute='email', description='manager of domain')), }) manager_fields_create = api.model('ManagerCreate', { @@ -78,16 +78,18 @@ alternative_fields = api.model('AlternativeDomain', { class Domains(Resource): @dom.doc('list_domain') @dom.marshal_with(domain_fields_get, as_list=True, skip_none=True, mask=None) + @dom.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @dom.doc(security='Bearer') @common.api_token_authorization def get(self): - """ List domains """ + """ List all 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.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @dom.response(409, 'Duplicate domain/alternative name', response_fields) @dom.doc(security='Bearer') @common.api_token_authorization @@ -131,14 +133,16 @@ class Domains(Resource): class Domain(Resource): @dom.doc('find_domain') - @dom.response(200, 'Success', domain_fields) + @dom.response(200, 'Success', domain_fields_get) + @dom.response(400, 'Input validation exception', response_fields) + @dom.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @dom.response(404, 'Domain not found', response_fields) @dom.doc(security='Bearer') @common.api_token_authorization def get(self, domain): - """ Find domain by name """ + """ Look up the specified domain """ if not validators.domain(domain): - return { 'code': 400, 'message': f'Domain {domain} is not a valid domain'}, 400 + return { 'code': 400, 'message': f'Domain {domain} is not a valid domain'}, 200 domain_found = models.Domain.query.get(domain) if not domain_found: return { 'code': 404, 'message': f'Domain {domain} does not exist'}, 404 @@ -148,16 +152,17 @@ class Domain(Resource): @dom.expect(domain_fields_update) @dom.response(200, 'Success', response_fields) @dom.response(400, 'Input validation exception', response_fields) + @dom.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @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 """ + """ Update the specified 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: + if not domain_found: return { 'code': 404, 'message': f'Domain {data["name"]} does not exist'}, 404 data = api.payload @@ -171,7 +176,7 @@ class Domain(Resource): 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']) + alternative = models.Alternative(name=item, domain_name=domain) models.db.session.add(alternative) if 'comment' in data: @@ -193,15 +198,16 @@ class Domain(Resource): @dom.doc('delete_domain') @dom.response(200, 'Success', response_fields) @dom.response(400, 'Input validation exception', response_fields) + @dom.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @dom.response(404, 'Domain not found', response_fields) @dom.doc(security='Bearer') @common.api_token_authorization def delete(self, domain): - """ Delete domain """ + """ Delete the specified 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: + if not domain_found: return { 'code': 404, 'message': f'Domain {domain} does not exist'}, 404 db.session.delete(domain_found) db.session.commit() @@ -212,11 +218,12 @@ class Domain(Resource): @dom.doc('generate_dkim') @dom.response(200, 'Success', response_fields) @dom.response(400, 'Input validation exception', response_fields) + @dom.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @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 """ + """ Generate new DKIM/DMARC keys for the specified 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) @@ -229,30 +236,32 @@ class Domain(Resource): @dom.route('//manager') class Manager(Resource): @dom.doc('list_managers') - @dom.marshal_with(manager_fields, as_list=True, skip_none=True, mask=None) + @dom.response(200, 'Success', manager_fields) @dom.response(400, 'Input validation exception', response_fields) + @dom.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @dom.response(404, 'domain not found', response_fields) @dom.doc(security='Bearer') @common.api_token_authorization def get(self, domain): - """ List managers of domain """ + """ List all managers of the specified domain """ if not validators.domain(domain): return { 'code': 400, 'message': f'Domain {domain} is not a valid domain'}, 400 - if not domain: + domain_found = models.Domain.query.get(domain) + if not domain_found: return { 'code': 404, 'message': f'Domain {domain} does not exist'}, 404 - domain = models.Domain.query.filter_by(name=domain) - return domain.managers + return marshal(domain_found, manager_fields), 200 @dom.doc('create_manager') @dom.expect(manager_fields_create) @dom.response(200, 'Success', response_fields) @dom.response(400, 'Input validation exception', response_fields) + @dom.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @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 """ + """ Create a new domain manager for the specified domain """ data = api.payload if not validators.email(data['user_email']): return {'code': 400, 'message': f'Invalid email address {data["user_email"]}'}, 400 @@ -273,12 +282,14 @@ class Manager(Resource): @dom.route('//manager/') class Domain(Resource): @dom.doc('find_manager') - @dom.response(200, 'Success', manager_fields) + @dom.response(200, 'Success', response_fields) + @dom.response(400, 'Input validation exception', response_fields) + @dom.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @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 """ + """ Check if the specified user is a manager of the specified domain """ if not validators.email(email): return {'code': 400, 'message': f'Invalid email address {email}'}, 400 if not validators.domain(domain): @@ -292,7 +303,7 @@ class Domain(Resource): if user in domain.managers: for manager in domain.managers: if manager.email == email: - return marshal(manager, manager_fields),200 + return { 'code': 200, 'message': f'User {email} is a manager of the domain {domain}'}, 200 else: return { 'code': 404, 'message': f'User {email} is not a manager of the domain {domain}'}, 404 @@ -300,10 +311,12 @@ class Domain(Resource): @dom.doc('delete_manager') @dom.response(200, 'Success', response_fields) @dom.response(400, 'Input validation exception', response_fields) + @dom.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @dom.response(404, 'Manager not found', response_fields) @dom.doc(security='Bearer') @common.api_token_authorization def delete(self, domain, email): + """ Delete the specified manager of the specified domain """ if not validators.email(email): return {'code': 400, 'message': f'Invalid email address {email}'}, 400 if not validators.domain(domain): @@ -324,29 +337,31 @@ class Domain(Resource): @dom.route('//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(200, 'Success', user.user_fields_get) @dom.response(400, 'Input validation exception', response_fields) + @dom.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @dom.response(404, 'Domain not found', response_fields) @dom.doc(security='Bearer') @common.api_token_authorization def get(self, domain): - """ List users from domain """ + """ List all the users from the specified 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() + return marshal(models.User.query.filter_by(domain=domain_found).all(), user.user_fields_get),200 @alt.route('') class Alternatives(Resource): @alt.doc('list_alternative') @alt.marshal_with(alternative_fields, as_list=True, skip_none=True, mask=None) + @alt.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @alt.doc(security='Bearer') @common.api_token_authorization def get(self): - """ List alternatives """ + """ List all alternatives """ return models.Alternative.query.all() @@ -354,12 +369,13 @@ class Alternatives(Resource): @alt.expect(alternative_fields) @alt.response(200, 'Success', response_fields) @alt.response(400, 'Input validation exception', response_fields) + @alt.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @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) """ + """ Create a 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 @@ -380,9 +396,13 @@ class Alternatives(Resource): class Alternative(Resource): @alt.doc('find_alternative') @alt.doc(security='Bearer') + @alt.response(200, 'Success', alternative_fields) + @alt.response(400, 'Input validation exception', response_fields) + @alt.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) + @alt.response(404, 'Alternative not found or missing', response_fields) @common.api_token_authorization def get(self, alt): - """ Find alternative (of domain) """ + """ Look up the specified 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() @@ -393,12 +413,13 @@ class Alternative(Resource): @alt.doc('delete_alternative') @alt.response(200, 'Success', response_fields) @alt.response(400, 'Input validation exception', response_fields) + @alt.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @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) """ + """ Delete the specified 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() diff --git a/core/admin/mailu/api/v1/relay.py b/core/admin/mailu/api/v1/relay.py index 356f8426..0f160947 100644 --- a/core/admin/mailu/api/v1/relay.py +++ b/core/admin/mailu/api/v1/relay.py @@ -24,25 +24,27 @@ relay_fields_update = api.model('RelayUpdate', { class Relays(Resource): @relay.doc('list_relays') @relay.marshal_with(relay_fields, as_list=True, skip_none=True, mask=None) + @relay.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @relay.doc(security='Bearer') @common.api_token_authorization def get(self): - "List relays" + "List all 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(400, 'Input validation exception', response_fields) + @relay.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @relay.response(409, 'Duplicate relay', response_fields) @relay.doc(security='Bearer') @common.api_token_authorization def post(self): - """ Create relay """ + """ Create a new relay """ data = api.payload - if not validators.domain(name): - return { 'code': 400, 'message': f'Relayed domain {name} is not a valid domain'}, 400 + if not validators.domain(data['name']): + return { 'code': 400, 'message': f'Relayed domain {data["name"]} is not a valid domain'}, 400 if common.fqdn_in_use(data['name']): return { 'code': 409, 'message': f'Duplicate domain {data["name"]}'}, 409 @@ -58,12 +60,14 @@ class Relays(Resource): @relay.route('/') class Relay(Resource): @relay.doc('find_relay') + @relay.response(200, 'Success', relay_fields) @relay.response(400, 'Input validation exception', response_fields) + @relay.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @relay.response(404, 'Relay not found', response_fields) @relay.doc(security='Bearer') @common.api_token_authorization def get(self, name): - """ Find relay """ + """ Look up the specified relay """ if not validators.domain(name): return { 'code': 400, 'message': f'Relayed domain {name} is not a valid domain'}, 400 @@ -76,12 +80,12 @@ class Relay(Resource): @relay.expect(relay_fields_update) @relay.response(200, 'Success', response_fields) @relay.response(400, 'Input validation exception', response_fields) + @relay.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @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 """ + """ Update the specified relay """ data = api.payload if not validators.domain(name): @@ -103,11 +107,12 @@ class Relay(Resource): @relay.doc('delete_relay') @relay.response(200, 'Success', response_fields) @relay.response(400, 'Input validation exception', response_fields) + @relay.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @relay.response(404, 'Relay not found', response_fields) @relay.doc(security='Bearer') @common.api_token_authorization def delete(self, name): - """ Delete relay """ + """ Delete the specified 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() diff --git a/core/admin/mailu/api/v1/token.py b/core/admin/mailu/api/v1/token.py index 0f2b5b7a..ba23dc79 100644 --- a/core/admin/mailu/api/v1/token.py +++ b/core/admin/mailu/api/v1/token.py @@ -15,20 +15,20 @@ token_user_fields = api.model('TokenGetResponse', { 'id': fields.String(description='The record id of the token (unique identifier)', example='1'), 'email': fields.String(description='The email address of the user', example='John.Doe@example.com', attribute='user_email'), 'comment': fields.String(description='A description for the token. This description is shown on the Authentication tokens page', example='my comment'), - 'AuthorizedIP': fields.String(description='Comma separated list of white listed IP addresses or networks that may use this token.', example="['203.0.113.0/24']", attribute='ip'), + 'AuthorizedIP': fields.List(fields.String(description='White listed IP addresses or networks that may use this token.', example="203.0.113.0/24"), attribute='ip'), 'Created': fields.String(description='The date when the token was created', example='John.Doe@example.com', attribute='created_at'), 'Last edit': fields.String(description='The date when the token was last modifified', example='John.Doe@example.com', attribute='updated_at') }) token_user_fields_post = api.model('TokenPost', { - 'email': fields.String(description='The email address of the user', example='John.Doe@example.com', attribute='user_email'), + 'email': fields.String(description='The email address of the user', example='John.Doe@example.com', attribute='user_email', required=True), 'comment': fields.String(description='A description for the token. This description is shown on the Authentication tokens page', example='my comment'), - 'AuthorizedIP': fields.String(description='Comma separated list of white listed IP addresses or networks that may use this token.', example="['203.0.113.0/24']", attribute='ip'), + 'AuthorizedIP': fields.List(fields.String(description='White listed IP addresses or networks that may use this token.', example="203.0.113.0/24")), }) token_user_fields_post2 = api.model('TokenPost2', { 'comment': fields.String(description='A description for the token. This description is shown on the Authentication tokens page', example='my comment'), - 'AuthorizedIP': fields.String(description='Comma separated list of white listed IP addresses or networks that may use this token.', example="['203.0.113.0/24']", attribute='ip'), + 'AuthorizedIP': fields.List(fields.String(description='White listed IP addresses or networks that may use this token.', example="203.0.113.0/24")), }) token_user_post_response = api.model('TokenPostResponse', { @@ -36,25 +36,29 @@ token_user_post_response = api.model('TokenPostResponse', { 'token': fields.String(description='The created authentication token for the user.', example='2caf6607de5129e4748a2c061aee56f2', attribute='password'), 'email': fields.String(description='The email address of the user', example='John.Doe@example.com', attribute='user_email'), 'comment': fields.String(description='A description for the token. This description is shown on the Authentication tokens page', example='my comment'), - 'AuthorizedIP': fields.String(description='Comma separated list of white listed IP addresses or networks that may use this token.', example="['203.0.113.0/24']", attribute='ip'), + 'AuthorizedIP': fields.List(fields.String(description='White listed IP addresses or networks that may use this token.', example="203.0.113.0/24")), 'Created': fields.String(description='The date when the token was created', example='John.Doe@example.com', attribute='created_at') }) + + @token.route('') class Tokens(Resource): @token.doc('list_tokens') @token.marshal_with(token_user_fields, as_list=True, skip_none=True, mask=None) + @token.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @token.doc(security='Bearer') @common.api_token_authorization def get(self): - """List tokens""" + """List all tokens""" return models.Token.query.all() @token.doc('create_token') @token.expect(token_user_fields_post) @token.response(200, 'Success', token_user_post_response) - @token.response(400, 'Input validation exception') - @token.response(409, 'Duplicate relay', response_fields) + @token.response(400, 'Input validation exception', response_fields) + @token.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) + @token.response(404, 'User not found', response_fields) @token.doc(security='Bearer') @common.api_token_authorization def post(self): @@ -72,7 +76,11 @@ class Tokens(Resource): if 'comment' in data: token_new.comment = data['comment'] if 'AuthorizedIP' in data: - token_new.ip = data['AuthorizedIP'].replace(' ','').split(',') + token_new.ip = data['AuthorizedIP'] + for ip in token_new.ip: + if (not validators.ip_address.ipv4(ip,cidr=True, strict=False, host_bit=False) and + not validators.ip_address.ipv6(ip,cidr=True, strict=False, host_bit=False)): + return { 'code': 400, 'message': f'Provided AuthorizedIP {ip} in {token_new.ip} is invalid'}, 400 raw_password = pwd.genword(entropy=128, length=32, charset="hex") token_new.set_password(raw_password) models.db.session.add(token_new) @@ -92,28 +100,43 @@ class Tokens(Resource): @token.route('user/') class Token(Resource): @token.doc('find_tokens_of_user') - @token.marshal_with(token_user_fields, as_list=True, skip_none=True, mask=None) + @token.response(200, 'Success', token_user_fields) + @token.response(400, 'Input validation exception', response_fields) + @token.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) + @token.response(404, 'Token not found', response_fields) @token.doc(security='Bearer') @common.api_token_authorization def get(self, email): - "Find tokens of user" + """ Look up all the tokens of the specified user """ if not validators.email(email): return { 'code': 400, 'message': f'Provided email address {email} is not a valid email address'}, 400 user_found = models.User.query.get(email) if not user_found: return {'code': 404, 'message': f'User {email} cannot be found'}, 404 tokens = user_found.tokens - return tokens + response_list = [] + for token in tokens: + response_dict = { + 'id' : token.id, + 'email' : token.user_email, + 'comment' : token.comment, + 'AuthorizedIP' : token.ip, + 'Created': str(token.created_at), + 'Last edit': str(token.updated_at) + } + response_list.append(response_dict) + return response_list @token.doc('create_token') @token.expect(token_user_fields_post2) @token.response(200, 'Success', token_user_post_response) - @token.response(400, 'Input validation exception') - @token.response(409, 'Duplicate relay', response_fields) + @token.response(400, 'Input validation exception', response_fields) + @token.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) + @token.response(404, 'User not found', response_fields) @token.doc(security='Bearer') @common.api_token_authorization def post(self, email): - """ Create a new token for user""" + """ Create a new token for the specified user""" data = api.payload if not validators.email(email): return { 'code': 400, 'message': f'Provided email address {email} is not a valid email address'}, 400 @@ -125,7 +148,11 @@ class Token(Resource): if 'comment' in data: token_new.comment = data['comment'] if 'AuthorizedIP' in data: - token_new.ip = token_new.ip = data['AuthorizedIP'].replace(' ','').split(',') + token_new.ip = token_new.ip = data['AuthorizedIP'] + for ip in token_new.ip: + if (not validators.ip_address.ipv4(ip,cidr=True, strict=False, host_bit=False) and + not validators.ip_address.ipv6(ip,cidr=True, strict=False, host_bit=False)): + return { 'code': 400, 'message': f'Provided AuthorizedIP {ip} in {token_new.ip} is invalid'}, 400 raw_password = pwd.genword(entropy=128, length=32, charset="hex") token_new.set_password(raw_password) models.db.session.add(token_new) @@ -144,24 +171,63 @@ class Token(Resource): @token.route('/') class Token(Resource): @token.doc('find_token') - @token.marshal_with(token_user_fields, as_list=True, skip_none=True, mask=None) + @token.response(200, 'Success', token_user_fields) + @token.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) + @token.response(404, 'Token not found', response_fields) @token.doc(security='Bearer') @common.api_token_authorization def get(self, token_id): - "Find token" + "Find the specified token" token = models.Token.query.get(token_id) if not token: return { 'code' : 404, 'message' : f'Record cannot be found for id {token_id} or invalid id provided'}, 404 - return token + response_dict = { + 'id' : token.id, + 'email' : token.user_email, + 'comment' : token.comment, + 'AuthorizedIP' : token.ip, + 'Created': str(token.created_at), + 'Last edit': str(token.updated_at) + } + return response_dict + + @token.doc('update_token') + @token.expect(token_user_fields_post2) + @token.response(200, 'Success', response_fields) + @token.response(400, 'Input validation exception', response_fields) + @token.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) + @token.response(404, 'User not found', response_fields) + @token.doc(security='Bearer') + def patch(self, token_id): + """ Update the specified token """ + data = api.payload + + token = models.Token.query.get(token_id) + if not token: + return { 'code' : 404, 'message' : f'Record cannot be found for id {token_id} or invalid id provided'}, 404 + + if 'comment' in data: + token.comment = data['comment'] + if 'AuthorizedIP' in data: + token.ip = token.ip = data['AuthorizedIP'] + for ip in token.ip: + if (not validators.ip_address.ipv4(ip,cidr=True, strict=False, host_bit=False) and + not validators.ip_address.ipv6(ip,cidr=True, strict=False, host_bit=False)): + return { 'code': 400, 'message': f'Provided AuthorizedIP {ip} in {token.ip} is invalid'}, 400 + models.db.session.add(token) + #apply the changes + db.session.commit() + return {'code': 200, 'message': f'Token with id {token_id} has been updated'}, 200 + @token.doc('delete_token') @token.response(200, 'Success', response_fields) - @token.response(400, 'Input validation exception', response_fields) + @token.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @token.response(404, 'Token not found', response_fields) @token.doc(security='Bearer') @common.api_token_authorization def delete(self, token_id): - """ Delete token """ + """ Delete the specified token """ token = models.Token.query.get(token_id) if not token: return { 'code' : 404, 'message' : f'Record cannot be found for id {token_id} or invalid id provided'}, 404 diff --git a/core/admin/mailu/api/v1/user.py b/core/admin/mailu/api/v1/user.py index 441845c2..b0c9d4da 100644 --- a/core/admin/mailu/api/v1/user.py +++ b/core/admin/mailu/api/v1/user.py @@ -22,7 +22,7 @@ user_fields_get = api.model('UserGet', { 'enable_pop': fields.Boolean(description='Allow email retrieval via POP3'), 'allow_spoofing': fields.Boolean(description='Allow the user to spoof the sender (send email as anyone)'), 'forward_enabled': fields.Boolean(description='Enable auto forwarding'), - 'forward_destination': fields.List(fields.String(description='Email address to forward emails to'), example='Other@example.com'), + 'forward_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'), @@ -47,7 +47,7 @@ user_fields_post = api.model('UserCreate', { 'enable_pop': fields.Boolean(description='Allow email retrieval via POP3'), 'allow_spoofing': fields.Boolean(description='Allow the user to spoof the sender (send email as anyone)'), 'forward_enabled': fields.Boolean(description='Enable auto forwarding'), - 'forward_destination': fields.List(fields.String(description='Email address to forward emails to'), example='Other@example.com'), + 'forward_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'), @@ -71,7 +71,7 @@ user_fields_put = api.model('UserUpdate', { 'enable_pop': fields.Boolean(description='Allow email retrieval via POP3'), 'allow_spoofing': fields.Boolean(description='Allow the user to spoof the sender (send email as anyone)'), 'forward_enabled': fields.Boolean(description='Enable auto forwarding'), - 'forward_destination': fields.List(fields.String(description='Email address to forward emails to'), example='Other@example.com'), + 'forward_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'), @@ -87,30 +87,44 @@ user_fields_put = api.model('UserUpdate', { @user.route('') class Users(Resource): - @user.doc('list_users') + @user.doc('list_user') @user.marshal_with(user_fields_get, as_list=True, skip_none=True, mask=None) + @user.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @user.doc(security='Bearer') @common.api_token_authorization def get(self): - "List users" + "List all 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(400, 'Input validation exception', response_fields) + @user.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @user.response(409, 'Duplicate user', response_fields) @user.doc(security='Bearer') @common.api_token_authorization def post(self): - """ Create user """ + """ Create a new 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 + if 'forward_destination' in data and len(data['forward_destination']) > 0: + for dest in data['forward_destination']: + if not validators.email(dest): + return { 'code': 400, 'message': f'Provided forward destination email address {dest} 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 + if not domain_found.max_users == -1 and len(domain_found.users) >= domain_found.max_users: + return { 'code': 409, 'message': f'Too many users for domain {domain_name}'}, 409 + email_found = models.User.query.filter_by(email=data['email']).first() + if email_found: + return { 'code': 409, 'message': f'User {data["email"]} already exists'}, 409 + if 'forward_enabled' in data and data['forward_enabled'] is True: + if ('forward_destination' in data and len(data['forward_destination']) == 0) or 'forward_destination' not in data: + return { 'code': 400, 'message': f'forward_destination is mandatory when forward_enabled is true'}, 400 user_new = models.User(email=data['email']) if 'raw_password' in data: @@ -133,7 +147,7 @@ class Users(Resource): user_new.allow_spoofing = data['allow_spoofing'] if 'forward_enabled' in data: user_new.forward_enabled = data['forward_enabled'] - if 'forward_destination' in data: + if 'forward_destination' in data and len(data['forward_destination']) > 0: user_new.forward_destination = data['forward_destination'] if 'forward_keep' in data: user_new.forward_keep = data['forward_keep'] @@ -164,16 +178,17 @@ class Users(Resource): return {'code': 200,'message': f'User {data["email"]} has been created'}, 200 - @user.route('/') class User(Resource): @user.doc('find_user') + @user.response(200, 'Success', user_fields_get) @user.response(400, 'Input validation exception', response_fields) + @user.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @user.response(404, 'User not found', response_fields) @user.doc(security='Bearer') @common.api_token_authorization def get(self, email): - """ Find user """ + """ Look up the specified user """ if not validators.email(email): return { 'code': 400, 'message': f'Provided email address {email} is not a valid email address'}, 400 @@ -186,18 +201,25 @@ class User(Resource): @user.expect(user_fields_put) @user.response(200, 'Success', response_fields) @user.response(400, 'Input validation exception', response_fields) + @user.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @user.response(404, 'User not found', response_fields) - @user.response(409, 'Duplicate user', response_fields) @user.doc(security='Bearer') @common.api_token_authorization def patch(self, email): - """ Update user """ + """ Update the specified 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 + return { 'code': 400, 'message': f'Provided email address {email} is not a valid email address'}, 400 + if 'forward_destination' in data and len(data['forward_destination']) > 0: + for dest in data['forward_destination']: + if not validators.email(dest): + return { 'code': 400, 'message': f'Provided forward destination email address {dest} is not a valid email address'}, 400 user_found = models.User.query.get(email) if not user_found: return {'code': 404, 'message': f'User {email} cannot be found'}, 404 + if ('forward_enabled' in data and data['forward_enabled'] is True) or ('forward_enabled' not in data and user_found.forward_enabled): + if ('forward_destination' in data and len(data['forward_destination']) == 0): + return { 'code': 400, 'message': f'forward_destination is mandatory when forward_enabled is true'}, 400 if 'raw_password' in data: user_found.set_password(data['raw_password']) @@ -219,8 +241,9 @@ class User(Resource): user_found.allow_spoofing = data['allow_spoofing'] if 'forward_enabled' in data: user_found.forward_enabled = data['forward_enabled'] - if 'forward_destination' in data: - user_found.forward_destination = data['forward_destination'] + if 'forward_destination' in data and len(data['forward_destination']) > 0: + if len(data['forward_destination']) == 0: + user_found.forward_destination = data['forward_destination'] if 'forward_keep' in data: user_found.forward_keep = data['forward_keep'] if 'reply_enabled' in data: @@ -254,11 +277,12 @@ class User(Resource): @user.doc('delete_user') @user.response(200, 'Success', response_fields) @user.response(400, 'Input validation exception', response_fields) + @user.doc(responses={401: 'Authorization header missing', 403: 'Invalid authorization header'}) @user.response(404, 'User not found', response_fields) @user.doc(security='Bearer') @common.api_token_authorization def delete(self, email): - """ Delete user """ + """ Delete the specified user """ if not validators.email(email): return { 'code': 400, 'message': f'Provided email address {email} is not a valid email address'}, 400 diff --git a/core/admin/mailu/internal/templates/default.sieve b/core/admin/mailu/internal/templates/default.sieve index 0e97c067..d5c28c8a 100644 --- a/core/admin/mailu/internal/templates/default.sieve +++ b/core/admin/mailu/internal/templates/default.sieve @@ -31,6 +31,6 @@ if spamtest :percent :value "gt" :comparator "i;ascii-numeric" "{{ user.spam_thr {% if user.reply_active %} if not address :localpart :contains ["From","Reply-To"] ["noreply","no-reply"]{ - vacation :days 1 {% if user.displayed_name != "" %}:from "{{ user.displayed_name }} <{{ user.email }}>"{% endif %} :subject "{{ user.reply_subject }}" "{{ user.reply_body }}"; + vacation :days 1 {% if user.displayed_name != "" %}:from "{{ user.displayed_name | replace("\"", "\\\"") }} <{{ user.email | replace("\"", "\\\"") }}>"{% endif %} :subject "{{ user.reply_subject | replace("\"", "\\\"") }}" "{{ user.reply_body | replace("\"", "\\\"") }}"; } {% endif %} diff --git a/core/admin/mailu/translations/be_BY/LC_MESSAGES/messages.po b/core/admin/mailu/translations/be_BY/LC_MESSAGES/messages.po new file mode 100644 index 00000000..474af0ee --- /dev/null +++ b/core/admin/mailu/translations/be_BY/LC_MESSAGES/messages.po @@ -0,0 +1,741 @@ +msgid "" +msgstr "" +"Project-Id-Version: Mailu\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2022-05-22 18:47+0200\n" +"PO-Revision-Date: 2024-03-30 12:18+0100\n" +"Last-Translator: spoooyders \n" +"Language-Team: \n" +"Language: be_BY\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && " +"(n%100<12 || n%100>14) ? 1 : 2);\n" +"Generated-By: Babel 2.3.4\n" +"X-Generator: Poedit 3.4.2\n" + +#: mailu/sso/forms.py:8 mailu/ui/forms.py:79 +msgid "E-mail" +msgstr "Электронная пошта" + +#: mailu/sso/forms.py:9 mailu/ui/forms.py:80 mailu/ui/forms.py:93 mailu/ui/forms.py:112 +#: mailu/ui/forms.py:166 mailu/ui/templates/client.html:32 +#: mailu/ui/templates/client.html:57 +msgid "Password" +msgstr "Пароль" + +#: mailu/sso/forms.py:10 mailu/sso/forms.py:11 mailu/sso/templates/login.html:4 +#: mailu/ui/templates/sidebar.html:142 +msgid "Sign in" +msgstr "Увайсьці" + +#: mailu/sso/templates/base_sso.html:8 mailu/ui/templates/base.html:8 +msgid "Admin page for" +msgstr "Старонка адміністратара для" + +#: mailu/sso/templates/base_sso.html:19 mailu/ui/templates/base.html:19 +msgid "toggle sidebar" +msgstr "уключыць бакавую панэль" + +#: mailu/sso/templates/base_sso.html:37 mailu/ui/templates/base.html:37 +msgid "change language" +msgstr "зьмяніць мову" + +#: mailu/sso/templates/sidebar_sso.html:4 mailu/ui/templates/sidebar.html:94 +msgid "Go to" +msgstr "Перайсьці да" + +#: mailu/sso/templates/sidebar_sso.html:9 mailu/ui/templates/client.html:4 +#: mailu/ui/templates/sidebar.html:50 mailu/ui/templates/sidebar.html:107 +msgid "Client setup" +msgstr "Налады кліента" + +#: mailu/sso/templates/sidebar_sso.html:16 mailu/ui/templates/sidebar.html:114 +msgid "Website" +msgstr "Сайт" + +#: mailu/sso/templates/sidebar_sso.html:22 mailu/ui/templates/sidebar.html:120 +msgid "Help" +msgstr "Дапамога" + +#: mailu/sso/templates/sidebar_sso.html:35 mailu/ui/templates/domain/signup.html:4 +#: mailu/ui/templates/sidebar.html:127 +msgid "Register a domain" +msgstr "Зарэгістраваць дамен" + +#: mailu/sso/templates/sidebar_sso.html:49 mailu/ui/forms.py:95 +#: mailu/ui/templates/sidebar.html:149 mailu/ui/templates/user/signup.html:4 +#: mailu/ui/templates/user/signup_domain.html:4 +msgid "Sign up" +msgstr "Рэгістрацыя" + +#: mailu/ui/forms.py:33 mailu/ui/forms.py:36 +msgid "Invalid email address." +msgstr "Няправільны адрас электроннай пошты." + +#: mailu/ui/forms.py:45 +msgid "Confirm" +msgstr "Пацвердзіць" + +#: mailu/ui/forms.py:48 mailu/ui/forms.py:58 mailu/ui/templates/domain/details.html:26 +#: mailu/ui/templates/domain/list.html:19 mailu/ui/templates/relay/list.html:18 +msgid "Domain name" +msgstr "Даменнае імя" + +#: mailu/ui/forms.py:49 +msgid "Maximum user count" +msgstr "Максымальная колькасьць карыстальнікаў" + +#: mailu/ui/forms.py:50 +msgid "Maximum alias count" +msgstr "Максымальная колькасьць псеўданімаў" + +#: mailu/ui/forms.py:51 +msgid "Maximum user quota" +msgstr "Максымальная квота карыстальніка" + +#: mailu/ui/forms.py:52 +msgid "Enable sign-up" +msgstr "Дазволіць рэгістрацыю" + +#: mailu/ui/forms.py:53 mailu/ui/forms.py:74 mailu/ui/forms.py:86 mailu/ui/forms.py:132 +#: mailu/ui/forms.py:144 mailu/ui/templates/alias/list.html:22 +#: mailu/ui/templates/domain/list.html:22 mailu/ui/templates/relay/list.html:20 +#: mailu/ui/templates/token/list.html:20 mailu/ui/templates/user/list.html:24 +msgid "Comment" +msgstr "Камэнтары" + +#: mailu/ui/forms.py:54 mailu/ui/forms.py:68 mailu/ui/forms.py:75 mailu/ui/forms.py:88 +#: mailu/ui/forms.py:136 mailu/ui/forms.py:145 +msgid "Save" +msgstr "Захаваць" + +#: mailu/ui/forms.py:59 +msgid "Initial admin" +msgstr "Пачатковы адміністратар" + +#: mailu/ui/forms.py:60 +msgid "Admin password" +msgstr "Пароль адміністратара" + +#: mailu/ui/forms.py:61 mailu/ui/forms.py:81 mailu/ui/forms.py:94 +msgid "Confirm password" +msgstr "Пацьвердзіць пароль" + +#: mailu/ui/forms.py:63 +msgid "Create" +msgstr "Стварыць" + +#: mailu/ui/forms.py:67 +msgid "Alternative name" +msgstr "Імя альтэрнатыўнага дамену" + +#: mailu/ui/forms.py:72 +msgid "Relayed domain name" +msgstr "Імя рэлейнага дамену" + +#: mailu/ui/forms.py:73 mailu/ui/templates/relay/list.html:19 +msgid "Remote host" +msgstr "Аддалены хост" + +#: mailu/ui/forms.py:82 mailu/ui/templates/user/list.html:23 +#: mailu/ui/templates/user/signup_domain.html:16 +msgid "Quota" +msgstr "Квота" + +#: mailu/ui/forms.py:83 +msgid "Allow IMAP access" +msgstr "Дазволіць доступ праз IMAP" + +#: mailu/ui/forms.py:84 +msgid "Allow POP3 access" +msgstr "Дазволіць доступ праз POP3" + +#: mailu/ui/forms.py:85 mailu/ui/forms.py:101 mailu/ui/templates/user/settings.html:15 +msgid "Displayed name" +msgstr "Паказваць імя" + +#: mailu/ui/forms.py:87 +msgid "Enabled" +msgstr "Уключана" + +#: mailu/ui/forms.py:92 +msgid "Email address" +msgstr "Паштовы адрас" + +#: mailu/ui/forms.py:102 +msgid "Enable spam filter" +msgstr "Уключыць спам-фільтар" + +#: mailu/ui/forms.py:103 +msgid "Enable marking spam mails as read" +msgstr "Пазначаць спам паведамленьні як прачытаныя" + +#: mailu/ui/forms.py:104 +msgid "Spam filter tolerance" +msgstr "Парог спам-фільтру" + +#: mailu/ui/forms.py:105 +msgid "Enable forwarding" +msgstr "Уключыць пераадрасацыю" + +#: mailu/ui/forms.py:106 +msgid "Keep a copy of the emails" +msgstr "Захоўваць копіі лістоў" + +#: mailu/ui/forms.py:107 mailu/ui/forms.py:143 mailu/ui/templates/alias/list.html:21 +msgid "Destination" +msgstr "Адрас атрымальніка" + +#: mailu/ui/forms.py:108 +msgid "Save settings" +msgstr "Захаваць налады" + +#: mailu/ui/forms.py:113 +msgid "Password check" +msgstr "Праверка пароля" + +#: mailu/ui/forms.py:114 mailu/ui/templates/sidebar.html:25 +msgid "Update password" +msgstr "Абнавіць пароль" + +#: mailu/ui/forms.py:118 +msgid "Enable automatic reply" +msgstr "Уключыць аўтаадказчык" + +#: mailu/ui/forms.py:119 +msgid "Reply subject" +msgstr "Загаловак адказу" + +#: mailu/ui/forms.py:120 +msgid "Reply body" +msgstr "Зьмест адказу" + +#: mailu/ui/forms.py:122 +msgid "Start of vacation" +msgstr "Пачатак водпуску" + +#: mailu/ui/forms.py:123 +msgid "End of vacation" +msgstr "Канец водпуску" + +#: mailu/ui/forms.py:124 +msgid "Update" +msgstr "Абнавіць" + +#: mailu/ui/forms.py:129 +msgid "Your token (write it down, as it will never be displayed again)" +msgstr "Ваш токен (запішыце яго, бо ён больш не будзе паказвацца)" + +#: mailu/ui/forms.py:134 mailu/ui/templates/token/list.html:21 +msgid "Authorized IP" +msgstr "Аўтарызаваныя IP-адрасы" + +#: mailu/ui/forms.py:140 +msgid "Alias" +msgstr "Псэўданім" + +#: mailu/ui/forms.py:142 +msgid "Use SQL LIKE Syntax (e.g. for catch-all aliases)" +msgstr "Выкарыстоўваць SQL-падобны сынтаксіс (напрылкад, для ўсеагульных псэўданімаў)" + +#: mailu/ui/forms.py:149 +msgid "Admin email" +msgstr "Адрас адміністратара" + +#: mailu/ui/forms.py:150 mailu/ui/forms.py:155 mailu/ui/forms.py:168 +msgid "Submit" +msgstr "Прыняць" + +#: mailu/ui/forms.py:154 +msgid "Manager email" +msgstr "Адрас мэнэджэра" + +#: mailu/ui/forms.py:159 +msgid "Protocol" +msgstr "Пратакол" + +#: mailu/ui/forms.py:162 +msgid "Hostname or IP" +msgstr "Імя хосту альбо IP" + +#: mailu/ui/forms.py:163 mailu/ui/templates/client.html:20 +#: mailu/ui/templates/client.html:45 +msgid "TCP port" +msgstr "Порт TCP" + +#: mailu/ui/forms.py:164 +msgid "Enable TLS" +msgstr "Уключыць TLS" + +#: mailu/ui/forms.py:165 mailu/ui/templates/client.html:28 +#: mailu/ui/templates/client.html:53 mailu/ui/templates/fetch/list.html:21 +msgid "Username" +msgstr "Імя карыстальніка" + +#: mailu/ui/forms.py:167 +msgid "Keep emails on the server" +msgstr "Захоўваць лісты на сэрвэры" + +#: mailu/ui/forms.py:172 +msgid "Announcement subject" +msgstr "Тэма абвесткі" + +#: mailu/ui/forms.py:174 +msgid "Announcement body" +msgstr "Зьмест абвесткі" + +#: mailu/ui/forms.py:176 +msgid "Send" +msgstr "Адаслаць" + +#: mailu/ui/templates/announcement.html:4 +msgid "Public announcement" +msgstr "Публічная аб'ява" + +#: mailu/ui/templates/antispam.html:4 mailu/ui/templates/sidebar.html:80 +#: mailu/ui/templates/user/settings.html:19 +msgid "Antispam" +msgstr "Антыспам" + +#: mailu/ui/templates/antispam.html:8 +msgid "RSPAMD status page" +msgstr "Старонка статусу RSPAMD" + +#: mailu/ui/templates/client.html:8 +msgid "configure your email client" +msgstr "наладзьце свой паштовы кліент" + +#: mailu/ui/templates/client.html:13 +msgid "Incoming mail" +msgstr "Уваходная пошта" + +#: mailu/ui/templates/client.html:16 mailu/ui/templates/client.html:41 +msgid "Mail protocol" +msgstr "Паштовы пратакол" + +#: mailu/ui/templates/client.html:24 mailu/ui/templates/client.html:49 +msgid "Server name" +msgstr "Імя сэрвэру" + +#: mailu/ui/templates/client.html:38 +msgid "Outgoing mail" +msgstr "Выходная пошта" + +#: mailu/ui/templates/confirm.html:4 +msgid "Confirm action" +msgstr "Пацьвердзіце дзеяньне" + +#: mailu/ui/templates/confirm.html:13 +#, python-format +msgid "You are about to %(action)s. Please confirm your action." +msgstr "Вы зьбіраецеся зьдзейсьніць %(action)s. Калі ласка пацьвердзіце ваша дзеяньне." + +#: mailu/ui/templates/docker-error.html:4 +msgid "Docker error" +msgstr "Памылка Docker" + +#: mailu/ui/templates/docker-error.html:12 +msgid "An error occurred while talking to the Docker server." +msgstr "Адбылася памылка пры зьвяртаньні да сэрвэру Docker." + +#: mailu/ui/templates/macros.html:129 +msgid "copy to clipboard" +msgstr "скапіраваць у буфэр абмену" + +#: mailu/ui/templates/sidebar.html:15 +msgid "My account" +msgstr "Мой уліковы запіс" + +#: mailu/ui/templates/sidebar.html:19 mailu/ui/templates/user/list.html:37 +msgid "Settings" +msgstr "Налады" + +#: mailu/ui/templates/sidebar.html:31 mailu/ui/templates/user/list.html:38 +msgid "Auto-reply" +msgstr "Аўтаматычны адказ" + +#: mailu/ui/templates/fetch/list.html:4 mailu/ui/templates/sidebar.html:37 +#: mailu/ui/templates/user/list.html:39 +msgid "Fetched accounts" +msgstr "Уліковая запісы пабочных сэрвэраў" + +#: mailu/ui/templates/sidebar.html:43 mailu/ui/templates/token/list.html:4 +msgid "Authentication tokens" +msgstr "Аўтэнтыфікацыйныя токены" + +#: mailu/ui/templates/sidebar.html:56 +msgid "Administration" +msgstr "Адміністраваньне" + +#: mailu/ui/templates/sidebar.html:62 +msgid "Announcement" +msgstr "Абвестка" + +#: mailu/ui/templates/sidebar.html:68 +msgid "Administrators" +msgstr "Адміністратары" + +#: mailu/ui/templates/sidebar.html:74 +msgid "Relayed domains" +msgstr "Рэлейныя дамены" + +#: mailu/ui/templates/sidebar.html:88 +msgid "Mail domains" +msgstr "Паштовыя дамены" + +#: mailu/ui/templates/sidebar.html:99 +msgid "Webmail" +msgstr "Электронная пошта" + +#: mailu/ui/templates/sidebar.html:135 +msgid "Sign out" +msgstr "Выйсьці" + +#: mailu/ui/templates/working.html:4 +msgid "We are still working on this feature!" +msgstr "Мы яшчэ працуем над дадзеным функцыяналам!" + +#: mailu/ui/templates/admin/create.html:4 +msgid "Add a global administrator" +msgstr "Дадаць глябальнага адміністратара" + +#: mailu/ui/templates/admin/list.html:4 +msgid "Global administrators" +msgstr "Глябальныя адміністратары" + +#: mailu/ui/templates/admin/list.html:9 +msgid "Add administrator" +msgstr "Дадаць адміністратара" + +#: mailu/ui/templates/admin/list.html:17 mailu/ui/templates/alias/list.html:19 +#: mailu/ui/templates/alternative/list.html:19 mailu/ui/templates/domain/list.html:17 +#: mailu/ui/templates/fetch/list.html:19 mailu/ui/templates/manager/list.html:19 +#: mailu/ui/templates/relay/list.html:17 mailu/ui/templates/token/list.html:19 +#: mailu/ui/templates/user/list.html:19 +msgid "Actions" +msgstr "Дзеяньні" + +#: mailu/ui/templates/admin/list.html:18 mailu/ui/templates/alias/list.html:20 +#: mailu/ui/templates/manager/list.html:20 mailu/ui/templates/user/list.html:21 +msgid "Email" +msgstr "Электронная пошта" + +#: mailu/ui/templates/admin/list.html:25 mailu/ui/templates/alias/list.html:32 +#: mailu/ui/templates/alternative/list.html:29 mailu/ui/templates/domain/list.html:34 +#: mailu/ui/templates/fetch/list.html:34 mailu/ui/templates/manager/list.html:27 +#: mailu/ui/templates/relay/list.html:30 mailu/ui/templates/token/list.html:30 +#: mailu/ui/templates/user/list.html:34 +msgid "Delete" +msgstr "Выдаліць" + +#: mailu/ui/templates/alias/create.html:4 +msgid "Create alias" +msgstr "Стварыць псэўданім" + +#: mailu/ui/templates/alias/edit.html:4 +msgid "Edit alias" +msgstr "Зьмяніць псэўданім" + +#: mailu/ui/templates/alias/list.html:4 +msgid "Alias list" +msgstr "Сьпіс псэўданімаў" + +#: mailu/ui/templates/alias/list.html:12 +msgid "Add alias" +msgstr "Дадаць псэўданім" + +#: mailu/ui/templates/alias/list.html:23 mailu/ui/templates/alternative/list.html:21 +#: mailu/ui/templates/domain/list.html:23 mailu/ui/templates/fetch/list.html:25 +#: mailu/ui/templates/relay/list.html:21 mailu/ui/templates/token/list.html:22 +#: mailu/ui/templates/user/list.html:25 +msgid "Created" +msgstr "Створана" + +#: mailu/ui/templates/alias/list.html:24 mailu/ui/templates/alternative/list.html:22 +#: mailu/ui/templates/domain/list.html:24 mailu/ui/templates/fetch/list.html:26 +#: mailu/ui/templates/relay/list.html:22 mailu/ui/templates/token/list.html:23 +#: mailu/ui/templates/user/list.html:26 +msgid "Last edit" +msgstr "Апошняя зьмена" + +#: mailu/ui/templates/alias/list.html:31 mailu/ui/templates/domain/list.html:33 +#: mailu/ui/templates/fetch/list.html:33 mailu/ui/templates/relay/list.html:29 +#: mailu/ui/templates/user/list.html:33 +msgid "Edit" +msgstr "Зьмяніць" + +#: mailu/ui/templates/alternative/create.html:4 +msgid "Create alternative domain" +msgstr "Стварыць альтэрнатыўны дамен" + +#: mailu/ui/templates/alternative/list.html:4 +msgid "Alternative domain list" +msgstr "Сьпіс альтэрнатыўных даменаў" + +#: mailu/ui/templates/alternative/list.html:12 +msgid "Add alternative" +msgstr "Дадаць альтэрнатыўны дамен" + +#: mailu/ui/templates/alternative/list.html:20 +msgid "Name" +msgstr "Імя" + +#: mailu/ui/templates/domain/create.html:4 mailu/ui/templates/domain/list.html:9 +msgid "New domain" +msgstr "Новы дамен" + +#: mailu/ui/templates/domain/details.html:4 +msgid "Domain details" +msgstr "Падрабязнасьці дамену" + +#: mailu/ui/templates/domain/details.html:15 +msgid "Regenerate keys" +msgstr "Згенерыраваць ключы нанова" + +#: mailu/ui/templates/domain/details.html:17 +msgid "Generate keys" +msgstr "Згенерыраваць ключы" + +#: mailu/ui/templates/domain/details.html:30 +msgid "DNS MX entry" +msgstr "Запіс DNS MX" + +#: mailu/ui/templates/domain/details.html:34 +msgid "DNS SPF entries" +msgstr "Запісы DNS SPF" + +#: mailu/ui/templates/domain/details.html:40 +msgid "DKIM public key" +msgstr "Публічны ключ DKIM" + +#: mailu/ui/templates/domain/details.html:44 +msgid "DNS DKIM entry" +msgstr "Запіс DNS DKIM" + +#: mailu/ui/templates/domain/details.html:48 +msgid "DNS DMARC entry" +msgstr "Запіс DNS DMARC" + +#: mailu/ui/templates/domain/details.html:58 +msgid "DNS TLSA entry" +msgstr "Запіс DNS TLSA" + +#: mailu/ui/templates/domain/details.html:63 +msgid "DNS client auto-configuration entries" +msgstr "Запісы аўтаканфігурацыі DNS" + +#: mailu/ui/templates/domain/edit.html:4 +msgid "Edit domain" +msgstr "Зьмяніць дамен" + +#: mailu/ui/templates/domain/list.html:4 +msgid "Domain list" +msgstr "Сьпіс даменаў" + +#: mailu/ui/templates/domain/list.html:18 +msgid "Manage" +msgstr "Кіраваньне" + +#: mailu/ui/templates/domain/list.html:20 +msgid "Mailbox count" +msgstr "Колькасьць паштовых скрынь" + +#: mailu/ui/templates/domain/list.html:21 +msgid "Alias count" +msgstr "Колькасьць псэўданімаў" + +#: mailu/ui/templates/domain/list.html:31 +msgid "Details" +msgstr "Падрабязна" + +#: mailu/ui/templates/domain/list.html:38 +msgid "Users" +msgstr "Карыстальнікі" + +#: mailu/ui/templates/domain/list.html:39 +msgid "Aliases" +msgstr "Псэўданімы" + +#: mailu/ui/templates/domain/list.html:40 +msgid "Managers" +msgstr "Мэнэджэры" + +#: mailu/ui/templates/domain/list.html:42 +msgid "Alternatives" +msgstr "Альтэрнатыўныя дамены" + +#: mailu/ui/templates/domain/signup.html:13 +msgid "" +"In order to register a new domain, you must first setup the\n" +" domain zone so that the domain MX points to this server" +msgstr "" +"Каб зарэгістраваць новы дамен, вы мусіце спачатку наладзіць\n" +"    даменная зона, так каб дамен MX паказваў на гэты сэрвэр" + +#: mailu/ui/templates/domain/signup.html:18 +msgid "" +"If you do not know how to setup an MX record for your DNS zone,\n" +" please contact your DNS provider or administrator. Also, please wait a\n" +" couple minutes after the MX is set so the local server cache\n" +" expires." +msgstr "" +"Калі вы не ведаеце як наладзіць запіс MX для сваёй зоны DNS,\n" +"    калі ласка, зьвяжыцеся з вашым пастаўшчыком DNS альбо адміністратарам. Таксама, " +"калі ласка, пачакайце\n" +"    некалькі хвілін пасьля таго як запіс MX быў зададзены, каб " +"скончыўся тэрмін дзеяньня кэшу\n" +"    лякальнага сэрвэру." + +#: mailu/ui/templates/fetch/create.html:4 +msgid "Add a fetched account" +msgstr "Дадаць уліковы запіс пабочнага сэрвэру" + +#: mailu/ui/templates/fetch/edit.html:4 +msgid "Update a fetched account" +msgstr "Абнавіць уліковы запіс пабочнага сэрвэру" + +#: mailu/ui/templates/fetch/list.html:12 +msgid "Add an account" +msgstr "Дадаць уліковы запіс" + +#: mailu/ui/templates/fetch/list.html:20 +msgid "Endpoint" +msgstr "Канчатковы пункт" + +#: mailu/ui/templates/fetch/list.html:22 +msgid "Keep emails" +msgstr "Захоўваць лісты" + +#: mailu/ui/templates/fetch/list.html:23 +msgid "Last check" +msgstr "Апошняя праверка" + +#: mailu/ui/templates/fetch/list.html:24 +msgid "Status" +msgstr "Статус" + +#: mailu/ui/templates/fetch/list.html:38 +msgid "yes" +msgstr "так" + +#: mailu/ui/templates/fetch/list.html:38 +msgid "no" +msgstr "не" + +#: mailu/ui/templates/manager/create.html:4 +msgid "Add a manager" +msgstr "Дадаць мэнэджара" + +#: mailu/ui/templates/manager/list.html:4 +msgid "Manager list" +msgstr "Сьпіс мэнэджараў" + +#: mailu/ui/templates/manager/list.html:12 +msgid "Add manager" +msgstr "Дадаць мэнэджэра" + +#: mailu/ui/templates/relay/create.html:4 +msgid "New relay domain" +msgstr "Новы рэлейны дамен" + +#: mailu/ui/templates/relay/edit.html:4 +msgid "Edit relayed domain" +msgstr "Зьмяніць рэлейным дамен" + +#: mailu/ui/templates/relay/list.html:4 +msgid "Relayed domain list" +msgstr "Сьпіс рэлейных даменаў" + +#: mailu/ui/templates/relay/list.html:9 +msgid "New relayed domain" +msgstr "Новы рэлейны дамен" + +#: mailu/ui/templates/token/create.html:4 +msgid "Create an authentication token" +msgstr "Стварыць аўтэнтыфікацыйны токен" + +#: mailu/ui/templates/token/list.html:12 +msgid "New token" +msgstr "Новы токен" + +#: mailu/ui/templates/user/create.html:4 +msgid "New user" +msgstr "Новы карыстальнік" + +#: mailu/ui/templates/user/create.html:15 +msgid "General" +msgstr "Агульныя" + +#: mailu/ui/templates/user/create.html:23 +msgid "Features and quotas" +msgstr "Функцыі і квоты" + +#: mailu/ui/templates/user/edit.html:4 +msgid "Edit user" +msgstr "Зьмяніць карыстальніка" + +#: mailu/ui/templates/user/list.html:4 +msgid "User list" +msgstr "Сьпіс карыстальнікаў" + +#: mailu/ui/templates/user/list.html:12 +msgid "Add user" +msgstr "Дадаць карыстальніка" + +#: mailu/ui/templates/user/list.html:20 mailu/ui/templates/user/settings.html:4 +msgid "User settings" +msgstr "Налады карыстальніка" + +#: mailu/ui/templates/user/list.html:22 +msgid "Features" +msgstr "Функцыі" + +#: mailu/ui/templates/user/password.html:4 +msgid "Password update" +msgstr "Зьмена паролю" + +#: mailu/ui/templates/user/reply.html:4 +msgid "Automatic reply" +msgstr "Аўтаматычны адказ" + +#: mailu/ui/templates/user/settings.html:27 +msgid "Auto-forward" +msgstr "Аўтаматычная перасылка" + +#: mailu/ui/templates/user/signup_domain.html:8 +msgid "pick a domain for the new account" +msgstr "выбраць дамен для новага ўліковага запісу" + +#: mailu/ui/templates/user/signup_domain.html:14 +msgid "Domain" +msgstr "Дамен" + +#: mailu/ui/templates/user/signup_domain.html:15 +msgid "Available slots" +msgstr "Наяўныя адтуліны" + +#: mailu/ui/templates/client.html:62 +msgid "If you use an Apple device," +msgstr "Калі вы выкарыстоўваеце прыладу Apple," + +#: mailu/ui/templates/client.html:63 +msgid "click here to auto-configure it." +msgstr "націсьніце сюды каб аўтаматычна наладзіць яе." + +#: mailu/ui/templates/domain/details.html:19 +msgid "Download zonefile" +msgstr "Спампаваць файл зоны" + +#: mailu/ui/forms.py:134 +msgid "Current password" +msgstr "Бягучы пароль" + +#: mailu/ui/forms.py:102 +msgid "Force password change at next login" +msgstr "Прымусова зьмяніць пароль пры наступным уваходзе" + +#: mailu/ui/forms.py:98 +msgid "Allow the user to spoof the sender (send email as anyone)" +msgstr "Дазволіць карыстальніку падрабляць адпраўшчыка (адсылаць пошту як хто заўгодна)" diff --git a/core/admin/mailu/translations/de/LC_MESSAGES/messages.po b/core/admin/mailu/translations/de/LC_MESSAGES/messages.po index afd9abda..0f77e563 100644 --- a/core/admin/mailu/translations/de/LC_MESSAGES/messages.po +++ b/core/admin/mailu/translations/de/LC_MESSAGES/messages.po @@ -307,6 +307,14 @@ msgstr "Antispam" msgid "RSPAMD status page" msgstr "RSPAMD Statusseite" +#: mailu/ui/templates/client.html:62 +msgid "If you use an Apple device," +msgstr "Falls es sich um ein Apple-Gerät handelt," + +#: mailu/ui/templates/client.html:63 +msgid "click here to auto-configure it." +msgstr "kann dieses hier automatisch eingerichtet werden." + #: mailu/ui/templates/client.html:8 msgid "configure your email client" msgstr "Informationen zur Einrichtung Ihres Email-Clients" diff --git a/core/admin/mailu/translations/fr/LC_MESSAGES/messages.po b/core/admin/mailu/translations/fr/LC_MESSAGES/messages.po index 8ee21235..214fa521 100644 --- a/core/admin/mailu/translations/fr/LC_MESSAGES/messages.po +++ b/core/admin/mailu/translations/fr/LC_MESSAGES/messages.po @@ -84,6 +84,22 @@ msgstr "Confirmer" msgid "Domain name" msgstr "Nom de domaine" +#: mailu/ui/forms.py:134 +msgid "Current password" +msgstr "Mot de passe actuel" + +#: mailu/ui/forms.py:98 +msgid "Allow the user to spoof the sender (send email as anyone)" +msgstr "Permettre à l'utilisateur de changer l'expéditeur (envoyer un e-mail en tant que n'importe qui)" + +#: mailu/ui/forms.py:102 +msgid "Force password change at next login" +msgstr "Forcer le changement du mot de passe à la prochaine connexion" + +#: mailu/ui/templates/domain/details.html:19 +msgid "Download zonefile" +msgstr "Télécharger le fichier de zone" + #: mailu/ui/forms.py:49 msgid "Maximum user count" msgstr "Nombre maximum d'utilisateurs" @@ -269,6 +285,14 @@ msgstr "Nom d'hôte ou adresse IP" msgid "TCP port" msgstr "Port TCP" +#: mailu/ui/templates/client.html:62 +msgid "If you use an Apple device," +msgstr "Si vous utilisez un appareil Apple," + +#: mailu/ui/templates/client.html:63 +msgid "click here to auto-configure it." +msgstr "cliquez ici pour le configurer automatiquement." + #: mailu/ui/forms.py:164 msgid "Enable TLS" msgstr "Activer TLS" diff --git a/core/admin/mailu/translations/nl/LC_MESSAGES/messages.po b/core/admin/mailu/translations/nl/LC_MESSAGES/messages.po index 18728723..dee24864 100644 --- a/core/admin/mailu/translations/nl/LC_MESSAGES/messages.po +++ b/core/admin/mailu/translations/nl/LC_MESSAGES/messages.po @@ -24,7 +24,7 @@ msgstr "E-mail" msgid "Password" msgstr "Wachtwoord" -#: mailu/sso/forms.py:10 mailu/sso/forms.py:11 mailu/sso/templates/login.html:4 +#: mailu/sso/forms.py:11 mailu/sso/forms.py:12 mailu/sso/templates/login.html:4 #: mailu/ui/templates/sidebar.html:142 msgid "Sign in" msgstr "Aanmelden" @@ -83,6 +83,14 @@ msgstr "Bevestigen" msgid "Domain name" msgstr "Domeinnaam" +#: mailu/ui/templates/domain/details.html:19 +msgid "Download zonefile" +msgstr "Download zone-bestand" + +#: mailu/ui/forms.py:98 +msgid "Allow the user to spoof the sender (send email as anyone)" +msgstr "Sta toe dat de gebruiker de verzender kan vervalsen (namens iedereen email versturen)" + #: mailu/ui/forms.py:49 msgid "Maximum user count" msgstr "Maximaal aantal gebruikers" @@ -99,6 +107,10 @@ msgstr "Maximum quotum gebruikers" msgid "Enable sign-up" msgstr "Schakel registreren in" +#: mailu/ui/forms.py:102 +msgid "Force password change at next login" +msgstr "Forceer dat bij de volgende aanmelding het wachtwoord veranderd moet worden" + #: mailu/ui/forms.py:53 mailu/ui/forms.py:74 mailu/ui/forms.py:86 #: mailu/ui/forms.py:132 mailu/ui/forms.py:144 #: mailu/ui/templates/alias/list.html:22 mailu/ui/templates/domain/list.html:22 @@ -124,6 +136,10 @@ msgstr "Beheerder wachtwoord" msgid "Confirm password" msgstr "Bevestig wachtwoord" +#: mailu/ui/forms.py:134 +msgid "Current password" +msgstr "Huidig wachtwoord" + #: mailu/ui/forms.py:63 msgid "Create" msgstr "Aanmaken" @@ -277,6 +293,14 @@ msgstr "TLS inschakelen" msgid "Username" msgstr "Gebruikersnaam" +#: mailu/ui/templates/client.html:62 +msgid "If you use an Apple device," +msgstr "Indien u een Apple-apparaat gebruikt," + +#: mailu/ui/templates/client.html:63 +msgid "click here to auto-configure it." +msgstr "kunt u hier klikken om deze automatisch te configureren." + #: mailu/ui/forms.py:167 msgid "Keep emails on the server" msgstr "Behoud de e-mails op de server" diff --git a/core/admin/mailu/translations/pl/LC_MESSAGES/messages.po b/core/admin/mailu/translations/pl/LC_MESSAGES/messages.po index 1312cbc6..3c87530b 100644 --- a/core/admin/mailu/translations/pl/LC_MESSAGES/messages.po +++ b/core/admin/mailu/translations/pl/LC_MESSAGES/messages.po @@ -85,6 +85,22 @@ msgstr "Zatwierdź" msgid "Domain name" msgstr "Nazwa domeny" +#: mailu/ui/templates/domain/details.html:19 +msgid "Download zonefile" +msgstr "Pobierz plik strefy DNS" + +#: mailu/ui/forms.py:134 +msgid "Current password" +msgstr "Aktualne hasło" + +#: mailu/ui/forms.py:102 +msgid "Force password change at next login" +msgstr "Wymuś zmianę hasła przy następnym logowaniu" + +#: mailu/ui/forms.py:98 +msgid "Allow the user to spoof the sender (send email as anyone)" +msgstr "Zezwól użytkownikowi na podrobienie adresu nadawcy (wysłanie emaila w czyimkolwiek imieniu)" + #: mailu/ui/forms.py:49 msgid "Maximum user count" msgstr "Maksymalna liczba użytkowników" @@ -271,6 +287,14 @@ msgstr "Nazwa hosta lub adres IP" msgid "TCP port" msgstr "Port TCP" +#: mailu/ui/templates/client.html:62 +msgid "If you use an Apple device," +msgstr "Jeśli używasz urządzenia firmy Apple," + +#: mailu/ui/templates/client.html:63 +msgid "click here to auto-configure it." +msgstr "kliknij tutaj by skonfigurować je automatycznie." + #: mailu/ui/forms.py:164 msgid "Enable TLS" msgstr "Włącz TLS" diff --git a/core/admin/mailu/ui/templates/user/list.html b/core/admin/mailu/ui/templates/user/list.html index 45a9cb10..7796359e 100644 --- a/core/admin/mailu/ui/templates/user/list.html +++ b/core/admin/mailu/ui/templates/user/list.html @@ -28,7 +28,7 @@ {%- for user in domain.users %} - +   diff --git a/core/admin/mailu/ui/views/users.py b/core/admin/mailu/ui/views/users.py index b0a945ad..d3816c4a 100644 --- a/core/admin/mailu/ui/views/users.py +++ b/core/admin/mailu/ui/views/users.py @@ -93,7 +93,12 @@ def user_settings(user_email): form = forms.UserSettingsForm(obj=user) utils.formatCSVField(form.forward_destination) if form.validate_on_submit(): - form.forward_destination.data = form.forward_destination.data.replace(" ","").split(",") + if form.forward_enabled.data and (form.forward_destination.data in ['', None] or type(form.forward_destination.data) is list): + flask.flash('Destination email address is missing', 'error') + user.forward_enabled = True + return flask.render_template('user/settings.html', form=form, user=user) + if form.forward_enabled.data: + form.forward_destination.data = form.forward_destination.data.replace(" ","").split(",") form.populate_obj(user) models.db.session.commit() form.forward_destination.data = ", ".join(form.forward_destination.data) @@ -101,6 +106,9 @@ def user_settings(user_email): if user_email: return flask.redirect( flask.url_for('.user_list', domain_name=user.domain.name)) + elif form.is_submitted() and not form.validate(): + user.forward_enabled = form.forward_enabled.data + return flask.render_template('user/settings.html', form=form, user=user) return flask.render_template('user/settings.html', form=form, user=user) def _process_password_change(form, user_email): diff --git a/core/base/Dockerfile b/core/base/Dockerfile index 3a5e0b41..39d9f29c 100644 --- a/core/base/Dockerfile +++ b/core/base/Dockerfile @@ -3,7 +3,7 @@ # base system image (intermediate) # Note when updating the alpine tag, first manually run the workflow .github/workflows/mirror.yml. # Just run the workflow with the tag that must be synchronised. -ARG DISTRO=ghcr.io/mailu/alpine:3.19.1 +ARG DISTRO=ghcr.io/mailu/alpine:3.20 FROM $DISTRO as system ENV TZ=Etc/UTC LANG=C.UTF-8 @@ -66,8 +66,6 @@ RUN set -euxo pipefail \ ; rm -rf src/tests/*php7*/ src/tests/*session*/ src/tests/broken_configuration/ src/tests/*cookie* src/tests/upload_validation/ \ ; apk add --virtual .build-deps php83-dev php83-cgi php83-simplexml php83-xml pcre-dev build-base php83-pear php83-openssl re2c \ ; pecl83 install vld-beta \ - ; ln -s /usr/bin/phpize83 /usr/bin/phpize \ - ; ln -s /usr/bin/php-config83 /usr/bin/php-config \ ; make -j $(grep -c processor /proc/cpuinfo) release \ ; cp src/.libs/snuffleupagus.so /app \ ; rm -rf /root/.cargo /tmp/*.pem /root/.cache diff --git a/core/base/requirements-build.txt b/core/base/requirements-build.txt index ed145a00..ea2e4f3f 100644 --- a/core/base/requirements-build.txt +++ b/core/base/requirements-build.txt @@ -1,3 +1,3 @@ -pip==23.3.1 -setuptools==68.2.2 -wheel==0.41.3 +pip==24.0 +setuptools==69.5.1 +wheel==0.43.0 diff --git a/core/base/requirements-prod.txt b/core/base/requirements-prod.txt index 5bd0c5a1..6152a45a 100644 --- a/core/base/requirements-prod.txt +++ b/core/base/requirements-prod.txt @@ -1,87 +1,87 @@ -aiodns==3.1.1 -aiohttp==3.9.1 +aiodns==3.2.0 +aiohttp==3.9.5 aiosignal==1.3.1 alembic==1.13.1 aniso8601==9.0.1 attrs==23.2.0 -Babel==2.14.0 -bcrypt==4.1.2 -blinker==1.7.0 -certifi==2023.11.17 +Babel==2.15.0 +bcrypt==4.1.3 +blinker==1.8.1 +certifi==2024.2.2 cffi==1.16.0 charset-normalizer==3.3.2 click==8.1.7 colorclass==2.2.2 -cryptography==42.0.5 +cryptography==42.0.6 defusedxml==0.7.1 Deprecated==1.2.14 -dnspython==2.5.0 +dnspython==2.6.1 dominate==2.9.1 easygui==0.98.3 -email-validator==2.1.0.post1 -Flask==3.0.1 +email-validator==2.1.1 +Flask==3.0.3 flask-babel==4.0.0 Flask-Bootstrap==3.3.7.1 -Flask-DebugToolbar==0.14.1 +Flask-DebugToolbar==0.15.1 Flask-Login==0.6.3 -flask-marshmallow==1.1.0 -Flask-Migrate==4.0.5 +flask-marshmallow==1.2.1 +Flask-Migrate==4.0.7 flask-restx==1.3.0 Flask-SQLAlchemy==3.1.1 Flask-WTF==1.2.1 frozenlist==1.4.1 greenlet==3.0.3 -gunicorn==21.2.0 -idna==3.6 -importlib-resources==6.1.1 +gunicorn==22.0.0 +idna==3.7 +importlib-resources==6.4.0 infinity==1.5 intervals==0.9.2 -itsdangerous==2.1.2 -Jinja2==3.1.3 -jsonschema==4.21.1 +itsdangerous==2.2.0 +Jinja2==3.1.4 +jsonschema==4.22.0 jsonschema-specifications==2023.12.1 -limits==3.7.0 -Mako==1.3.0 -MarkupSafe==2.1.4 -marshmallow==3.20.2 -marshmallow-sqlalchemy==0.30.0 -msoffcrypto-tool==5.3.1 -multidict==6.0.4 -mysql-connector-python==8.3.0 +limits==3.11.0 +Mako==1.3.3 +MarkupSafe==2.1.5 +marshmallow==3.21.2 +marshmallow-sqlalchemy==1.0.0 +msoffcrypto-tool==5.4.0 +multidict==6.0.5 +mysql-connector-python==8.4.0 olefile==0.47 oletools==0.60.1 -packaging==23.2 +packaging==24.0 passlib==1.7.4 pcodedmp==1.2.6 podop @ file:///app/libs/podop postfix-mta-sts-resolver==1.4.0 psycopg2-binary==2.9.9 pycares==4.4.0 -pycparser==2.21 -Pygments==2.17.2 +pycparser==2.22 +Pygments==2.18.0 pyparsing==2.4.7 -python-dateutil==2.8.2 +python-dateutil==2.9.0.post0 python-magic==0.4.27 -pytz==2023.3.post1 +pytz==2024.1 PyYAML==6.0.1 -Radicale==3.1.8 -redis==5.0.1 -referencing==0.32.1 +Radicale==3.1.9 +redis==5.0.4 +referencing==0.35.1 requests==2.31.0 -rpds-py==0.17.1 +rpds-py==0.18.0 six==1.16.0 socrate @ file:///app/libs/socrate -SQLAlchemy==2.0.25 +SQLAlchemy==2.0.30 srslib==0.1.4 tabulate==0.9.0 tenacity==8.2.3 -typing_extensions==4.9.0 -urllib3==2.1.0 -validators==0.22.0 +typing_extensions==4.11.0 +urllib3==2.2.1 +validators==0.28.1 visitor==0.1.3 -vobject==0.9.6.1 -watchdog==3.0.0 -Werkzeug==3.0.1 +vobject==0.9.7 +watchdog==4.0.0 +Werkzeug==3.0.3 wrapt==1.16.0 WTForms==3.1.2 WTForms-Components==0.10.5 diff --git a/core/dovecot/Dockerfile b/core/dovecot/Dockerfile index 25eb9263..72753e1e 100644 --- a/core/dovecot/Dockerfile +++ b/core/dovecot/Dockerfile @@ -7,7 +7,6 @@ ARG VERSION LABEL version=$VERSION RUN set -euxo pipefail \ - ; echo -e 'http://dl-cdn.alpinelinux.org/alpine/edge/main\nhttp://dl-cdn.alpinelinux.org/alpine/edge/testing\nhttp://dl-cdn.alpinelinux.org/alpine/edge/community' >> /etc/apk/repositories \ ; apk add --no-cache 'dovecot<2.4' dovecot-lmtpd dovecot-pigeonhole-plugin dovecot-pop3d dovecot-submissiond rspamd-client dovecot-fts-flatcurve \ ; mkdir /var/lib/dovecot diff --git a/core/dovecot/conf/dovecot.conf b/core/dovecot/conf/dovecot.conf index 4ea4fc43..9c92aca6 100644 --- a/core/dovecot/conf/dovecot.conf +++ b/core/dovecot/conf/dovecot.conf @@ -5,7 +5,7 @@ log_path = /dev/stderr protocols = imap pop3 lmtp sieve postmaster_address = {{ POSTMASTER }}@{{ DOMAIN }} hostname = {{ HOSTNAMES.split(",")[0] }} -{%- if PROXY_PROTOCOL in ['all', 'all-but-http', 'mail'] %} +{%- if PROXY_PROTOCOL_25 %} submission_host = {{ HOSTNAMES.split(",")[0] }} {% else %} submission_host = {{ FRONT_ADDRESS }} @@ -69,9 +69,9 @@ plugin { fts_enforced = yes fts_autoindex_exclude = \Trash fts_autoindex_exclude1 = \Junk - fts_filters = normalizer-icu snowball stopwords - fts_filters_en = lowercase snowball english-possessive stopwords - fts_filters_fr = lowercase snowball contractions stopwords + fts_filters = normalizer-icu lowercase snowball stopwords + fts_filters_en = normalizer-icu lowercase snowball english-possessive stopwords + fts_filters_fr = normalizer-icu lowercase snowball contractions stopwords fts_header_excludes = Received DKIM-* ARC-* X-* x-* Comments Delivered-To Return-Path Authentication-Results Message-ID References In-Reply-To Thread-* Accept-Language Content-* MIME-Version {% if FULL_TEXT_SEARCH_ATTACHMENTS %} fts_tika = http://{{ FTS_ATTACHMENTS_ADDRESS }}:9998/tika/ diff --git a/core/dovecot/conf/report-spam.sieve b/core/dovecot/conf/report-spam.sieve index 87fd515e..df7d276c 100644 --- a/core/dovecot/conf/report-spam.sieve +++ b/core/dovecot/conf/report-spam.sieve @@ -1,5 +1,4 @@ require "imap4flags"; require "vnd.dovecot.execute"; -setflag "\\seen"; execute :pipe "spam"; diff --git a/core/nginx/conf/nginx.conf b/core/nginx/conf/nginx.conf index 214744ee..e7d4885e 100644 --- a/core/nginx/conf/nginx.conf +++ b/core/nginx/conf/nginx.conf @@ -22,7 +22,7 @@ http { {% if REAL_IP_HEADER %} real_ip_header {{ REAL_IP_HEADER }}; - {% elif PROXY_PROTOCOL in ['all', 'all-but-http', 'http'] %} + {% elif (PROXY_PROTOCOL_80 or PROXY_PROTOCOL_443) and REAL_IP_FROM %} real_ip_header proxy_protocol; {% endif %} @@ -54,14 +54,14 @@ http { gzip_min_length 1024; # TODO: figure out how to server pre-compressed assets from admin container - {% if not KUBERNETES_INGRESS and TLS_FLAVOR in [ 'letsencrypt', 'cert' ] %} + {% if PORT_80 and TLS_FLAVOR in [ 'letsencrypt', 'cert' ] %} # Enable the proxy for certbot if the flavor is letsencrypt and not on kubernetes # server { # Listen over HTTP - listen 80{% if PROXY_PROTOCOL in ['all', 'http'] %} proxy_protocol{% endif %}; + listen 80{% if PROXY_PROTOCOL_80 %} proxy_protocol{% endif %}; {% if SUBNET6 %} - listen [::]:80{% if PROXY_PROTOCOL in ['all', 'http'] %} proxy_protocol{% endif %}; + listen [::]:80{% if PROXY_PROTOCOL_80 %} proxy_protocol{% endif %}; {% endif %} {% if TLS_FLAVOR in ['letsencrypt', 'mail-letsencrypt'] %} location ^~ /.well-known/acme-challenge/ { @@ -95,18 +95,18 @@ http { client_max_body_size {{ MESSAGE_SIZE_LIMIT|int + 8388608 }}; # Listen on HTTP only in kubernetes or behind reverse proxy - {% if KUBERNETES_INGRESS or TLS_FLAVOR in [ 'mail-letsencrypt', 'notls', 'mail' ] %} - listen 80{% if PROXY_PROTOCOL in ['all', 'http'] %} proxy_protocol{% endif %}; + {% if TLS_FLAVOR in [ 'mail-letsencrypt', 'notls', 'mail' ] %} + listen 80{% if PROXY_PROTOCOL_80 %} proxy_protocol{% endif %}; {% if SUBNET6 %} - listen [::]:80{% if PROXY_PROTOCOL in ['all', 'http'] %} proxy_protocol{% endif %}; + listen [::]:80{% if PROXY_PROTOCOL_80 %} proxy_protocol{% endif %}; {% endif %} {% endif %} - # Only enable HTTPS if TLS is enabled with no error and not on kubernetes - {% if not KUBERNETES_INGRESS and TLS and not TLS_ERROR %} - listen 443 ssl http2{% if PROXY_PROTOCOL in ['all', 'all-but-http', 'http'] %} proxy_protocol{% endif %}; + # Only enable HTTPS if TLS is enabled with no error + {% if TLS_443 and not TLS_ERROR %} + listen 443 ssl http2{% if PROXY_PROTOCOL_443 %} proxy_protocol{% endif %}; {% if SUBNET6 %} - listen [::]:443 ssl http2{% if PROXY_PROTOCOL in ['all', 'all-but-http', 'http'] %} proxy_protocol{% endif %}; + listen [::]:443 ssl http2{% if PROXY_PROTOCOL_443 %} proxy_protocol{% endif %}; {% endif %} include /etc/nginx/tls.conf; @@ -162,7 +162,7 @@ http { {% endif %} # If TLS is failing, prevent access to anything except certbot - {% if not KUBERNETES_INGRESS and TLS_ERROR and not (TLS_FLAVOR in [ 'mail-letsencrypt', 'mail' ]) %} + {% if TLS_ERROR and not (TLS_FLAVOR in [ 'mail-letsencrypt', 'mail' ]) %} location / { return 403; } @@ -315,7 +315,7 @@ mail { ssl_session_cache shared:SSLMAIL:3m; {% endif %} - {% if PROXY_PROTOCOL in ['all', 'all-but-http', 'mail'] and REAL_IP_FROM %}{% for from_ip in REAL_IP_FROM.split(',') %} + {% if PROXY_PROTOCOL_25 and REAL_IP_FROM %}{% for from_ip in REAL_IP_FROM.split(',') %} set_real_ip_from {{ from_ip }}; {% endfor %}{% endif %} @@ -324,9 +324,9 @@ mail { # SMTP is always enabled, to avoid losing emails when TLS is failing server { - listen 25{% if PROXY_PROTOCOL in ['all', 'all-but-http', 'mail'] %} proxy_protocol{% endif %}; + listen 25{% if PROXY_PROTOCOL_25 %} proxy_protocol{% endif %}; {% if SUBNET6 %} - listen [::]:25{% if PROXY_PROTOCOL in ['all', 'all-but-http', 'mail'] %} proxy_protocol{% endif %}; + listen [::]:25{% if PROXY_PROTOCOL_25 %} proxy_protocol{% endif %}; {% endif %} {% if TLS and not TLS_ERROR %} {% if TLS_FLAVOR in ['letsencrypt','mail-letsencrypt'] %} diff --git a/core/nginx/conf/proxy.conf b/core/nginx/conf/proxy.conf index ebbd30aa..7a3d8721 100644 --- a/core/nginx/conf/proxy.conf +++ b/core/nginx/conf/proxy.conf @@ -6,7 +6,7 @@ proxy_hide_header True-Client-IP; proxy_hide_header CF-Connecting-IP; proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; -{% if (REAL_IP_HEADER or (PROXY_PROTOCOL in ['http', 'all'])) and REAL_IP_FROM %} +{% if (REAL_IP_HEADER or (PROXY_PROTOCOL_80 or PROXY_PROTOCOL_443)) and REAL_IP_FROM %} proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-By $realip_remote_addr; {% else %} diff --git a/core/nginx/config.py b/core/nginx/config.py index 73cc085c..1f06424b 100755 --- a/core/nginx/config.py +++ b/core/nginx/config.py @@ -70,6 +70,36 @@ with open("/etc/resolv.conf") as handle: resolver = content[content.index("nameserver") + 1] args["RESOLVER"] = f"[{resolver}]" if ":" in resolver else resolver +# Configure PROXY_PROTOCOL +PROTO_MAIL=['25', '110', '995', '143', '993', '587', '465', '4190'] +PROTO_ALL_BUT_HTTP=PROTO_MAIL.copy() +PROTO_ALL_BUT_HTTP.extend(['443']) +PROTO_ALL=PROTO_ALL_BUT_HTTP.copy() +PROTO_ALL.extend(['80']) +for item in args.get('PROXY_PROTOCOL', '').split(','): + if item.isdigit(): + args[f'PROXY_PROTOCOL_{item}']=True + elif item == 'mail': + for p in PROTO_MAIL: args[f'PROXY_PROTOCOL_{p}']=True + elif item == 'all-but-http': + for p in PROTO_ALL_BUT_HTTP: args[f'PROXY_PROTOCOL_{p}']=True + elif item == 'all': + for p in PROTO_ALL: args[f'PROXY_PROTOCOL_{p}']=True + else: + log.error(f'Not sure what to do with {item} in PROXY_PROTOCOL ({args.get("PROXY_PROTOCOL")})') + +PORTS_REQUIRING_TLS=['443', '465', '993', '995'] +ALL_PORTS='25,80,443,465,993,995,4190' +for item in args.get('PORTS', ALL_PORTS).split(','): + if item in PORTS_REQUIRING_TLS and args['TLS_FLAVOR'] == 'notls': + continue + args[f'PORT_{item}']=True + +if args['TLS_FLAVOR'] != 'notls': + for item in args.get('TLS', ALL_PORTS).split(','): + if item in PORTS_REQUIRING_TLS: + args[f'TLS_{item}']=True + # TLS configuration cert_name = args.get("TLS_CERT_FILENAME", "cert.pem") keypair_name = args.get("TLS_KEYPAIR_FILENAME", "key.pem") @@ -129,6 +159,8 @@ if args["TLS"] and not all(os.path.exists(file_path) for file_path in args["TLS" print("Missing cert or key file, disabling TLS") args["TLS_ERROR"] = "yes" +args['TLS_PERMISSIVE'] = str(args.get('TLS_PERMISSIVE')).lower() not in ('false', 'no') + # Build final configuration paths conf.jinja("/conf/tls.conf", args, "/etc/nginx/tls.conf") conf.jinja("/conf/proxy.conf", args, "/etc/nginx/proxy.conf") diff --git a/core/nginx/dovecot/proxy.conf b/core/nginx/dovecot/proxy.conf index db5a5f03..a6e64719 100644 --- a/core/nginx/dovecot/proxy.conf +++ b/core/nginx/dovecot/proxy.conf @@ -75,11 +75,12 @@ service anvil { } } +{%- if PORT_4190 %} service managesieve-login { executable = managesieve-login inet_listener sieve { port = 4190 -{%- if PROXY_PROTOCOL in ['all', 'all-but-http', 'mail'] %} +{%- if PROXY_PROTOCOL_4190 %} haproxy = yes {% endif %} } @@ -87,6 +88,7 @@ service managesieve-login { port = 14190 } } +{% endif %} protocol imap { mail_max_userip_connections = 20 @@ -94,42 +96,46 @@ protocol imap { } service imap-login { +{%- if PORT_143 %} inet_listener imap { port = 143 -{%- if PROXY_PROTOCOL in ['all', 'all-but-http', 'mail'] %} +{%- if PROXY_PROTOCOL_143 %} haproxy = yes {% endif %} } +{% endif %} +{%- if TLS_993 and PORT_993 %} inet_listener imaps { port = 993 -{%- if TLS %} ssl = yes -{% endif %} -{%- if PROXY_PROTOCOL in ['all', 'all-but-http', 'mail'] %} +{%- if PROXY_PROTOCOL_993 %} haproxy = yes {% endif %} } +{% endif %} inet_listener imap-webmail { port = 10143 } } service pop3-login { +{%- if PORT_110 %} inet_listener pop3 { port = 110 -{%- if PROXY_PROTOCOL in ['all', 'all-but-http', 'mail'] %} +{%- if PROXY_PROTOCOL_110 %} haproxy = yes {% endif %} } +{% endif %} +{%- if TLS_995 and PORT_995 %} inet_listener pop3s { port = 995 -{%- if TLS %} ssl = yes -{% endif %} -{%- if PROXY_PROTOCOL in ['all', 'all-but-http', 'mail'] %} +{%- if PROXY_PROTOCOL_995 %} haproxy = yes {% endif %} } +{% endif %} } recipient_delimiter = {{ RECIPIENT_DELIMITER }} @@ -142,20 +148,25 @@ service lmtp { service submission-login { inet_listener submission { +{%- if PORT_587 %} port = 587 -{%- if PROXY_PROTOCOL in ['all', 'all-but-http', 'mail'] %} +{%- if PROXY_PROTOCOL_587 %} haproxy = yes +{% endif %} +{%- else %} +# if the section is unset the port is bound anyways + port = 0 {% endif %} } +{%- if TLS_465 and PORT_465 %} inet_listener submissions { port = 465 -{%- if TLS %} ssl = yes -{% endif %} -{%- if PROXY_PROTOCOL in ['all', 'all-but-http', 'mail'] %} +{%- if PROXY_PROTOCOL_465 %} haproxy = yes {% endif %} } +{% endif %} inet_listener submission-webmail { port = 10025 } diff --git a/docs/Dockerfile b/docs/Dockerfile index 958eaf87..25ecc496 100644 --- a/docs/Dockerfile +++ b/docs/Dockerfile @@ -1,5 +1,5 @@ # Convert .rst files to .html in temporary build container -FROM python:3.12.0-alpine3.18 AS build +FROM python:3.12.3-alpine3.19 AS build ARG version=master ENV VERSION=$version @@ -7,16 +7,24 @@ ENV VERSION=$version COPY requirements.txt /requirements.txt COPY . /docs -RUN apk add --no-cache --virtual .build-deps \ - gcc musl-dev \ - && pip3 install -r /requirements.txt \ - && mkdir -p /build/$VERSION \ - && sphinx-build -W /docs /build/$VERSION \ - && apk del .build-deps +RUN set -euxo pipefail \ + ; machine="$(uname -m)" \ + ; deps="gcc musl-dev" \ + ; [[ "${machine}" != x86_64 ]] && \ + deps="${deps} cargo" \ + ; apk add --no-cache --virtual .build-deps ${deps} \ + ; [[ "${machine}" == armv7* ]] && \ + mkdir -p /root/.cargo/registry/index && \ + git clone --bare https://github.com/rust-lang/crates.io-index.git /root/.cargo/registry/index/github.com-1285ae84e5963aae \ + ; pip3 install -r /requirements.txt \ + ; mkdir -p /build/$VERSION \ + ; sphinx-build -W /docs /build/$VERSION \ + ; apk del .build-deps \ + ; rm -rf /root/.cargo # Build nginx deployment image including generated html -FROM nginx:1.25.3-alpine +FROM nginx:1.25.5-alpine ARG version=master ARG pinned_version=master @@ -30,4 +38,4 @@ COPY --from=build /build/$VERSION /build/$VERSION EXPOSE 80/tcp CMD nginx -g "daemon off;" -RUN echo $pinned_version >> /version \ No newline at end of file +RUN echo $pinned_version >> /version diff --git a/docs/api.rst b/docs/api.rst index f1c01b85..c483b798 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -12,7 +12,7 @@ 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 +* ``API_TOKEN`` - API token for authentication (with minimum length of 3 characters) For more information refer to the detailed descriptions in the :ref:`configuration reference `. diff --git a/docs/cli.rst b/docs/cli.rst index 88d8edc5..7c36dbef 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -162,7 +162,7 @@ Attributes explicitly specified in filters are automatically exported: there is .. code-block:: bash - $ docker compose exec admin flask mailu config-export --output mail-config.yml + $ docker compose exec admin flask mailu config-export --output-file mail-config.yml $ docker compose exec -T admin flask mailu config-export domain.dns_mx domain.dns_spf diff --git a/docs/compose/setup.rst b/docs/compose/setup.rst index 81433ba3..ae1a6387 100644 --- a/docs/compose/setup.rst +++ b/docs/compose/setup.rst @@ -31,18 +31,14 @@ Sets the ``TLS_FLAVOR`` to one of the following values: - ``cert`` is the default and requires certificates to be setup manually; -- ``letsencrypt`` will use the *Letsencrypt!* CA to generate automatic certificates; -- ``mail`` is similar to ``cert`` except that TLS will only be served for - emails (IMAP and SMTP), not HTTP (use it behind reverse proxies); -- ``mail-letsencrypt`` is similar to ``letsencrypt`` except that TLS will only be served for - emails (IMAP and SMTP), not HTTP (use it behind reverse proxies); +- ``letsencrypt`` will use the *Letsencrypt!* CA to obtain certificates automatically; - ``notls`` will disable TLS, this is not recommended except for testing. .. note:: When using *Letsencrypt!* you have to make sure that the DNS ``A`` and ``AAAA`` records for the - all hostnames mentioned in the ``HOSTNAMES`` variable match with the ip addresses of you server. - Or else certificate generation will fail! See also: :ref:`dns_setup`. + all hostnames mentioned in the ``HOSTNAMES`` variable match with the ip addresses of you server + or else certificate generation will fail! See also: :ref:`dns_setup`. Bind address ```````````` @@ -91,7 +87,7 @@ Finish setting up TLS Mailu relies heavily on TLS and must have a key pair and a certificate available, at least for the hostname configured in the ``mailu.env`` file. -If you set ``TLS_FLAVOR`` to ``cert`` or ``mail`` then you must create a ``certs`` directory +If you set ``TLS_FLAVOR`` to ``cert`` then you must create a ``certs`` directory in your root path and setup a key-certificate pair there: - ``cert.pem`` contains the certificate (override with ``TLS_CERT_FILENAME``), diff --git a/docs/configuration.rst b/docs/configuration.rst index 50a576fd..06b7627e 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -62,8 +62,7 @@ The ``AUTH_RATELIMIT_EXEMPTION`` (default: '') is a comma separated list of netw CIDRs that won't be subject to any form of rate limiting. Specifying ``0.0.0.0/0, ::/0`` there is a good way to disable rate limiting altogether. -The ``TLS_FLAVOR`` sets how Mailu handles TLS connections. Setting this value to -``notls`` will cause Mailu not to serve any web content! More on :ref:`tls_flavor`. +The ``TLS_FLAVOR`` sets how Mailu obtains a x509 certificate. More on :ref:`tls_flavor`. The ``DEFAULT_SPAM_THRESHOLD`` (default: 80) is the default spam tolerance used when creating a new user. @@ -218,6 +217,7 @@ Advanced settings The ``AUTH_REQUIRE_TOKENS`` (default: False) setting controls whether thick clients can authenticate using passwords or whether they are forced to use tokens/application specific passwords. The ``API_TOKEN`` (default: None) setting configures the authentication token. +The minimum length is 3 characters. This token must be passed as request header to the API as authentication token. This is a mandatory setting for using the RESTful API. @@ -248,9 +248,20 @@ but slows down the performance of modern devices. .. _`android handsets older than 7.1.1`: https://community.letsencrypt.org/t/production-chain-changes/150739 -The ``TLS_PERMISSIVE`` (default: true) setting controls whether ciphers and protocols offered on port 25 for STARTTLS are optimized for maximum compatibility. We **strongly recommend** that you do **not** change this setting on the basis that any encryption beats no encryption. If you are subject to compliance requirements and are not afraid of losing emails as a result of artificially reducing compatibility, set it to 'false'. Keep in mind that servers that are running a software stack old enough to not be compatible with the current TLS requirements will either a) deliver in plaintext b) bounce emails c) silently drop emails; moreover, modern servers will benefit from various downgrade protections (DOWNGRD, RFC7507) making the security argument mostly a moot point. +The ``TLS_PERMISSIVE`` (default: true) setting controls whether ciphers and protocols offered on port 25 +for STARTTLS are optimized for maximum compatibility. We **strongly recommend** that you do **not** change +this setting on the basis that any encryption beats no encryption. If you are subject to compliance +requirements and are not afraid of losing emails as a result of artificially reducing compatibility, +set it to 'false'. Keep in mind that servers that are running a software stack old enough to not be +compatible with the current TLS requirements will either a) deliver in plaintext b) bounce emails +c) silently drop emails; moreover, modern servers will benefit from various downgrade protections +(DOWNGRD, RFC7507) making the security argument mostly a moot point. -The ``COMPRESSION`` (default: unset) setting controls whether emails are stored compressed at rest on disk. Valid values are ``gz``, ``bz2`` or ``zstd`` and additional settings can be configured via ``COMPRESSION_LEVEL``, see `zlib_save_level`_ for accepted values. If the underlying filesystem supports compression natively you should use it instead of this setting as it will be more efficient and will improve compatibility with 3rd party tools. +The ``COMPRESSION`` (default: unset) setting controls whether emails are stored compressed at rest on disk. +Valid values are ``gz``, ``bz2`` or ``zstd`` and additional settings can be configured via +``COMPRESSION_LEVEL``, see `zlib_save_level`_ for accepted values. If the underlying filesystem +supports compression natively you should use it instead of this setting as it will be more efficient +and will improve compatibility with 3rd party tools. .. _`zlib_save_level`: https://doc.dovecot.org/settings/plugin/zlib-plugin/#plugin_setting-zlib-zlib_save_level @@ -267,13 +278,13 @@ The ``TZ`` sets the timezone Mailu will use. The timezone naming convention usua .. _`TZ database name`: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones -The ``PROXY_PROTOCOL`` (default: unset) allows the the front container to receive TCP and HTTP connections with -the `PROXY protocol`_ (originally introduced in HAProxy, now also configurable in other proxy servers). -It can be set to: -* ``http`` to accept the ``PROXY`` protocol on nginx's HTTP proxy ports -* ``mail`` to accept the ``PROXY`` protocol on nginx's mail proxy ports -* ``all`` to accept the ``PROXY`` protocol on all nginx's HTTP and mail proxy ports +The ``PORTS`` (default: '25,80,443,465,993,995,4190') setting determines which services should be enabled. It is a comma delimited list of ports numbers. +If you need to re-enable IMAP, POP3 and Submission, you can append '110,143,587' to that list. + +The ``PROXY_PROTOCOL`` (default: unset) setting allows the the front container to receive TCP and HTTP connections with +the `PROXY protocol`_ (originally introduced in HAProxy, now also configurable in other proxy servers). +It can be set to a comma delimited list of ports on which it should be enabled. .. _`PROXY protocol`: https://github.com/haproxy/haproxy/blob/master/doc/proxy-protocol.txt diff --git a/docs/database.rst b/docs/database.rst index 54e5015a..e200555a 100644 --- a/docs/database.rst +++ b/docs/database.rst @@ -17,7 +17,7 @@ This means it is not possible to switch the database back-end used by roundcube To switch to a different database back-end: -1. Run config-export to export the configuration. E.g. `docker compose exec admin flask mailu config-export --secrets --output /data/mail-config.yml` +1. Run config-export to export the configuration. E.g. `docker compose exec admin flask mailu config-export --secrets --output-file /data/mail-config.yml` 2. Set up your new database server. Refer to the subsequent sections for tips for creating the database. 3. Modify the database settings (SQLAlchemy database URL) in mailu.env. Refer to the :ref:`configuration guide (link) ` for the exact settings. 4. Start your Mailu deployment. diff --git a/docs/requirements.txt b/docs/requirements.txt index 46d263a7..56a54f27 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,5 +1,5 @@ recommonmark==0.7.1 -Sphinx==7.2.6 -sphinx-autobuild==2021.3.14 -sphinx-rtd-theme==1.3.0 -docutils==0.18.1 +Sphinx==7.3.7 +sphinx-autobuild==2024.4.16 +sphinx-rtd-theme==2.0.0 +docutils==0.20.1 diff --git a/docs/reverse.rst b/docs/reverse.rst index de427d5d..56958d9b 100644 --- a/docs/reverse.rst +++ b/docs/reverse.rst @@ -28,11 +28,8 @@ and add a section like follows: - "--entrypoints.web.address=:http" - "--entrypoints.websecure.address=:https" - "--entrypoints.smtp.address=:smtp" - - "--entrypoints.submission.address=:submission" - "--entrypoints.submissions.address=:submissions" - - "--entrypoints.imap.address=:imap" - "--entrypoints.imaps.address=:imaps" - - "--entrypoints.pop3.address=:pop3" - "--entrypoints.pop3s.address=:pop3s" - "--entrypoints.sieve.address=:sieve" # - "--api.insecure=true" @@ -42,11 +39,8 @@ and add a section like follows: - "80:80" - "443:443" - "465:465" - - "587:587" - "993:993" - "995:995" - - "110:110" - - "143:143" - "4190:4190" # The Web UI (enabled by --api.insecure=true) # - "8080:8080" @@ -80,36 +74,18 @@ and then add the following to the front section: - "traefik.tcp.services.smtp.loadbalancer.server.port=25" - "traefik.tcp.services.smtp.loadbalancer.proxyProtocol.version=2" - - "traefik.tcp.routers.submission.rule=HostSNI(`*`)" - - "traefik.tcp.routers.submission.entrypoints=submission" - - "traefik.tcp.routers.submission.service=submission" - - "traefik.tcp.services.submission.loadbalancer.server.port=587" - - "traefik.tcp.services.submission.loadbalancer.proxyProtocol.version=2" - - "traefik.tcp.routers.submissions.rule=HostSNI(`*`)" - "traefik.tcp.routers.submissions.entrypoints=submissions" - "traefik.tcp.routers.submissions.service=submissions" - "traefik.tcp.services.submissions.loadbalancer.server.port=465" - "traefik.tcp.services.submissions.loadbalancer.proxyProtocol.version=2" - - "traefik.tcp.routers.imap.rule=HostSNI(`*`)" - - "traefik.tcp.routers.imap.entrypoints=imap" - - "traefik.tcp.routers.imap.service=imap" - - "traefik.tcp.services.imap.loadbalancer.server.port=143" - - "traefik.tcp.services.imap.loadbalancer.proxyProtocol.version=2" - - "traefik.tcp.routers.imaps.rule=HostSNI(`*`)" - "traefik.tcp.routers.imaps.entrypoints=imaps" - "traefik.tcp.routers.imaps.service=imaps" - "traefik.tcp.services.imaps.loadbalancer.server.port=993" - "traefik.tcp.services.imaps.loadbalancer.proxyProtocol.version=2" - - "traefik.tcp.routers.pop3.rule=HostSNI(`*`)" - - "traefik.tcp.routers.pop3.entrypoints=pop3" - - "traefik.tcp.routers.pop3.service=pop3" - - "traefik.tcp.services.pop3.loadbalancer.server.port=110" - - "traefik.tcp.services.pop3.loadbalancer.proxyProtocol.version=2" - - "traefik.tcp.routers.pop3s.rule=HostSNI(`*`)" - "traefik.tcp.routers.pop3s.entrypoints=pop3s" - "traefik.tcp.routers.pop3s.service=pop3s" @@ -129,9 +105,9 @@ in mailu.env: .. code-block:: docker REAL_IP_FROM=192.168.203.0/24 - PROXY_PROTOCOL=all-but-http + PROXY_PROTOCOL=25,443,465,993,995,4190 TRAEFIK_VERSION=v2 - TLS_FLAVOR=mail-letsencrypt + TLS_FLAVOR=letsencrypt WEBROOT_REDIRECT=/sso/login Using the above configuration, Traefik will proxy all the traffic related to Mailu's FQDNs without requiring duplicate certificates. diff --git a/docs/webadministration.rst b/docs/webadministration.rst index 3ce53fe8..3a3af6a6 100644 --- a/docs/webadministration.rst +++ b/docs/webadministration.rst @@ -93,6 +93,8 @@ The exception to this rule, are email messages with an extremely high spam score When the spam filter is enabled, received email messages will be moved to the logged in user's inbox folder or junk folder depending on the user defined spam filter tolerance. +When `Enable marking spam mails as read` is enabled. Received messages moved to the Junk folder are marked as read. When this setting is disabled. Received messages moved to the Junk folder are not marked as read. They remain marked as unread. + The user defined spam filter tolerance determines when an email is classified as ham (moved to the inbox folder) or spam (moved to the junk folder). The default value is 80%. The lower the spam filter tolerance, the more false positives (ham classified as spam). The higher the spam filter tolerance, the more false negatives (spam classified as ham). For more information see the :ref:`antispam documentation `. diff --git a/optional/radicale/radicale.conf b/optional/radicale/radicale.conf index 7914e6ea..d9859508 100644 --- a/optional/radicale/radicale.conf +++ b/optional/radicale/radicale.conf @@ -1,5 +1,4 @@ [server] -hosts = :5232 ssl = False [encoding] diff --git a/scripts/purge_user.sh b/scripts/purge_user.sh index 07d4a354..926d861d 100755 --- a/scripts/purge_user.sh +++ b/scripts/purge_user.sh @@ -1,7 +1,7 @@ #!/bin/bash # get id of running admin container -admin="$(docker compose ps admin --format=json | jq -r '.[].ID')" +admin="$(docker compose ps admin --format=json | jq -r '.ID')" if [[ -z "${admin}" ]]; then echo "Sorry, can't find running mailu admin container." echo "You need to start this in the path containing your docker-compose.yml." diff --git a/setup/flavors/compose/docker-compose.yml b/setup/flavors/compose/docker-compose.yml index 6f7c3947..8cd374cd 100644 --- a/setup/flavors/compose/docker-compose.yml +++ b/setup/flavors/compose/docker-compose.yml @@ -158,7 +158,7 @@ services: {% if tika_enabled %} fts_attachments: - image: ghcr.io/paperless-ngx/tika:2.9.1-full + image: apache/tika:2.9.2.1-full hostname: tika logging: driver: journald diff --git a/setup/flavors/compose/mailu.env b/setup/flavors/compose/mailu.env index 7c36a7ec..496a8324 100644 --- a/setup/flavors/compose/mailu.env +++ b/setup/flavors/compose/mailu.env @@ -178,7 +178,7 @@ REAL_IP_FROM={{ real_ip_from }} REJECT_UNLISTED_RECIPIENT={{ reject_unlisted_recipient }} # Log level threshold in start.py (value: CRITICAL, ERROR, WARNING, INFO, DEBUG, NOTSET) -LOG_LEVEL=WARNING +LOG_LEVEL=INFO # Timezone for the Mailu containers. See this link for all possible values https://en.wikipedia.org/wiki/List_of_tz_database_time_zones TZ=Etc/UTC diff --git a/setup/templates/steps/compose/03_expose.html b/setup/templates/steps/compose/03_expose.html index e14e4bbc..e928fccf 100644 --- a/setup/templates/steps/compose/03_expose.html +++ b/setup/templates/steps/compose/03_expose.html @@ -49,7 +49,7 @@ avoid generic all-interfaces addresses like 0.0.0.0 or ::Read this: Mailu requires a validating, DNSSEC-enabled DNS resolver to function. Be sure to read our FAQ entry on the topic.

-

You server will be available under a main hostname but may expose multiple public +

Your server will be available under a main hostname but may expose multiple public hostnames. Every e-mail domain that points to this server must have one of the hostnames in its MX record. Hostnames must be comma-separated. If you're having trouble accessing your admin interface, make sure it is the first entry here (and possibly the diff --git a/tests/compose/api/00_create_users.sh b/tests/compose/api/00_create_users.sh new file mode 100755 index 00000000..54a8e4e2 --- /dev/null +++ b/tests/compose/api/00_create_users.sh @@ -0,0 +1,106 @@ +# create user admin@maiu.io +echo "Create users" + +curl --silent --insecure -X 'POST' \ + 'https://localhost/api/v1/domain' \ + -H 'accept: application/json' \ + -H 'Authorization: Bearer apitest' \ + -H 'Content-Type: application/json' \ + -d '{ + "name": "mailu.io", + "comment": "internal domain for testing", + "max_users": -1, + "max_aliases": -1, + "max_quota_bytes": 0, + "signup_enabled": false +}' | grep 200 + +if [ $? -ne 0 ]; then + exit 1 +fi +echo "Domain mail.io has been created successfully" + +curl --silent --insecure -X 'POST' \ + 'https://localhost/api/v1/user' \ + -H 'accept: application/json' \ + -H 'Authorization: Bearer apitest' \ + -H 'Content-Type: application/json' \ + -d '{ + "email": "admin@mailu.io", + "raw_password": "password", + "comment": "created for testing RESTful API", + "global_admin": true, + "enabled": true, + "change_pw_next_login": false, + "enable_imap": true, + "enable_pop": true, + "allow_spoofing": false, + "forward_enabled": false, + "reply_enabled": false, + "displayed_name": "admin", + "spam_enabled": true, + "spam_mark_as_read": true +}' | grep 200 + +if [ $? -ne 0 ]; then + exit 1 +fi +echo "Created admin user (admin@mailu.io) successfully" + +# Test if creating duplicate returns 409 HTTP response. +curl --silent --insecure -X 'POST' \ + 'https://localhost/api/v1/user' \ + -H 'accept: application/json' \ + -H 'Authorization: Bearer apitest' \ + -H 'Content-Type: application/json' \ + -d '{ + "email": "admin@mailu.io", + "raw_password": "password", + "comment": "created for testing RESTful API", + "global_admin": true, + "enabled": true, + "change_pw_next_login": false, + "enable_imap": true, + "enable_pop": true, + "allow_spoofing": false, + "forward_enabled": false, + "reply_enabled": false, + "displayed_name": "admin", + "spam_enabled": true, + "spam_mark_as_read": true +}' | grep 409 + +if [ $? -ne 0 ]; then + exit 1 +fi +echo "OK. Failed creating duplicate user." + +# create user user@mailu.io +curl --silent --insecure -X 'POST' \ + 'https://localhost/api/v1/user' \ + -H 'accept: application/json' \ + -H 'Authorization: Bearer apitest' \ + -H 'Content-Type: application/json' \ + -d '{ + "email": "user@mailu.io", + "raw_password": "password", + "comment": "created for testing RESTful API", + "global_admin": false, + "enabled": true, + "change_pw_next_login": false, + "enable_imap": true, + "enable_pop": true, + "allow_spoofing": false, + "forward_enabled": false, + "reply_enabled": false, + "displayed_name": "admin", + "spam_enabled": true, + "spam_mark_as_read": true +}' | grep 200 + +if [ $? -ne 0 ]; then + exit 1 +fi +echo "Created user (user@mailu.io) successfully" + +echo "Finished 00_create_users.sh" \ No newline at end of file diff --git a/tests/compose/api/01_test_user_interfaces.sh b/tests/compose/api/01_test_user_interfaces.sh new file mode 100755 index 00000000..f63258c3 --- /dev/null +++ b/tests/compose/api/01_test_user_interfaces.sh @@ -0,0 +1,80 @@ +echo "Test user interfaces" +# create user user@mailu.io for testing deletion +curl --silent --insecure -X 'POST' \ + 'https://localhost/api/v1/user' \ + -H 'accept: application/json' \ + -H 'Authorization: Bearer apitest' \ + -H 'Content-Type: application/json' \ + -d '{ + "email": "user2@mailu.io", + "raw_password": "password", + "comment": "created for testing RESTful API", + "global_admin": false, + "enabled": true, + "change_pw_next_login": false, + "enable_imap": true, + "enable_pop": true, + "allow_spoofing": false, + "forward_enabled": false, + "reply_enabled": false, + "displayed_name": "admin", + "spam_enabled": true, + "spam_mark_as_read": true +}' | grep 200 + +if [ $? -ne 0 ]; then + exit 1 +fi +echo "created user (user2@mailu.io) successfully" + +#delete user2@mailu.io +curl --silent --insecure -X 'DELETE' \ + 'https://localhost/api/v1/user/user2%40mailu.io' \ + -H 'accept: application/json' \ + -H 'Authorization: Bearer apitest' \ + | grep 200 + +if [ $? -ne 0 ]; then + exit 1 +fi +echo "Deleted user2 (user2@mailu.io) successfully" + +#Check if updating user works +curl --silent --insecure -X 'PATCH' \ + 'https://localhost/api/v1/user/user%40mailu.io' \ + -H 'accept: application/json' \ + -H 'Authorization: Bearer apitest' \ + -H 'Content-Type: application/json' \ + -d '{ + "comment": "updated_comment" +}' | grep 200 + +if [ $? -ne 0 ]; then + exit 1 +fi +echo "Updated user(user@mailu.io) successfully" + +curl --silent --insecure -X 'GET' \ + 'https://localhost/api/v1/user/user%40mailu.io' \ + -H 'accept: application/json' \ + -H 'Authorization: Bearer apitest' \ + | grep updated_comment + +if [ $? -ne 0 ]; then + exit 1 +fi +echo "Confirmed that comment attribute of user was correctly updated" + +# try get all users. At this moment we should have 2 users total +curl --silent --insecure -X 'GET' \ + 'https://localhost/api/v1/user' \ + -H 'accept: application/json' \ + -H 'Authorization: Bearer apitest' \ + | grep -o "email" | grep -c "email" | grep 2 + +if [ $? -ne 0 ]; then + exit 1 +fi +echo "Retrieved all users successfully" + +echo "Finished 01_test_user_interfaces.sh" \ No newline at end of file diff --git a/tests/compose/api/02_test_domain_interfaces.sh b/tests/compose/api/02_test_domain_interfaces.sh new file mode 100755 index 00000000..b9238594 --- /dev/null +++ b/tests/compose/api/02_test_domain_interfaces.sh @@ -0,0 +1,145 @@ +echo "Test Domain interfaces" + +curl --silent --insecure -X 'POST' \ + 'https://localhost/api/v1/domain' \ + -H 'accept: application/json' \ + -H 'Authorization: Bearer apitest' \ + -H 'Content-Type: application/json' \ + -d '{ + "name": "mailu2.io", + "comment": "internal domain for testing", + "max_users": -1, + "max_aliases": -1, + "max_quota_bytes": 0, + "signup_enabled": false +}' | grep 200 + +if [ $? -ne 0 ]; then + exit 1 +fi +echo "Domain mail2.io has been created successfully" + +curl --silent --insecure -X 'PATCH' \ + 'https://localhost/api/v1/domain/mailu2.io' \ + -H 'accept: application/json' \ + -H 'Authorization: Bearer apitest' \ + -H 'Content-Type: application/json' \ + -d '{ + "comment": "updated_domain" +}' | grep 200 + +if [ $? -ne 0 ]; then + exit 1 +fi +echo "Domain mail2.io has been updated" + +curl --silent --insecure -X 'GET' \ + 'https://localhost/api/v1/domain/mailu2.io' \ + -H 'accept: application/json' \ + -H 'Authorization: apitest' \ + | grep updated_domain + +if [ $? -ne 0 ]; then + exit 1 +fi +echo "Confirmed that comment attribute of domain mailu2.io was correctly updated" + +# try get all domains. At this moment we should have 2 domains total +curl --silent --insecure -X 'GET' \ + 'https://localhost/api/v1/domain' \ + -H 'accept: application/json' \ + -H 'Authorization: Bearer apitest' \ + | grep -o "name" | grep -c "name" | grep 2 + +if [ $? -ne 0 ]; then + exit 1 +fi +echo "Retrieved all domains successfully" + +# try create dkim keys +curl --silent --insecure -X 'POST' \ + 'https://localhost/api/v1/domain/mailu2.io/dkim' \ + -H 'accept: application/json' \ + -H 'Authorization: apitest' \ + -d '' \ + | grep 200 + +if [ $? -ne 0 ]; then + exit 1 +fi +echo "dkim keys were created successfully for domain mailu2.io" + +# try deleting a domain +curl --silent --insecure -X 'DELETE' \ + 'https://localhost/api/v1/domain/mailu2.io' \ + -H 'accept: application/json' \ + -H 'Authorization: Bearer apitest' \ + | grep 200 + +if [ $? -ne 0 ]; then + exit 1 +fi +echo "Domain mailu2.io was deleted successfully" + +# try looking up all users of a domain. There should be 2 users. +curl --silent --insecure -X 'GET' \ + 'https://localhost/api/v1/domain/mailu.io/users' \ + -H 'accept: application/json' \ + -H 'Authorization: apitest' \ + | grep -o "email" | grep -c "email" | grep 2 + +if [ $? -ne 0 ]; then + exit 1 +fi +echo "Retrieved all users of domain mailu.io successfully" + + +#### Alternatives + +#try to create an alternative +curl --silent --insecure -X 'POST' \ + 'https://localhost/api/v1/alternative' \ + -H 'accept: application/json' \ + -H 'Authorization: apitest' \ + -H 'Content-Type: application/json' \ + -d '{ + "name": "mailu2.io", + "domain": "mailu.io" +}' | grep 200 + +if [ $? -ne 0 ]; then + exit 1 +fi +echo "Alternative mailu2.io for domain mailu.io was created successfully" + +# try get all alternatives. At this moment we should have 1 alternative total +curl --silent --insecure -X 'GET' \ + 'https://localhost/api/v1/alternative' \ + -H 'accept: application/json' \ + -H 'Authorization: Bearer apitest' \ + | grep -o "name" | grep -c "name" | grep 1 + +if [ $? -ne 0 ]; then + exit 1 +fi +echo "Retrieved all alternatives successfully" + +# try to check if an alternative exists +curl --silent --insecure -X 'GET' \ + 'https://localhost/api/v1/alternative/mailu2.io' \ + -H 'accept: application/json' \ + -H 'Authorization: apitest' \ + | grep '{"name": "mailu2.io", "domain": "mailu.io"}' + +if [ $? -ne 0 ]; then + exit 1 +fi +echo "Lookup for alternative mailu2.io was successful" + +# try to delete an alternative +curl --silent --insecure -X 'DELETE' \ + 'https://localhost/api/v1/alternative/mailu2.io' \ + -H 'accept: application/json' \ + -H 'Authorization: apitest' + +echo "Finshed 02_test_domain_interfaces.sh" \ No newline at end of file diff --git a/tests/compose/api/03_test_token_interfaces.sh b/tests/compose/api/03_test_token_interfaces.sh new file mode 100755 index 00000000..f81cc9be --- /dev/null +++ b/tests/compose/api/03_test_token_interfaces.sh @@ -0,0 +1,107 @@ +echo "start token tests" + +# Try creating a token /token +curl --silent --insecure -X 'POST' \ + 'https://localhost/api/v1/token' \ + -H 'accept: application/json' \ + -H 'Authorization: apitest' \ + -H 'Content-Type: application/json' \ + -d '{ + "email": "user@mailu.io", + "comment": "my token related comment", + "AuthorizedIP": [ + "203.0.113.0/24", + "203.2.114.2/32" + ] +}' | grep '"token": "' +if [ $? -ne 0 ]; then + exit 1 +fi +echo "created a token for user@mailu.io successfully" + +# Try create a token for a specific user /tokenuser/{email} +curl --silent --insecure -X 'POST' \ + 'https://localhost/api/v1/tokenuser/user%40mailu.io' \ + -H 'accept: application/json' \ + -H 'Authorization: apitest' \ + -H 'Content-Type: application/json' \ + -d '{ + "comment": "token test" +}' | grep '"token": "' +if [ $? -ne 0 ]; then + exit 1 +fi +echo "created a second token for user@mailu.io successfully" + +# Try retrieving all tokens /token. We expect to retrieve 2 in total. +curl --silent --insecure -X 'GET' \ + 'https://localhost/api/v1/token' \ + -H 'accept: application/json' \ + -H 'Authorization: apitest' \ + | grep -o "id" | grep -c "id" | grep 2 +if [ $? -ne 0 ]; then + exit 1 +fi +echo "Retrieved all tokens (2 in total) successfully" + +# Try finding a specific token /token/{token_id} +curl --silent --insecure -X 'GET' \ + 'https://localhost/api/v1/token/2' \ + -H 'accept: application/json' \ + -H 'Authorization: apitest' \ + | grep '"id": 2' +if [ $? -ne 0 ]; then + exit 1 +fi +echo "Retrieved token with id 2 successfully" + +# Try deleting a token /token/{token_id} +curl --silent --insecure -X 'DELETE' \ + 'https://localhost/api/v1/token/1' \ + -H 'accept: application/json' \ + -H 'Authorization: apitest' \ + | grep 200 +if [ $? -ne 0 ]; then + exit 1 +fi +echo "Deleted token with id 1 successfully" + +# Try updating a token /token/{token_id} +curl --silent --insecure -X 'PATCH' \ + 'https://localhost/api/v1/token/2' \ + -H 'accept: application/json' \ + -H 'Authorization: apitest' \ + -H 'Content-Type: application/json' \ + -d '{ + "comment": "updated_comment", + "AuthorizedIP": [ + "203.0.112.0/24" + ] +}' | grep 200 +if [ $? -ne 0 ]; then + exit 1 +fi +echo "Updated token with id 2 successfully" + +curl --silent --insecure -X 'GET' \ + 'https://localhost/api/v1/token/2' \ + -H 'accept: application/json' \ + -H 'Authorization: apitest' \ + | grep 'comment": "updated_comment"' +if [ $? -ne 0 ]; then + exit 1 +fi +echo "Confirmed that comment field of token with id 2 was correctly updated" + +# Try looking up all tokens of a specific user /tokenuser/{email} +curl --silent --insecure -X 'GET' \ + 'https://localhost/api/v1/tokenuser/user%40mailu.io' \ + -H 'accept: application/json' \ + -H 'Authorization: apitest' \ + | grep -o "id" | grep -c "id" | grep 1 +if [ $? -ne 0 ]; then + exit 1 +fi +echo "Retrieved all tokens (1 in total) for user@mailu.io successfully" + +echo "Finished 03_test_token_interfaces.sh" \ No newline at end of file diff --git a/tests/compose/api/04_test_relay_interfaces.sh b/tests/compose/api/04_test_relay_interfaces.sh new file mode 100755 index 00000000..87d1a173 --- /dev/null +++ b/tests/compose/api/04_test_relay_interfaces.sh @@ -0,0 +1,98 @@ +echo "Start 04_test_relay_interfaces.sh" + +# Try creating a new relay /relay +curl --silent --insecure -X 'POST' \ + 'https://localhost/api/v1/relay' \ + -H 'accept: application/json' \ + -H 'Authorization: apitest' \ + -H 'Content-Type: application/json' \ + -d '{ + "name": "relay1.mailu.io", + "smtp": "relay1.mailu.io:8755", + "comment": "backup relay1" +}' | grep 200 +if [ $? -ne 0 ]; then + exit 1 +fi +echo "created a relay for domain relay1.mailu.io successfully" + +curl --silent --insecure -X 'POST' \ + 'https://localhost/api/v1/relay' \ + -H 'accept: application/json' \ + -H 'Authorization: apitest' \ + -H 'Content-Type: application/json' \ + -d '{ + "name": "relay2.mailu.io", + "comment": "backup relay2" +}' | grep 200 +if [ $? -ne 0 ]; then + exit 1 +fi +echo "created a relay for domain relay2.mailu.io successfully" + +# Try retrieving all relays /relay. We expect to retrieve 2 in total +curl --silent --insecure -X 'GET' \ + 'https://localhost/api/v1/relay' \ + -H 'accept: application/json' \ + -H 'Authorization: apitest' \ + | grep -o '"name":' | grep -c '"name":' | grep 2 +if [ $? -ne 0 ]; then + exit 1 +fi +echo "Retrieved all relays (2 in total) successfully" + +# Try looking up a specific relay /relay/{name} +curl --silent --insecure -X 'GET' \ + 'https://localhost/api/v1/relay/relay1.mailu.io' \ + -H 'accept: application/json' \ + -H 'Authorization: apitest' \ + | grep '"name": "relay1.mailu.io"' +if [ $? -ne 0 ]; then + exit 1 +fi +echo "Retrieved the specified relay (relay1.mailu.io) successfully" + +# Try deleting a specific relay /relay/{name} +curl -silent --insecure -X 'DELETE' \ + 'https://localhost/api/v1/relay/relay2.mailu.io' \ + -H 'accept: application/json' \ + -H 'Authorization: apitest' \ + | grep 200 +if [ $? -ne 0 ]; then + exit 1 +fi +echo "Deleted relay2.mailu.io successfully" + +curl --silent --insecure -X 'GET' \ + 'https://localhost/api/v1/relay' \ + -H 'accept: application/json' \ + -H 'Authorization: apitest' \ + | grep -o '"name":' | grep -c '"name":' | grep 1 +if [ $? -ne 0 ]; then + exit 1 +fi +echo "confirmed we only have 1 relay now" + +# Try updating a specific relay /relay/{name} +curl --silent --insecure -X 'PATCH' \ + 'https://localhost/api/v1/relay/relay1.mailu.io' \ + -H 'accept: application/json' \ + -H 'Authorization: apitest' \ + -H 'Content-Type: application/json' \ + -d '{ + "smtp": "anotherName", + "comment": "updated_comment" +}' | grep 200 +if [ $? -ne 0 ]; then + exit 1 +fi +echo "update of relay was succcessful" + +curl --silent --insecure -X 'GET' \ + 'https://localhost/api/v1/relay/relay1.mailu.io' \ + -H 'accept: application/json' \ + -H 'Authorization: apitest' \ + | grep anotherName | grep updated_comment +echo "confirmed that smtp attribute and comment attribute were correctly updated" + +echo "Finished 04_test_relay_interfaces.sh" \ No newline at end of file diff --git a/tests/compose/api/05_test_alias_interfaces.sh b/tests/compose/api/05_test_alias_interfaces.sh new file mode 100755 index 00000000..036091e6 --- /dev/null +++ b/tests/compose/api/05_test_alias_interfaces.sh @@ -0,0 +1,111 @@ +# try create, find, lookup, delete + +echo "Start 05_test_alias_interfaces.sh" + +# Try creating a new alias /alias +curl --silent --insecure -X 'POST' \ + 'https://localhost/api/v1/alias' \ + -H 'accept: application/json' \ + -H 'Authorization: apitest' \ + -H 'Content-Type: application/json' \ + -d '{ + "comment": "test alias for user@mailu.io and admin@mailu.io", + "destination": [ + "user@mailu.io", + "admin@mailu.io" + ], + "wildcard": false, + "email": "test@mailu.io" +}' | grep 200 +if [ $? -ne 0 ]; then + exit 1 +fi +echo "Created alias test@mailu.io succcessfully for user@mailu.io and admin@mailu.io" + +curl --silent --insecure -X 'POST' \ + 'https://localhost/api/v1/alias' \ + -H 'accept: application/json' \ + -H 'Authorization: apitest' \ + -H 'Content-Type: application/json' \ + -d '{ + "comment": "test2 alias for user@mailu.io", + "destination": [ + "user@mailu.io" + ], + "wildcard": false, + "email": "test2@mailu.io" +}' | grep 200 +if [ $? -ne 0 ]; then + exit 1 +fi +echo "Created alias test2@mailu.io succcessfully for user@mailu.io " + +# Try retrieving all aliases /alias. We expect to retrieve 2 +curl --silent --insecure -X 'GET' \ + 'https://localhost/api/v1/alias' \ + -H 'accept: application/json' \ + -H 'Authorization: apitest' \ + | grep -o '"destination":' | grep -c '"destination":' | grep 2 +if [ $? -ne 0 ]; then + exit 1 +fi +echo "Successfully retrieved 2 aliases" + +# Try looking up the aliases for a specific domain /alias/destination/{domain}. We expect to retrieve 2 +curl --silent --insecure -X 'GET' \ + 'https://localhost/api/v1/alias/destination/mailu.io' \ + -H 'accept: application/json' \ + -H 'Authorization: apitest' \ + | grep -o '"destination":' | grep -c '"destination":' | grep 2 +if [ $? -ne 0 ]; then + exit 1 +fi +echo "Successfully retrieved 2 aliases" + +# Try deleting a specific alias /alias/{alias} +curl --silent --insecure -X 'DELETE' \ + 'https://localhost/api/v1/alias/test2%40mailu.io' \ + -H 'accept: application/json' \ + -H 'Authorization: apitest' \ + | grep 200 +if [ $? -ne 0 ]; then + exit 1 +fi +echo "Deleted alias test2@mailu.io succcessfully" + +# Try updating a specific alias /alias/{alias} +curl --silent --insecure -X 'PATCH' \ + 'https://localhost/api/v1/alias/test%40mailu.io' \ + -H 'accept: application/json' \ + -H 'Authorization: apitest' \ + -H 'Content-Type: application/json' \ + -d '{ + "comment": "updated_comment", + "destination": [ + "user@mailu.io" + ], + "wildcard": true +}' | grep 200 +if [ $? -ne 0 ]; then + exit 1 +fi +echo "Updated alias test2@mailu.io succcessfully" + +# Try looking up a specific alias /alias/{alias}. +#Check if values were updated correctyly in previous step. +response=$(curl --silent --insecure -X 'GET' \ + 'https://localhost/api/v1/alias/test%40mailu.io' \ + -H 'accept: application/json' \ + -H 'Authorization: apitest') +echo $response | grep 'admin@mailu.io' +if [ $? -ne 1 ]; then + exit 1 +fi +echo "Confirmed that destination admin@mailu.io is removed from alias test@mailu.io" +echo $response | grep 'updated_comment' +if [ $? -ne 0 ]; then + exit 1 +fi +echo "Confirmed that comment attribute is updated successfully" + +echo "Finished 05_test_alias_interfaces.sh" \ No newline at end of file diff --git a/tests/compose/api/docker-compose.yml b/tests/compose/api/docker-compose.yml new file mode 100644 index 00000000..384e89cd --- /dev/null +++ b/tests/compose/api/docker-compose.yml @@ -0,0 +1,112 @@ +# This file is auto-generated by the Mailu configuration wizard. +# Please read the documentation before attempting any change. +# Generated for compose flavor + +version: '3.6' + +services: + + # External dependencies + redis: + image: redis:alpine + restart: always + volumes: + - "/mailu/redis:/data" + + # Core services + front: + image: ${DOCKER_ORG:-mailu}/${DOCKER_PREFIX:-}nginx:${MAILU_VERSION:-local} + restart: always + env_file: mailu.env + logging: + driver: json-file + ports: + - "127.0.0.1:80:80" + - "127.0.0.1:443:443" + - "127.0.0.1:25:25" + - "127.0.0.1:465:465" + - "127.0.0.1:587:587" + - "127.0.0.1:110:110" + - "127.0.0.1:995:995" + - "127.0.0.1:143:143" + - "127.0.0.1:993:993" + - "127.0.0.1:4190:4190" + volumes: + - "/mailu/certs:/certs" + + admin: + image: ${DOCKER_ORG:-mailu}/${DOCKER_PREFIX:-}admin:${MAILU_VERSION:-local} + restart: always + env_file: mailu.env + volumes: + - "/mailu/data:/data" + - "/mailu/dkim:/dkim" + dns: + - 192.168.203.254 + depends_on: + - redis + - resolver + + imap: + image: ${DOCKER_ORG:-mailu}/${DOCKER_PREFIX:-}dovecot:${MAILU_VERSION:-local} + restart: always + env_file: mailu.env + volumes: + - "/mailu/mail:/mail" + - "/mailu/overrides:/overrides" + depends_on: + - front + + smtp: + image: ${DOCKER_ORG:-mailu}/${DOCKER_PREFIX:-}postfix:${MAILU_VERSION:-local} + restart: always + env_file: mailu.env + volumes: + - "/mailu/overrides:/overrides" + depends_on: + - front + + oletools: + image: ${DOCKER_ORG:-mailu}/${DOCKER_PREFIX:-}oletools:${MAILU_VERSION:-local} + hostname: oletools + restart: always + networks: + - noinet + + antispam: + image: ${DOCKER_ORG:-mailu}/${DOCKER_PREFIX:-}rspamd:${MAILU_VERSION:-local} + restart: always + env_file: mailu.env + networks: + - default + - noinet + volumes: + - "/mailu/filter:/var/lib/rspamd" + - "/mailu/dkim:/dkim" + - "/mailu/overrides/rspamd:/etc/rspamd/override.d" + depends_on: + - front + + # Optional services + + resolver: + image: ${DOCKER_ORG:-mailu}/${DOCKER_PREFIX:-}unbound:${MAILU_VERSION:-local} + env_file: mailu.env + restart: always + networks: + default: + ipv4_address: 192.168.203.254 + + # Webmail + + +networks: + default: + driver: bridge + ipam: + driver: default + config: + - subnet: 192.168.203.0/24 + noinet: + driver: bridge + internal: true diff --git a/tests/compose/api/mailu.env b/tests/compose/api/mailu.env new file mode 100644 index 00000000..d667496c --- /dev/null +++ b/tests/compose/api/mailu.env @@ -0,0 +1,151 @@ +# Mailu main configuration file +# +# Generated for compose flavor +# +# This file is autogenerated by the configuration management wizard. +# For a detailed list of configuration variables, see the documentation at +# https://mailu.io + +################################### +# Common configuration variables +################################### + +# Set this to the path where Mailu data and configuration is stored +# This variable is now set directly in `docker-compose.yml by the setup utility +# ROOT=/mailu + +# Mailu version to run (1.0, 1.1, etc. or master) +#VERSION=master + +# Set to a randomly generated 16 bytes string +SECRET_KEY=HGZCYGVI6FVG31HS + +# Address where listening ports should bind +# This variables are now set directly in `docker-compose.yml by the setup utility +# PUBLIC_IPV4= 127.0.0.1 (default: 127.0.0.1) +# PUBLIC_IPV6= (default: ::1) + +# Subnet of the docker network. This should not conflict with any networks to which your system is connected. (Internal and external!) +SUBNET=192.168.203.0/24 + +# Main mail domain +DOMAIN=mailu.io + +# Hostnames for this server, separated with commas +HOSTNAMES=localhost + +# Postmaster local part (will append the main mail domain) +POSTMASTER=admin + +# Choose how secure connections will behave (value: letsencrypt, cert, notls, mail, mail-letsencrypt) +TLS_FLAVOR=cert + +# Authentication rate limit (per source IP address) +AUTH_RATELIMIT=10/minute;1000/hour + +# Opt-out of statistics, replace with "True" to opt out +DISABLE_STATISTICS=False + +################################### +# Optional features +################################### + +# Expose the admin interface (value: true, false) +ADMIN=true + +# Choose which webmail to run if any (values: roundcube, snappymail, none) +WEBMAIL=none + +# Dav server implementation (value: radicale, none) +WEBDAV=none + +# Antivirus solution (value: clamav, none) +#ANTIVIRUS=none + +#Antispam solution +ANTISPAM=none + +#RESTful API +API=true + +# Scan Macros solution (value: true, false) +SCAN_MACROS=True + +################################### +# Mail settings +################################### + +# Message size limit in bytes +# Default: accept messages up to 50MB +MESSAGE_SIZE_LIMIT=50000000 + +# Networks granted relay permissions +# Use this with care, all hosts in this networks will be able to send mail without authentication! +RELAYNETS= + +# Will relay all outgoing mails if configured +RELAYHOST= + +# Show fetchmail functionality in admin interface +FETCHMAIL_ENABLED=false + +# Fetchmail delay +FETCHMAIL_DELAY=600 + +# Recipient delimiter, character used to delimiter localpart from custom address part +RECIPIENT_DELIMITER=+ + +# DMARC rua and ruf email +DMARC_RUA=admin +DMARC_RUF=admin + + +# Maildir Compression +# choose compression-method, default: none (value: gz, bz2, lz4, zstd) +COMPRESSION= +# change compression-level, default: 6 (value: 1-9) +COMPRESSION_LEVEL= + +################################### +# Web settings +################################### + +# Path to the admin interface if enabled +WEB_ADMIN=/admin + +# Path to the webmail if enabled +WEB_WEBMAIL=/webmail + +WEB_API=/api + +# Website name +SITENAME=Mailu + +# Linked Website URL +WEBSITE=https://mailu.io + + + +################################### +# Advanced settings +################################### + +# Log driver for front service. Possible values: +# json-file (default) +# journald (On systemd platforms, useful for Fail2Ban integration) +# syslog (Non systemd platforms, Fail2Ban integration. Disables `docker compose log` for front!) +# LOG_DRIVER=json-file + +# Docker-compose project name, this will prepended to containers names. +COMPOSE_PROJECT_NAME=mailu + +# Header to take the real ip from +REAL_IP_HEADER= + +# IPs for nginx set_real_ip_from (CIDR list separated by commas) +REAL_IP_FROM= + +# choose wether mailu bounces (no) or rejects (yes) mail when recipient is unknown (value: yes, no) +REJECT_UNLISTED_RECIPIENT= + +API_TOKEN=apitest diff --git a/tests/compose/core/mailu.env b/tests/compose/core/mailu.env index e7456679..5b99d33f 100644 --- a/tests/compose/core/mailu.env +++ b/tests/compose/core/mailu.env @@ -41,7 +41,7 @@ POSTMASTER=admin TLS_FLAVOR=cert # Authentication rate limit (per source IP address) -AUTH_RATELIMIT=10/minute;1000/hour +AUTH_RATELIMIT=10/minute;1000/hour # Opt-out of statistics, replace with "True" to opt out DISABLE_STATISTICS=False @@ -147,3 +147,5 @@ REJECT_UNLISTED_RECIPIENT= INITIAL_ADMIN_ACCOUNT=admin INITIAL_ADMIN_DOMAIN=mailu.io INITIAL_ADMIN_PW=FooBar + +PORTS=25,80,443,110,995,143,993,587,465,4190 \ No newline at end of file diff --git a/tests/compose/fetchmail/mailu.env b/tests/compose/fetchmail/mailu.env index af736912..570fccd4 100644 --- a/tests/compose/fetchmail/mailu.env +++ b/tests/compose/fetchmail/mailu.env @@ -142,3 +142,5 @@ REAL_IP_FROM= # choose wether mailu bounces (no) or rejects (yes) mail when recipient is unknown (value: yes, no) REJECT_UNLISTED_RECIPIENT= + +PORTS=25,80,443,110,995,143,993,587,465,4190 \ No newline at end of file diff --git a/tests/compose/filters/mailu.env b/tests/compose/filters/mailu.env index 0c48baf3..f8536e65 100644 --- a/tests/compose/filters/mailu.env +++ b/tests/compose/filters/mailu.env @@ -142,3 +142,5 @@ REAL_IP_FROM= # choose wether mailu bounces (no) or rejects (yes) mail when recipient is unknown (value: yes, no) REJECT_UNLISTED_RECIPIENT= + +PORTS=25,80,443,110,995,143,993,587,465,4190 \ No newline at end of file diff --git a/tests/compose/webdav/mailu.env b/tests/compose/webdav/mailu.env index 88f3c671..cbda1e67 100644 --- a/tests/compose/webdav/mailu.env +++ b/tests/compose/webdav/mailu.env @@ -142,3 +142,5 @@ REAL_IP_FROM= # choose wether mailu bounces (no) or rejects (yes) mail when recipient is unknown (value: yes, no) REJECT_UNLISTED_RECIPIENT= + +PORTS=25,80,443,110,995,143,993,587,465,4190 \ No newline at end of file diff --git a/tests/compose/webmail/mailu.env b/tests/compose/webmail/mailu.env index 53b86cba..fc78eeab 100644 --- a/tests/compose/webmail/mailu.env +++ b/tests/compose/webmail/mailu.env @@ -142,3 +142,5 @@ REAL_IP_FROM= # choose wether mailu bounces (no) or rejects (yes) mail when recipient is unknown (value: yes, no) REJECT_UNLISTED_RECIPIENT= + +PORTS=25,80,443,110,995,143,993,587,465,4190 \ No newline at end of file diff --git a/tests/requirements.txt b/tests/requirements.txt index 67060918..8d06bd70 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -1,3 +1,4 @@ -docker==4.2.2 -colorama==0.4.3 +docker==7.0.0 +colorama==0.4.6 managesieve==0.7.1 +requests==2.31.0 diff --git a/towncrier/newsfragments/3029.bugfix b/towncrier/newsfragments/3029.bugfix new file mode 100644 index 00000000..a502affe --- /dev/null +++ b/towncrier/newsfragments/3029.bugfix @@ -0,0 +1,29 @@ +Added missing translations for Dutch, German and French. +6 new strings were introduced after 2.0. These must be translated for all languages. +If this translation is missing for your native language, please submit a PR with the translation, +or open a new issue where you mention the translated strings. + +The missing translations are: +#: mailu/ui/templates/domain/details.html:19 +msgid "Download zonefile" +msgstr "translation of Download zonefile" + +#: mailu/ui/forms.py:134 +msgid "Current password" +msgstr "translation of Current password" + +#: mailu/ui/forms.py:102 +msgid "Force password change at next login" +msgstr "translation of password change at next login" + +#: mailu/ui/forms.py:98 +msgid "Allow the user to spoof the sender (send email as anyone)" +msgstr "translation of Allow the user to spoof the sender (send email as anyone)" + +#: mailu/ui/templates/client.html:62 +msgid "If you use an Apple device," +msgstr "translation of If you use an Apple device," + +#: mailu/ui/templates/client.html:63 +msgid "click here to auto-configure it." +msgstr "translation of click here to auto-configure it." \ No newline at end of file diff --git a/towncrier/newsfragments/3061.feature b/towncrier/newsfragments/3061.feature new file mode 100644 index 00000000..b8f3e4cd --- /dev/null +++ b/towncrier/newsfragments/3061.feature @@ -0,0 +1,6 @@ +Introduce new settings for configuring proxying and TLS. Disable POP3, IMAP and SUBMISSION by default, see https://nostarttls.secvuln.info/ +- Drop TLS_FLAVOR=mail-* +- Change the meaning of PROXY_PROTOCOL, introduce PORTS +- Disable POP3, IMAP and SUBMISSION ports by default, to re-enable ensure PORTS include 110, 143 and 587 + +MANAGESIEVE with implicit TLS is not a thing clients support... so 4190 is enabled by default. diff --git a/towncrier/newsfragments/3068.bugfix b/towncrier/newsfragments/3068.bugfix new file mode 100644 index 00000000..b7689088 --- /dev/null +++ b/towncrier/newsfragments/3068.bugfix @@ -0,0 +1,2 @@ +When "Enable marking spam mails as read" was disabled, new spam messages were still marked as read. +Updated documentation with the setting "Enable marking spam mails as read". diff --git a/towncrier/newsfragments/3113.bugfix b/towncrier/newsfragments/3113.bugfix new file mode 100644 index 00000000..4d84e335 --- /dev/null +++ b/towncrier/newsfragments/3113.bugfix @@ -0,0 +1 @@ +Some RESTful API interfaces were incorrectly documented. \ No newline at end of file diff --git a/towncrier/newsfragments/3207.feature b/towncrier/newsfragments/3207.feature new file mode 100644 index 00000000..a3ea2baf --- /dev/null +++ b/towncrier/newsfragments/3207.feature @@ -0,0 +1 @@ +Add belarusian translation diff --git a/towncrier/newsfragments/3238.bugfix b/towncrier/newsfragments/3238.bugfix new file mode 100644 index 00000000..adfa975a --- /dev/null +++ b/towncrier/newsfragments/3238.bugfix @@ -0,0 +1 @@ +Fix purge_user.sh diff --git a/towncrier/newsfragments/3251.bugfix b/towncrier/newsfragments/3251.bugfix new file mode 100644 index 00000000..e096521f --- /dev/null +++ b/towncrier/newsfragments/3251.bugfix @@ -0,0 +1 @@ +Fix CVE-2024-1135 diff --git a/towncrier/newsfragments/3260.bugfix b/towncrier/newsfragments/3260.bugfix new file mode 100644 index 00000000..268da281 --- /dev/null +++ b/towncrier/newsfragments/3260.bugfix @@ -0,0 +1 @@ +Fix a bug preventing double quotes from being used in ooo messages diff --git a/towncrier/newsfragments/3261.bugfix b/towncrier/newsfragments/3261.bugfix new file mode 100644 index 00000000..2564c1dc --- /dev/null +++ b/towncrier/newsfragments/3261.bugfix @@ -0,0 +1 @@ +Updated roundcube to version 1.6.7 diff --git a/towncrier/newsfragments/3279.misc b/towncrier/newsfragments/3279.misc new file mode 100644 index 00000000..c7a3c655 --- /dev/null +++ b/towncrier/newsfragments/3279.misc @@ -0,0 +1,2 @@ +Switch to alpine 3.20, remove a dependency on edge for dovecot +Ensure we user normalizer-icu in all languages diff --git a/webmails/Dockerfile b/webmails/Dockerfile index d233a1a8..950c3f3c 100644 --- a/webmails/Dockerfile +++ b/webmails/Dockerfile @@ -20,7 +20,6 @@ RUN set -euxo pipefail \ aspell-uk aspell-ru aspell-fr aspell-de aspell-en \ ; rm /etc/nginx/http.d/default.conf \ ; rm /etc/php83/php-fpm.d/www.conf \ - ; ln -s /usr/bin/php83 /usr/bin/php \ ; mkdir -m 700 /root/.gnupg/ \ ; gpg --import /tmp/snappymail.asc \ ; gpg --import /tmp/roundcube.asc \ @@ -29,7 +28,7 @@ RUN set -euxo pipefail \ ; mkdir -p /run/nginx /conf # roundcube -ENV ROUNDCUBE_URL https://github.com/roundcube/roundcubemail/releases/download/1.6.6/roundcubemail-1.6.6-complete.tar.gz +ENV ROUNDCUBE_URL https://github.com/roundcube/roundcubemail/releases/download/1.6.7/roundcubemail-1.6.7-complete.tar.gz ENV CARDDAV_URL https://github.com/mstilkerich/rcmcarddav/releases/download/v5.1.0/carddav-v5.1.0.tar.gz RUN set -euxo pipefail \ @@ -55,7 +54,7 @@ COPY roundcube/config/config.inc.carddav.php /var/www/roundcube/plugins/carddav/ # snappymail -ENV SNAPPYMAIL_URL https://github.com/the-djmaze/snappymail/releases/download/v2.31.0/snappymail-2.31.0.tar.gz +ENV SNAPPYMAIL_URL https://github.com/the-djmaze/snappymail/releases/download/v2.36.1/snappymail-2.36.1.tar.gz RUN set -euxo pipefail \ ; mkdir /var/www/snappymail \ diff --git a/webmails/snappymail/defaults/default.json b/webmails/snappymail/defaults/default.json index 12252aee..dee93332 100644 --- a/webmails/snappymail/defaults/default.json +++ b/webmails/snappymail/defaults/default.json @@ -3,7 +3,7 @@ "IMAP": { "host": "{{ FRONT_ADDRESS }}", "port": 10143, - "secure": 2, + "secure": 3, "shortLogin": false, "ssl": { "verify_peer": false, @@ -20,7 +20,7 @@ "SMTP": { "host": "{{ FRONT_ADDRESS }}", "port": 10025, - "secure": 2, + "secure": 3, "shortLogin": false, "ssl": { "verify_peer": false, @@ -37,7 +37,7 @@ "Sieve": { "host": "{{ FRONT_ADDRESS }}", "port": 14190, - "type": 2, + "type": 3, "shortLogin": false, "ssl": { "verify_peer": false, diff --git a/webmails/snuffleupagus.rules b/webmails/snuffleupagus.rules index b3f69819..4cbe966d 100644 --- a/webmails/snuffleupagus.rules +++ b/webmails/snuffleupagus.rules @@ -71,6 +71,7 @@ sp.disable_function.function("include").drop() # Prevent `system`-related injections sp.disable_function.function("system").param("command").value_r("[$|;&`\\n\\(\\)\\\\]").drop(); +sp.disable_function.function("exec_shell").filename_r("/var/www/snappymail/snappymail/v/[0-9]+\.[0-9]+\.[0-9]+/app/libraries/snappymail/gpg/base.php").allow(); sp.disable_function.function("shell_exec").param("command").value_r("[$|;&`\\n\\(\\)\\\\]").drop(); sp.disable_function.function("exec").param("command").value_r("[$|;&`\\n\\(\\)\\\\]").drop(); # This is **very** broad but doing better is non-straightforward @@ -91,17 +92,18 @@ sp.disable_function.function("ini_get").filename("/var/www/roundcube/plugins/man sp.disable_function.function("ini_get").param("option").value("allow_url_fopen").drop(); sp.disable_function.function("ini_get").param("option").value("open_basedir").drop(); sp.disable_function.function("ini_get").param("option").value_r("suhosin").drop(); +sp.disable_function.function("function_exists").filename_r("/var/www/snappymail/snappymail/v/[0-9]+\.[0-9]+\.[0-9]+/app/libraries/snappymail/gpg/base.php").allow(); sp.disable_function.function("function_exists").param("function").value("eval").drop(); sp.disable_function.function("function_exists").param("function").value("exec").drop(); sp.disable_function.function("function_exists").param("function").value("system").drop(); sp.disable_function.function("function_exists").param("function").value("shell_exec").drop(); sp.disable_function.function("function_exists").param("function").value("proc_open").drop(); sp.disable_function.function("function_exists").param("function").value("passthru").drop(); +sp.disable_function.function("is_callable").filename_r("/var/www/snappymail/snappymail/v/[0-9]+\.[0-9]+\.[0-9]+/app/libraries/snappymail/gpg/base.php").allow(); sp.disable_function.function("is_callable").param("value").value("eval").drop(); sp.disable_function.function("is_callable").param("value").value("exec").drop(); sp.disable_function.function("is_callable").param("value").value("system").drop(); sp.disable_function.function("is_callable").param("value").value("shell_exec").drop(); -sp.disable_function.function("is_callable").filename_r("^/var/www/snappymail/snappymail/v/[0-9]+\.[0-9]+\.[0-9]+/app/libraries/snappymail/pgp/gpg\.php$").param("value").value("proc_open").allow(); sp.disable_function.function("is_callable").param("value").value("proc_open").drop(); sp.disable_function.function("is_callable").param("value").value("passthru").drop();