Merge remote-tracking branch 'upstream/master' into kvsession

master
Alexander Graf 3 years ago
commit fab3168c23

@ -0,0 +1,318 @@
name: CI
on:
push:
branches:
- staging
- testing
- '1.5'
- '1.6'
- '1.7'
- '1.8'
- master
# version tags, e.g. 1.7.1
- '[1-9].[0-9].[0-9]'
# pre-releases, e.g. 1.8-pre1
- 1.8-pre[0-9]
# test branches, e.g. test-debian
- test-*
###############################################
# REQUIRED secrets
# DOCKER_UN: ${{ secrets.Docker_Login }}
# Username of docker login for pushing the images to repo $DOCKER_ORG
# DOCKER_PW: ${{ secrets.Docker_Password }}
# Password of docker login for pushing the images to repo $DOCKER_ORG
# DOCKER_ORG: ${{ secrets.DOCKER_ORG }}
# The docker repository where the images are pushed to.
# DOCKER_ORG_TESTS: ${{ secrets.DOCKER_ORG_TESTS }}
# The docker repository for test images. Only used for the branch TESTING (BORS try).
# Add the above secrets to your github repo to determine where the images will be pushed.
################################################
jobs:
build:
name: Build images
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Extract branch name
shell: bash
run: |
echo "BRANCH=${GITHUB_REF#refs/heads/}" >> $GITHUB_ENV
- name: Create folder for storing images
run: |
sudo mkdir -p /images
sudo chmod 777 /images
- name: Configure images folder for caching
uses: actions/cache@v2
with:
path: /images
key: ${{ env.BRANCH }}-${{ github.run_id }}-${{ github.run_number }}
- name: Check docker-compose version
run: docker-compose -v
- name: Login docker
env:
DOCKER_UN: ${{ secrets.Docker_Login }}
DOCKER_PW: ${{ secrets.Docker_Password }}
DOCKER_ORG: ${{ secrets.DOCKER_ORG }}
run: echo "$DOCKER_PW" | docker login --username $DOCKER_UN --password-stdin
- name: Build all docker images
env:
MAILU_VERSION: ${{ env.BRANCH }}
TRAVIS_BRANCH: ${{ env.BRANCH }}
DOCKER_ORG: ${{ secrets.DOCKER_ORG }}
run: docker-compose -f tests/build.yml build
- name: Save all docker images
run: docker save ${{ secrets.DOCKER_ORG }}/admin ${{ secrets.DOCKER_ORG }}/clamav ${{ secrets.DOCKER_ORG }}/docs ${{ secrets.DOCKER_ORG }}/dovecot ${{ secrets.DOCKER_ORG }}/fetchmail ${{ secrets.DOCKER_ORG }}/nginx ${{ secrets.DOCKER_ORG }}/none ${{ secrets.DOCKER_ORG }}/postfix ${{ secrets.DOCKER_ORG }}/postgresql ${{ secrets.DOCKER_ORG }}/radicale ${{ secrets.DOCKER_ORG }}/rainloop ${{ secrets.DOCKER_ORG }}/roundcube ${{ secrets.DOCKER_ORG }}/rspamd ${{ secrets.DOCKER_ORG }}/setup ${{ secrets.DOCKER_ORG }}/traefik-certdumper ${{ secrets.DOCKER_ORG }}/unbound -o /images/images.tar.gz
test-core:
name: Perform core tests
runs-on: ubuntu-latest
needs:
- build
steps:
- uses: actions/checkout@v2
- name: Extract branch name
shell: bash
run: |
echo "BRANCH=${GITHUB_REF#refs/heads/}" >> $GITHUB_ENV
- name: Create folder for storing images
run: |
sudo mkdir -p /images
sudo chmod 777 /images
- name: Configure images folder for caching
uses: actions/cache@v2
with:
path: /images
key: ${{ env.BRANCH }}-${{ github.run_id }}-${{ github.run_number }}
- name: Load docker images
run: docker load -i /images/images.tar.gz
- name: Install python packages
run: python3 -m pip install -r tests/requirements.txt
- name: Copy all certs
run: sudo -- sh -c 'mkdir -p /mailu && cp -r tests/certs /mailu && chmod 600 /mailu/certs/*'
- name: Test core suite
run: python tests/compose/test.py core 1
env:
MAILU_VERSION: ${{ env.BRANCH }}
TRAVIS_BRANCH: ${{ env.BRANCH }}
DOCKER_ORG: ${{ secrets.DOCKER_ORG }}
test-fetchmail:
name: Perform fetchmail tests
runs-on: ubuntu-latest
needs:
- build
steps:
- uses: actions/checkout@v2
- name: Extract branch name
shell: bash
run: |
echo "BRANCH=${GITHUB_REF#refs/heads/}" >> $GITHUB_ENV
- name: Create folder for storing images
run: |
sudo mkdir -p /images
sudo chmod 777 /images
- name: Configure images folder for caching
uses: actions/cache@v2
with:
path: /images
key: ${{ env.BRANCH }}-${{ github.run_id }}-${{ github.run_number }}
- name: Load docker images
run: docker load -i /images/images.tar.gz
- name: Install python packages
run: python3 -m pip install -r tests/requirements.txt
- name: Copy all certs
run: sudo -- sh -c 'mkdir -p /mailu && cp -r tests/certs /mailu && chmod 600 /mailu/certs/*'
- name: Test fetch
run: python tests/compose/test.py fetchmail 1
env:
MAILU_VERSION: ${{ env.BRANCH }}
TRAVIS_BRANCH: ${{ env.BRANCH }}
DOCKER_ORG: ${{ secrets.DOCKER_ORG }}
test-filters:
name: Perform filter tests
runs-on: ubuntu-latest
needs:
- build
steps:
- uses: actions/checkout@v2
- name: Extract branch name
shell: bash
run: |
echo "BRANCH=${GITHUB_REF#refs/heads/}" >> $GITHUB_ENV
- name: Create folder for storing images
run: |
sudo mkdir -p /images
sudo chmod 777 /images
- name: Configure images folder for caching
uses: actions/cache@v2
with:
path: /images
key: ${{ env.BRANCH }}-${{ github.run_id }}-${{ github.run_number }}
- name: Load docker images
run: docker load -i /images/images.tar.gz
- name: Install python packages
run: python3 -m pip install -r tests/requirements.txt
- name: Copy all certs
run: sudo -- sh -c 'mkdir -p /mailu && cp -r tests/certs /mailu && chmod 600 /mailu/certs/*'
- name: Test clamvav
run: python tests/compose/test.py filters 2
env:
MAILU_VERSION: ${{ env.BRANCH }}
TRAVIS_BRANCH: ${{ env.BRANCH }}
DOCKER_ORG: ${{ secrets.DOCKER_ORG }}
test-rainloop:
name: Perform rainloop tests
runs-on: ubuntu-latest
needs:
- build
steps:
- uses: actions/checkout@v2
- name: Extract branch name
shell: bash
run: |
echo "BRANCH=${GITHUB_REF#refs/heads/}" >> $GITHUB_ENV
- name: Create folder for storing images
run: |
sudo mkdir -p /images
sudo chmod 777 /images
- name: Configure images folder for caching
uses: actions/cache@v2
with:
path: /images
key: ${{ env.BRANCH }}-${{ github.run_id }}-${{ github.run_number }}
- name: Load docker images
run: docker load -i /images/images.tar.gz
- name: Install python packages
run: python3 -m pip install -r tests/requirements.txt
- name: Copy all certs
run: sudo -- sh -c 'mkdir -p /mailu && cp -r tests/certs /mailu && chmod 600 /mailu/certs/*'
- name: Test rainloop
run: python tests/compose/test.py rainloop 1
env:
MAILU_VERSION: ${{ env.BRANCH }}
TRAVIS_BRANCH: ${{ env.BRANCH }}
DOCKER_ORG: ${{ secrets.DOCKER_ORG }}
test-roundcube:
name: Perform roundcube tests
runs-on: ubuntu-latest
needs:
- build
steps:
- uses: actions/checkout@v2
- name: Extract branch name
shell: bash
run: |
echo "BRANCH=${GITHUB_REF#refs/heads/}" >> $GITHUB_ENV
- name: Create folder for storing images
run: |
sudo mkdir -p /images
sudo chmod 777 /images
- name: Configure images folder for caching
uses: actions/cache@v2
with:
path: /images
key: ${{ env.BRANCH }}-${{ github.run_id }}-${{ github.run_number }}
- name: Load docker images
run: docker load -i /images/images.tar.gz
- name: Install python packages
run: python3 -m pip install -r tests/requirements.txt
- name: Copy all certs
run: sudo -- sh -c 'mkdir -p /mailu && cp -r tests/certs /mailu && chmod 600 /mailu/certs/*'
- name: Test roundcube
run: python tests/compose/test.py roundcube 1
env:
MAILU_VERSION: ${{ env.BRANCH }}
TRAVIS_BRANCH: ${{ env.BRANCH }}
DOCKER_ORG: ${{ secrets.DOCKER_ORG }}
test-webdav:
name: Perform webdav tests
runs-on: ubuntu-latest
needs:
- build
steps:
- uses: actions/checkout@v2
- name: Extract branch name
shell: bash
run: |
echo "BRANCH=${GITHUB_REF#refs/heads/}" >> $GITHUB_ENV
- name: Create folder for storing images
run: |
sudo mkdir -p /images
sudo chmod 777 /images
- name: Configure images folder for caching
uses: actions/cache@v2
with:
path: /images
key: ${{ env.BRANCH }}-${{ github.run_id }}-${{ github.run_number }}
- name: Load docker images
run: docker load -i /images/images.tar.gz
- name: Install python packages
run: python3 -m pip install -r tests/requirements.txt
- name: Copy all certs
run: sudo -- sh -c 'mkdir -p /mailu && cp -r tests/certs /mailu && chmod 600 /mailu/certs/*'
- name: Test webdav
run: python tests/compose/test.py webdav 1
env:
MAILU_VERSION: ${{ env.BRANCH }}
TRAVIS_BRANCH: ${{ env.BRANCH }}
DOCKER_ORG: ${{ secrets.DOCKER_ORG }}
deploy:
name: Deploy images
runs-on: ubuntu-latest
needs:
- build
- test-core
- test-fetchmail
- test-filters
- test-rainloop
- test-roundcube
- test-webdav
steps:
- uses: actions/checkout@v2
- name: Extract branch name
shell: bash
run: |
echo "BRANCH=${GITHUB_REF#refs/heads/}" >> $GITHUB_ENV
- name: Create folder for storing images
run: |
sudo mkdir -p /images
sudo chmod 777 /images
- name: Configure images folder for caching
# For staging we do not deploy images. So we do not have to load them from cache.
if: ${{ env.BRANCH != 'staging' }}
uses: actions/cache@v2
with:
path: /images
key: ${{ env.BRANCH }}-${{ github.run_id }}-${{ github.run_number }}
- name: Load docker images
if: ${{ env.BRANCH != 'staging' }}
run: docker load -i /images/images.tar.gz
- name: Deploy built docker images
env:
DOCKER_UN: ${{ secrets.Docker_Login }}
DOCKER_PW: ${{ secrets.Docker_Password }}
DOCKER_ORG: ${{ secrets.DOCKER_ORG }}
DOCKER_ORG_TESTS: ${{ secrets.DOCKER_ORG_TESTS }}
MAILU_VERSION: ${{ env.BRANCH }}
TRAVIS_BRANCH: ${{ env.BRANCH }}
TRAVIS_COMMIT_MESSAGE: ${{ github.event.head_commit.message }}
run: bash tests/deploy.sh
# This job is watched by bors. It only complets if building,testing and deploy worked.
ci-success:
name: CI-Done
#Returns true when none of the **previous** steps have failed or been canceled.
if: ${{ success() }}
needs:
- deploy
runs-on: ubuntu-latest
steps:
- name: CI/CD succeeded.
run: exit 0

@ -27,7 +27,7 @@ pull_request_rules:
- name: Trusted author and 1 approved review; trigger bors r+
conditions:
- author~=^(mergify|kaiyou|muhlemmer|mildred|HorayNarea|adi90x|hoellen|ofthesun9|Nebukadneza|micw|lub|Diman0)$
- author~=^(mergify|kaiyou|muhlemmer|mildred|HorayNarea|hoellen|ofthesun9|Nebukadneza|micw|lub|Diman0|3-w-c|decentral1se|ghostwheel42|nextgens|parisni)$
- -title~=(WIP|wip)
- -label~=^(status/wip|status/blocked|review/need2)$
- "#approved-reviews-by>=1"

@ -1,56 +0,0 @@
branches:
only:
- staging
- testing
- '1.5'
- '1.6'
- '1.7'
- '1.8'
- master
# version tags, e.g. 1.7.1
- /^1\.[5678]\.\d+$/
# pre-releases, e.g. 1.8-pre1
- /^1\.8-pre\d+$/
# test branches, e.g. test-debian
- /^test-[\w\-\.]+$/
sudo: required
services: docker
addons:
apt:
packages:
- docker-ce
env:
- MAILU_VERSION=${TRAVIS_BRANCH////-}
language: python
python:
- "3.6"
install:
- pip install -r tests/requirements.txt
- sudo curl -L https://github.com/docker/compose/releases/download/1.23.0-rc3/docker-compose-`uname -s`-`uname -m` -o /usr/local/bin/docker-compose
- sudo chmod +x /usr/local/bin/docker-compose
before_script:
- docker-compose -v
- echo "$DOCKER_PW" | docker login --username $DOCKER_UN --password-stdin
- docker-compose -f tests/build.yml build
- sudo -- sh -c 'mkdir -p /mailu && cp -r tests/certs /mailu && chmod 600 /mailu/certs/*'
script:
# test.py, test name and timeout between start and tests.
- python tests/compose/test.py core 1
- python tests/compose/test.py fetchmail 1
- travis_wait python tests/compose/test.py filters 10
- python tests/compose/test.py rainloop 1
- python tests/compose/test.py roundcube 1
- python tests/compose/test.py webdav 1
deploy:
provider: script
script: bash tests/deploy.sh
on:
all_branches: true
condition: -n $DOCKER_UN

@ -1,3 +1,4 @@
status = [
"continuous-integration/travis-ci/push"
"CI-Done"
]

@ -6,6 +6,7 @@ import flask_bootstrap
from mailu import utils, debug, models, manage, configuration
import hmac
def create_app_from_config(config):
""" Create a new application based on the given configuration
@ -28,6 +29,8 @@ def create_app_from_config(config):
utils.proxy.init_app(app)
utils.migrate.init_app(app, models.db)
app.temp_token_key = hmac.new(bytearray(app.secret_key, 'utf-8'), bytearray('WEBMAIL_TEMP_TOKEN_KEY', 'utf-8'), 'sha256').digest()
# Initialize debugging tools
if app.config.get("DEBUG"):
debug.toolbar.init_app(app)

@ -7,7 +7,6 @@ import ipaddress
import socket
import tenacity
SUPPORTED_AUTH_METHODS = ["none", "plain"]
@ -26,8 +25,12 @@ def check_credentials(user, password, ip, protocol=None):
if not user or not user.enabled or (protocol == "imap" and not user.enable_imap) or (protocol == "pop3" and not user.enable_pop):
return False
is_ok = False
# webmails
if len(password) == 64 and ip == app.config['WEBMAIL_ADDRESS']:
if user.verify_temp_token(password):
is_ok = True
# All tokens are 32 characters hex lowercase
if len(password) == 32:
if not is_ok and len(password) == 32:
for token in user.tokens:
if (token.check_password(password) and
(not token.ip or token.ip == ip)):

@ -43,6 +43,18 @@ def admin_authentication():
return ""
return flask.abort(403)
@internal.route("/auth/user")
def user_authentication():
""" Fails if the user is not authenticated.
"""
if (not flask_login.current_user.is_anonymous
and flask_login.current_user.enabled):
response = flask.Response()
response.headers["X-User"] = flask_login.current_user.get_id()
response.headers["X-User-Token"] = models.User.get_temp_token(flask_login.current_user.get_id())
return response
return flask.abort(403)
@internal.route("/auth/basic")
def basic_authentication():

@ -2,6 +2,7 @@ from mailu import models
from mailu.internal import internal
import flask
import idna
import re
import srslib
@ -35,13 +36,67 @@ def postfix_alias_map(alias):
def postfix_transport(email):
if email == '*' or re.match("(^|.*@)\[.*\]$", email):
return flask.abort(404)
localpart, domain_name = models.Email.resolve_domain(email)
_, domain_name = models.Email.resolve_domain(email)
relay = models.Relay.query.get(domain_name) or flask.abort(404)
ret = "smtp:[{0}]".format(relay.smtp)
if ":" in relay.smtp:
split = relay.smtp.split(':')
ret = "smtp:[{0}]:{1}".format(split[0], split[1])
return flask.jsonify(ret)
target = relay.smtp.lower()
port = None
use_lmtp = False
use_mx = False
# strip prefixes mx: and lmtp:
if target.startswith('mx:'):
target = target[3:]
use_mx = True
elif target.startswith('lmtp:'):
target = target[5:]
use_lmtp = True
# split host:port or [host]:port
if target.startswith('['):
if use_mx or ']' not in target:
# invalid target (mx: and [] or missing ])
flask.abort(400)
host, rest = target[1:].split(']', 1)
if rest.startswith(':'):
port = rest[1:]
elif rest:
# invalid target (rest should be :port)
flask.abort(400)
else:
if ':' in target:
host, port = target.rsplit(':', 1)
else:
host = target
# default for empty host part is mx:domain
if not host:
if not use_lmtp:
host = relay.name.lower()
use_mx = True
else:
# lmtp: needs a host part
flask.abort(400)
# detect ipv6 address or encode host
if ':' in host:
host = f'ipv6:{host}'
else:
try:
host = idna.encode(host).decode('ascii')
except idna.IDNAError:
# invalid host (fqdn not encodable)
flask.abort(400)
# validate port
if port is not None:
try:
port = int(port, 10)
except ValueError:
# invalid port (should be numeric)
flask.abort(400)
# create transport
transport = 'lmtp' if use_lmtp else 'smtp'
# use [] when not using MX lookups or host is an ipv6 address
if host.startswith('ipv6:') or (not use_lmtp and not use_mx):
host = f'[{host}]'
# create port suffix
port = '' if port is None else f':{port}'
return flask.jsonify(f'{transport}:{host}{port}')
@internal.route("/postfix/recipient/map/<path:recipient>")

@ -1,40 +1,46 @@
from mailu import models
""" Mailu command line interface
"""
from flask import current_app as app
from flask import cli as flask_cli
import flask
import sys
import os
import socket
import uuid
import click
import yaml
from flask import current_app as app
from flask.cli import FlaskGroup, with_appcontext
from mailu import models
from mailu.schemas import MailuSchema, Logger, RenderJSON
db = models.db
@click.group()
def mailu(cls=flask_cli.FlaskGroup):
@click.group(cls=FlaskGroup, context_settings={'help_option_names': ['-?', '-h', '--help']})
def mailu():
""" Mailu command line
"""
@mailu.command()
@flask_cli.with_appcontext
@with_appcontext
def advertise():
""" Advertise this server against statistic services.
"""
if os.path.isfile(app.config["INSTANCE_ID_PATH"]):
with open(app.config["INSTANCE_ID_PATH"], "r") as handle:
if os.path.isfile(app.config['INSTANCE_ID_PATH']):
with open(app.config['INSTANCE_ID_PATH'], 'r') as handle:
instance_id = handle.read()
else:
instance_id = str(uuid.uuid4())
with open(app.config["INSTANCE_ID_PATH"], "w") as handle:
with open(app.config['INSTANCE_ID_PATH'], 'w') as handle:
handle.write(instance_id)
if not app.config["DISABLE_STATISTICS"]:
if not app.config['DISABLE_STATISTICS']:
try:
socket.gethostbyname(app.config["STATS_ENDPOINT"].format(instance_id))
except:
socket.gethostbyname(app.config['STATS_ENDPOINT'].format(instance_id))
except OSError:
pass
@ -43,7 +49,7 @@ def advertise():
@click.argument('domain_name')
@click.argument('password')
@click.option('-m', '--mode')
@flask_cli.with_appcontext
@with_appcontext
def admin(localpart, domain_name, password, mode='create'):
""" Create an admin user
'mode' can be:
@ -58,7 +64,7 @@ def admin(localpart, domain_name, password, mode='create'):
user = None
if mode == 'ifmissing' or mode == 'update':
email = '{}@{}'.format(localpart, domain_name)
email = f'{localpart}@{domain_name}'
user = models.User.query.get(email)
if user and mode == 'ifmissing':
@ -86,7 +92,7 @@ def admin(localpart, domain_name, password, mode='create'):
@click.argument('localpart')
@click.argument('domain_name')
@click.argument('password')
@flask_cli.with_appcontext
@with_appcontext
def user(localpart, domain_name, password):
""" Create a user
"""
@ -108,16 +114,16 @@ def user(localpart, domain_name, password):
@click.argument('localpart')
@click.argument('domain_name')
@click.argument('password')
@flask_cli.with_appcontext
@with_appcontext
def password(localpart, domain_name, password):
""" Change the password of an user
"""
email = '{0}@{1}'.format(localpart, domain_name)
email = f'{localpart}@{domain_name}'
user = models.User.query.get(email)
if user:
user.set_password(password)
else:
print("User " + email + " not found.")
print(f'User {email} not found.')
db.session.commit()
@ -126,7 +132,7 @@ def password(localpart, domain_name, password):
@click.option('-u', '--max-users')
@click.option('-a', '--max-aliases')
@click.option('-q', '--max-quota-bytes')
@flask_cli.with_appcontext
@with_appcontext
def domain(domain_name, max_users=-1, max_aliases=-1, max_quota_bytes=0):
""" Create a domain
"""
@ -142,9 +148,9 @@ def domain(domain_name, max_users=-1, max_aliases=-1, max_quota_bytes=0):
@click.argument('localpart')
@click.argument('domain_name')
@click.argument('password_hash')
@flask_cli.with_appcontext
@with_appcontext
def user_import(localpart, domain_name, password_hash):
""" Import a user along with password hash.
""" Import a user along with password hash
"""
domain = models.Domain.query.get(domain_name)
if not domain:
@ -160,14 +166,14 @@ def user_import(localpart, domain_name, password_hash):
db.session.commit()
# TODO: remove deprecated config_update function?
@mailu.command()
@click.option('-v', '--verbose')
@click.option('-d', '--delete-objects')
@flask_cli.with_appcontext
@with_appcontext
def config_update(verbose=False, delete_objects=False):
"""sync configuration with data from YAML-formatted stdin"""
import yaml
import sys
""" Sync configuration with data from YAML (deprecated)
"""
new_config = yaml.safe_load(sys.stdin)
# print new_config
domains = new_config.get('domains', [])
@ -187,13 +193,13 @@ def config_update(verbose=False, delete_objects=False):
max_aliases=max_aliases,
max_quota_bytes=max_quota_bytes)
db.session.add(domain)
print("Added " + str(domain_config))
print(f'Added {domain_config}')
else:
domain.max_users = max_users
domain.max_aliases = max_aliases
domain.max_quota_bytes = max_quota_bytes
db.session.add(domain)
print("Updated " + str(domain_config))
print(f'Updated {domain_config}')
users = new_config.get('users', [])
tracked_users = set()
@ -209,7 +215,7 @@ def config_update(verbose=False, delete_objects=False):
domain_name = user_config['domain']
password_hash = user_config.get('password_hash', None)
domain = models.Domain.query.get(domain_name)
email = '{0}@{1}'.format(localpart, domain_name)
email = f'{localpart}@{domain_name}'
optional_params = {}
for k in user_optional_params:
if k in user_config:
@ -239,13 +245,13 @@ def config_update(verbose=False, delete_objects=False):
print(str(alias_config))
localpart = alias_config['localpart']
domain_name = alias_config['domain']
if type(alias_config['destination']) is str:
if isinstance(alias_config['destination'], str):
destination = alias_config['destination'].split(',')
else:
destination = alias_config['destination']
wildcard = alias_config.get('wildcard', False)
domain = models.Domain.query.get(domain_name)
email = '{0}@{1}'.format(localpart, domain_name)
email = f'{localpart}@{domain_name}'
if not domain:
domain = models.Domain(name=domain_name)
db.session.add(domain)
@ -275,7 +281,7 @@ def config_update(verbose=False, delete_objects=False):
domain_name = manager_config['domain']
user_name = manager_config['user']
domain = models.Domain.query.get(domain_name)
manageruser = models.User.query.get(user_name + '@' + domain_name)
manageruser = models.User.query.get(f'{user_name}@{domain_name}')
if manageruser not in domain.managers:
domain.managers.append(manageruser)
db.session.add(domain)
@ -284,26 +290,117 @@ def config_update(verbose=False, delete_objects=False):
if delete_objects:
for user in db.session.query(models.User).all():
if not (user.email in tracked_users):
if not user.email in tracked_users:
if verbose:
print("Deleting user: " + str(user.email))
print(f'Deleting user: {user.email}')
db.session.delete(user)
for alias in db.session.query(models.Alias).all():
if not (alias.email in tracked_aliases):
if not alias.email in tracked_aliases:
if verbose:
print("Deleting alias: " + str(alias.email))
print(f'Deleting alias: {alias.email}')
db.session.delete(alias)
for domain in db.session.query(models.Domain).all():
if not (domain.name in tracked_domains):
if not domain.name in tracked_domains:
if verbose:
print("Deleting domain: " + str(domain.name))
print(f'Deleting domain: {domain.name}')
db.session.delete(domain)
db.session.commit()
@mailu.command()
@click.option('-v', '--verbose', count=True, help='Increase verbosity.')
@click.option('-s', '--secrets', is_flag=True, help='Show secret attributes in messages.')
@click.option('-d', '--debug', is_flag=True, help='Enable debug output.')
@click.option('-q', '--quiet', is_flag=True, help='Quiet mode - only show errors.')
@click.option('-c', '--color', is_flag=True, help='Force colorized output.')
@click.option('-u', '--update', is_flag=True, help='Update mode - merge input with existing config.')
@click.option('-n', '--dry-run', is_flag=True, help='Perform a trial run with no changes made.')
@click.argument('source', metavar='[FILENAME|-]', type=click.File(mode='r'), default=sys.stdin)
@with_appcontext
def config_import(verbose=0, secrets=False, debug=False, quiet=False, color=False,
update=False, dry_run=False, source=None):
""" Import configuration as YAML or JSON from stdin or file
"""
log = Logger(want_color=color or None, can_color=sys.stdout.isatty(), secrets=secrets, debug=debug)
log.lexer = 'python'
log.strip = True
log.verbose = 0 if quiet else verbose
log.quiet = quiet
context = {
'import': True,
'update': update,
'clear': not update,
'callback': log.track_serialize,
}
schema = MailuSchema(only=MailuSchema.Meta.order, context=context)
try:
# import source
with models.db.session.no_autoflush:
config = schema.loads(source)
# flush session to show/count all changes
if not quiet and (dry_run or verbose):
db.session.flush()
# check for duplicate domain names
config.check()
except Exception as exc:
if msg := log.format_exception(exc):
raise click.ClickException(msg) from exc
raise
# don't commit when running dry
if dry_run:
log.changes('Dry run. Not committing changes.')
db.session.rollback()
else:
log.changes('Committing changes.')
db.session.commit()
@mailu.command()
@click.option('-f', '--full', is_flag=True, help='Include attributes with default value.')
@click.option('-s', '--secrets', is_flag=True,
help='Include secret attributes (dkim-key, passwords).')
@click.option('-d', '--dns', is_flag=True, help='Include dns records.')
@click.option('-c', '--color', is_flag=True, help='Force colorized output.')
@click.option('-o', '--output-file', 'output', default=sys.stdout, type=click.File(mode='w'),
help='Save configuration to file.')
@click.option('-j', '--json', 'as_json', is_flag=True, help='Export configuration in json format.')
@click.argument('only', metavar='[FILTER]...', nargs=-1)
@with_appcontext
def config_export(full=False, secrets=False, color=False, dns=False, output=None, as_json=False, only=None):
""" Export configuration as YAML or JSON to stdout or file
"""
log = Logger(want_color=color or None, can_color=output.isatty())
only = only or MailuSchema.Meta.order
context = {
'full': full,
'secrets': secrets,
'dns': dns,
}
try:
schema = MailuSchema(only=only, context=context)
if as_json:
schema.opts.render_module = RenderJSON
log.lexer = 'json'
log.strip = True
print(log.colorize(schema.dumps(models.MailuConfig())), file=output)
except Exception as exc:
if msg := log.format_exception(exc):
raise click.ClickException(msg) from exc
raise
@mailu.command()
@click.argument('email')
@flask_cli.with_appcontext
@with_appcontext
def user_delete(email):
"""delete user"""
user = models.User.query.get(email)
@ -314,7 +411,7 @@ def user_delete(email):
@mailu.command()
@click.argument('email')
@flask_cli.with_appcontext
@with_appcontext
def alias_delete(email):
"""delete alias"""
alias = models.Alias.query.get(email)
@ -328,7 +425,7 @@ def alias_delete(email):
@click.argument('domain_name')
@click.argument('destination')
@click.option('-w', '--wildcard', is_flag=True)
@flask_cli.with_appcontext
@with_appcontext
def alias(localpart, domain_name, destination, wildcard=False):
""" Create an alias
"""
@ -341,7 +438,7 @@ def alias(localpart, domain_name, destination, wildcard=False):
domain=domain,
wildcard=wildcard,
destination=destination.split(','),
email="%s@%s" % (localpart, domain_name)
email=f'{localpart}@{domain_name}'
)
db.session.add(alias)
db.session.commit()
@ -352,7 +449,7 @@ def alias(localpart, domain_name, destination, wildcard=False):
@click.argument('max_users')
@click.argument('max_aliases')
@click.argument('max_quota_bytes')
@flask_cli.with_appcontext
@with_appcontext
def setlimits(domain_name, max_users, max_aliases, max_quota_bytes):
""" Set domain limits
"""
@ -367,16 +464,12 @@ def setlimits(domain_name, max_users, max_aliases, max_quota_bytes):
@mailu.command()
@click.argument('domain_name')
@click.argument('user_name')
@flask_cli.with_appcontext
@with_appcontext
def setmanager(domain_name, user_name='manager'):
""" Make a user manager of a domain
"""
domain = models.Domain.query.get(domain_name)
manageruser = models.User.query.get(user_name + '@' + domain_name)
manageruser = models.User.query.get(f'{user_name}@{domain_name}')
domain.managers.append(manageruser)
db.session.add(domain)
db.session.commit()
if __name__ == '__main__':
cli()

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

@ -528,7 +528,7 @@ msgstr "Alternatieve naam"
#: mailu/ui/forms.py:70
msgid "Relayed domain name"
msgstr "Relayed domainnaam"
msgstr "Relayed domeinnaam"
#: mailu/ui/forms.py:71 mailu/ui/templates/relay/list.html:18
msgid "Remote host"
@ -536,7 +536,7 @@ msgstr "Externe host"
#: mailu/ui/templates/sidebar.html:54
msgid "Relayed domains"
msgstr "Relayed domainen"
msgstr "Relayed domeinen"
#: mailu/ui/templates/alternative/create.html:4
msgid "Create alternative domain"

@ -50,5 +50,17 @@
<td><pre>_dmarc.{{ domain.name }}. 600 IN TXT "v=DMARC1; p=reject;{% if config["DMARC_RUA"] %} rua=mailto:{{ config["DMARC_RUA"] }}@{{ config["DOMAIN"] }};{% endif %}{% if config["DMARC_RUF"] %} ruf=mailto:{{ config["DMARC_RUF"] }}@{{ config["DOMAIN"] }};{% endif %} adkim=s; aspf=s"</pre></td>
</tr>
{% endif %}
<tr>
<th>{% trans %}DNS client auto-configuration (RFC6186) entries{% endtrans %}</th>
<td>
<pre style="white-space: pre-wrap; word-wrap: break-word;">_submission._tcp.{{ domain.name }}. 600 IN SRV 1 1 587 {{ config["HOSTNAMES"].split(',')[0] }}.</pre>
<pre style="white-space: pre-wrap; word-wrap: break-word;">_imap._tcp.{{ domain.name }}. 600 IN SRV 100 1 143 {{ config["HOSTNAMES"].split(',')[0] }}.</pre>
<pre style="white-space: pre-wrap; word-wrap: break-word;">_pop3._tcp.{{ domain.name }}. 600 IN SRV 100 1 110 {{ config["HOSTNAMES"].split(',')[0] }}.</pre>
{% if config["TLS_FLAVOR"] != "notls" %}
<pre style="white-space: pre-wrap; word-wrap: break-word;">_submissions._tcp.{{ domain.name }}. 600 IN SRV 10 1 465 {{ config["HOSTNAMES"].split(',')[0] }}.</pre>
<pre style="white-space: pre-wrap; word-wrap: break-word;">_imaps._tcp.{{ domain.name }}. 600 IN SRV 10 1 993 {{ config["HOSTNAMES"].split(',')[0] }}.</pre>
<pre style="white-space: pre-wrap; word-wrap: break-word;">_pop3s._tcp.{{ domain.name }}. 600 IN SRV 10 1 995 {{ config["HOSTNAMES"].split(',')[0] }}.</pre>
{% endif %}</td>
</tr>
{% endcall %}
{% endblock %}

@ -1,6 +1,7 @@
from mailu import models
from mailu.ui import ui, forms, access
from flask import current_app as app
import flask
import flask_login
@ -49,6 +50,9 @@ def announcement():
flask.flash('Your announcement was sent', 'success')
return flask.render_template('announcement.html', form=form)
@ui.route('/webmail', methods=['GET'])
def webmail():
return flask.redirect(app.config['WEB_WEBMAIL'])
@ui.route('/client', methods=['GET'])
def client():

@ -15,6 +15,7 @@ Flask-Bootstrap==3.3.7.1
Flask-DebugToolbar==0.10.1
Flask-Limiter==1.0.1
Flask-Login==0.4.1
flask-marshmallow==0.14.0
Flask-Migrate==2.4.0
Flask-Script==2.0.6
Flask-SQLAlchemy==2.4.0
@ -29,9 +30,12 @@ limits==1.3
Mako==1.0.9
MarkupSafe==1.1.1
mysqlclient==1.4.2.post1
marshmallow==3.10.0
marshmallow-sqlalchemy==0.24.1
passlib==1.7.4
psycopg2==2.8.2
pycparser==2.19
Pygments==2.8.1
pyOpenSSL==19.0.0
python-dateutil==2.8.0
python-editor==1.0.4

@ -16,6 +16,7 @@ gunicorn
tabulate
PyYAML
PyOpenSSL
Pygments
dnspython
bcrypt
tenacity
@ -23,3 +24,6 @@ mysqlclient
psycopg2
idna
srslib
marshmallow
flask-marshmallow
marshmallow-sqlalchemy

@ -21,7 +21,10 @@ mail_access_groups = mail
maildir_stat_dirs = yes
mailbox_list_index = yes
mail_vsize_bg_after_count = 100
mail_plugins = $mail_plugins quota quota_clone zlib{{ ' ' }}
mail_plugins = $mail_plugins quota quota_clone{{ ' ' }}
{%- if COMPRESSION -%}
zlib{{ ' ' }}
{%- endif %}
{%- if (FULL_TEXT_SEARCH or '').lower() not in ['off', 'false', '0'] -%}
fts fts_xapian
{%- endif %}
@ -50,7 +53,7 @@ plugin {
fts_autoindex_exclude = \Trash
{% endif %}
{% if COMPRESSION in [ 'gz', 'bz2' ] %}
{% if COMPRESSION in [ 'gz', 'bz2', 'lz4', 'zstd' ] %}
zlib_save = {{ COMPRESSION }}
{% endif %}

@ -136,9 +136,33 @@ http {
include /etc/nginx/proxy.conf;
client_max_body_size {{ MESSAGE_SIZE_LIMIT|int + 8388608 }};
proxy_pass http://$webmail;
{% if ADMIN == 'true' %}
auth_request /internal/auth/user;
error_page 403 @webmail_login;
}
{% endif %}
location {{ WEB_WEBMAIL }}/sso.php {
{% if WEB_WEBMAIL != '/' %}
rewrite ^({{ WEB_WEBMAIL }})$ $1/ permanent;
rewrite ^{{ WEB_WEBMAIL }}/(.*) /$1 break;
{% endif %}
include /etc/nginx/proxy.conf;
client_max_body_size {{ MESSAGE_SIZE_LIMIT|int + 8388608 }};
auth_request /internal/auth/user;
auth_request_set $user $upstream_http_x_user;
auth_request_set $token $upstream_http_x_user_token;
proxy_set_header X-Remote-User $user;
proxy_set_header X-Remote-User-Token $token;
proxy_pass http://$webmail;
error_page 403 @webmail_login;
}
location @webmail_login {
return 302 {{ WEB_ADMIN }}/ui/login?next=ui.webmail;
}
{% else %}
}
{% endif %}{% endif %}
{% if ADMIN == 'true' %}
location {{ WEB_ADMIN }} {
return 301 {{ WEB_ADMIN }}/ui;

@ -10,7 +10,6 @@ log.basicConfig(stream=sys.stderr, level=os.environ.get("LOG_LEVEL", "WARNING"))
# Actual startup script
os.environ["FRONT_ADDRESS"] = system.get_host_address_from_environment("FRONT", "front")
os.environ["REDIS_ADDRESS"] = system.get_host_address_from_environment("REDIS", "redis")
if os.environ.get("ANTIVIRUS") == 'clamav':

@ -11,6 +11,8 @@ Managing users and aliases can be done from CLI using commands:
* user-import
* user-delete
* config-update
* config-export
* config-import
alias
-----
@ -62,7 +64,7 @@ primary difference with simple `user` command is that password is being imported
docker-compose run --rm admin flask mailu user-import myuser example.net '$6$51ebe0cb9f1dab48effa2a0ad8660cb489b445936b9ffd812a0b8f46bca66dd549fea530ce' 'SHA512-CRYPT'
user-delete
------------
-----------
.. code-block:: bash
@ -94,7 +96,7 @@ where mail-config.yml looks like:
without ``--delete-object`` option config-update will only add/update new values but will *not* remove any entries missing in provided YAML input.
Users
-----
^^^^^
following are additional parameters that could be defined for users:
@ -113,8 +115,197 @@ following are additional parameters that could be defined for users:
* spam_threshold
Alias
-----
^^^^^
additional fields:
* wildcard
config-export
-------------
The purpose of this command is to export the complete configuration in YAML or JSON format.
.. code-block:: bash
$ docker-compose exec admin flask mailu config-export --help
Usage: flask mailu config-export [OPTIONS] [FILTER]...
Export configuration as YAML or JSON to stdout or file
Options:
-f, --full Include attributes with default value.
-s, --secrets Include secret attributes (dkim-key, passwords).
-d, --dns Include dns records.
-c, --color Force colorized output.
-o, --output-file FILENAME Save configuration to file.
-j, --json Export configuration in json format.
-?, -h, --help Show this message and exit.
Only non-default attributes are exported. If you want to export all attributes use ``--full``.
If you want to export plain-text secrets (dkim-keys, passwords) you have to add the ``--secrets`` option.
To include dns records (mx, spf, dkim and dmarc) add the ``--dns`` option.
By default all configuration objects are exported (domain, user, alias, relay). You can specify
filters to export only some objects or attributes (try: ``user`` or ``domain.name``).
Attributes explicitly specified in filters are automatically exported: there is no need to add ``--secrets`` or ``--full``.
.. code-block:: bash
$ docker-compose exec admin flask mailu config-export --output mail-config.yml
$ docker-compose exec admin flask mailu config-export domain.dns_mx domain.dns_spf
$ docker-compose exec admin flask mailu config-export user.spam_threshold
config-import
-------------
This command imports configuration data from an external YAML or JSON source.
.. code-block:: bash
$ docker-compose exec admin flask mailu config-import --help
Usage: flask mailu config-import [OPTIONS] [FILENAME|-]
Import configuration as YAML or JSON from stdin or file
Options:
-v, --verbose Increase verbosity.
-s, --secrets Show secret attributes in messages.
-q, --quiet Quiet mode - only show errors.
-c, --color Force colorized output.
-u, --update Update mode - merge input with existing config.
-n, --dry-run Perform a trial run with no changes made.
-?, -h, --help Show this message and exit.
The current version of docker-compose exec does not pass stdin correctly, so you have to user docker exec instead:
.. code-block:: bash
docker exec -i $(docker-compose ps -q admin) flask mailu config-import -nv < mail-config.yml
mail-config.yml contains the configuration and looks like this:
.. code-block:: yaml
domain:
- name: example.com
alternatives:
- alternative.example.com
user:
- email: foo@example.com
password_hash: '$2b$12$...'
hash_scheme: MD5-CRYPT
alias:
- email: alias1@example.com
destination:
- user1@example.com
- user2@example.com
relay:
- name: relay.example.com
comment: test
smtp: mx.example.com
config-import shows the number of created/modified/deleted objects after import.
To suppress all messages except error messages use ``--quiet``.
By adding the ``--verbose`` switch the import gets more detailed and shows exactly what attributes changed.
In all log messages plain-text secrets (dkim-keys, passwords) are hidden by default. Use ``--secrets`` to log secrets.
If you want to test what would be done when importing without committing any changes, use ``--dry-run``.
By default config-import replaces the whole configuration. ``--update`` allows to modify the existing configuration instead.
New elements will be added and existing elements will be modified.
It is possible to delete a single element or prune all elements from lists and associative arrays using a special notation:
+-----------------------------+------------------+--------------------------+
| Delete what? | notation | example |
+=============================+==================+==========================+
| specific array object | ``- -key: id`` | ``- -name: example.com`` |
+-----------------------------+------------------+--------------------------+
| specific list item | ``- -id`` | ``- -user1@example.com`` |
+-----------------------------+------------------+--------------------------+
| all remaining array objects | ``- -key: null`` | ``- -email: null`` |
+-----------------------------+------------------+--------------------------+
| all remaining list items | ``- -prune-`` | ``- -prune-`` |
+-----------------------------+------------------+--------------------------+
The ``-key: null`` notation can also be used to reset an attribute to its default.
To reset *spam_threshold* to it's default *80* use ``-spam_threshold: null``.
A new dkim key can be generated when adding or modifying a domain, by using the special value
``dkim_key: -generate-``.
This is a complete YAML template with all additional parameters that can be defined:
.. code-block:: yaml
domain:
- name: example.com
alternatives:
- alternative.tld
comment: ''
dkim_key: ''
max_aliases: -1
max_quota_bytes: 0
max_users: -1
signup_enabled: false
user:
- email: postmaster@example.com
comment: ''
displayed_name: 'Postmaster'
enable_imap: true
enable_pop: false
enabled: true
fetches:
- id: 1
comment: 'test fetch'
error: null
host: other.example.com
keep: true
last_check: '2020-12-29T17:09:48.200179'
password: 'secret'
hash_password: true
port: 993
protocol: imap
tls: true
username: fetch-user
forward_destination:
- address@remote.example.com
forward_enabled: true
forward_keep: true
global_admin: true
manager_of:
- example.com
password: '$2b$12$...'
hash_password: true
quota_bytes: 1000000000
reply_body: ''
reply_enabled: false
reply_enddate: '2999-12-31'
reply_startdate: '1900-01-01'
reply_subject: ''
spam_enabled: true
spam_threshold: 80
tokens:
- id: 1
comment: email-client
ip: 192.168.1.1
password: '$5$rounds=1$...'
aliases:
- email: email@example.com
comment: ''
destination:
- address@example.com
wildcard: false
relay:
- name: relay.example.com
comment: ''
smtp: mx.example.com

@ -97,7 +97,7 @@ WELCOME_SUBJECT=Welcome to your new email account
WELCOME_BODY=Welcome to your new email account, if you can read this, then it is configured properly!
# Maildir Compression
# choose compression-method, default: none (value: bz2, gz)
# choose compression-method, default: none (value: gz, bz2, lz4, zstd)
COMPRESSION=
# change compression-level, default: 6 (value: 1-9)
COMPRESSION_LEVEL=

@ -195,4 +195,24 @@ resolved. This can be used to rely on DNS based service discovery with changing
When using ``*_ADDRESS``, the hostnames must be full-qualified hostnames. Otherwise nginx will not be able to
resolve the hostnames.
Database settings
-----------------
The admin service stores configurations in a database.
- ``DB_FLAVOR``: the database type for mailu admin service. (``sqlite``, ``postgresql``, ``mysql``)
- ``DB_HOST``: the database host for mailu admin service. (when not ``sqlite``)
- ``DB_PORT``: the database port for mailu admin service. (when not ``sqlite``)
- ``DB_PW``: the database password for mailu admin service. (when not ``sqlite``)
- ``DB_USER``: the database user for mailu admin service. (when not ``sqlite``)
- ``DB_NAME``: the database name for mailu admin service. (when not ``sqlite``)
The roundcube service stores configurations in a database.
- ``ROUNDCUBE_DB_FLAVOR``: the database type for roundcube service. (``sqlite``, ``postgresql``, ``mysql``)
- ``ROUNDCUBE_DB_HOST``: the database host for roundcube service. (when not ``sqlite``)
- ``ROUNDCUBE_DB_PORT``: the database port for roundcube service. (when not ``sqlite``)
- ``ROUNDCUBE_DB_PW``: the database password for roundcube service. (when not ``sqlite``)
- ``ROUNDCUBE_DB_USER``: the database user for roundcube service. (when not ``sqlite``)
- ``ROUNDCUBE_DB_NAME``: the database name for roundcube service. (when not ``sqlite``)

@ -178,9 +178,9 @@ In the case of a PR from a fellow team member, a single review is enough
to initiate merging. In all other cases, two approving reviews are required.
There is also a possibility to set the ``review/need2`` to require a second review.
After Travis successfully tests the PR and the required amount of reviews are acquired,
After the Github Action workflow successfully tests the PR and the required amount of reviews are acquired,
Mergify will trigger with a ``bors r+`` command. Bors will batch any approved PR's,
merges them with master in a staging branch where Travis builds and tests the result.
merges them with master in a staging branch where the Github Action workflow builds and tests the result.
After a successful test, the actual master gets fast-forwarded to that point.
System requirements
@ -201,16 +201,16 @@ us on `Matrix`_.
Test images
```````````
All PR's automatically get build by Travis, controlled by `bors-ng`_.
All PR's automatically get build by a Github Action workflow, controlled by `bors-ng`_.
Some primitive auto testing is done.
The resulting images get uploaded to Docker hub, under the
tag name ``mailutest/<name>:pr-<no>``.
tag name ``mailuci/<name>:pr-<no>``.
For example, to test PR #500 against master, reviewers can use:
.. code-block:: bash
export DOCKER_ORG="mailutest"
export DOCKER_ORG="mailuci"
export MAILU_VERSION="pr-500"
docker-compose pull
docker-compose up -d
@ -232,8 +232,8 @@ after Bors confirms a successful build.
When bors try fails
```````````````````
Sometimes Travis fails when another PR triggers a ``bors try`` command,
before Travis cloned the git repository.
Sometimes the Github Action workflow fails when another PR triggers a ``bors try`` command,
before the Github Action workflow cloned the git repository.
Inspect the build log in the link provided by *bors-ng* to find out the cause.
If you see something like the following error on top of the logs,
feel free to write a comment with ``bors retry``.

@ -41,7 +41,7 @@ PR Workflow
-----------
All pull requests have to be against the main ``master`` branch.
The PR gets build by Travis and some primitive auto-testing is done.
The PR gets build by a Github Action workflow and some primitive auto-testing is done.
Test images get uploaded to a separate section in Docker hub.
Reviewers will check the PR and test the resulting images.
See the :ref:`testing` section for more info.

@ -8,7 +8,8 @@ This functionality should still be considered experimental!
Mailu Postgresql
----------------
Mailu optionally comes with a pre-configured Postgresql image.
Mailu optionally comes with a pre-configured Postgresql image, which as of 1.8, is deprecated
and will be removed in 1.9.
This images has the following features:
- Automatic creation of users, db, extensions and password;

@ -61,7 +61,7 @@ have to prevent pushing out something quickly.
We currently maintain a strict work flow:
#. Someone writes a solution and sends a pull request;
#. We use Travis-CI for some very basic building and testing;
#. We use Github actions for some very basic building and testing;
#. The pull request needs to be code-reviewed and tested by at least two members
from the contributors team.
@ -261,6 +261,8 @@ correct syntax. The following file names will be taken as override configuration
- ``main.cf`` as ``$ROOT/overrides/postfix/postfix.cf``
- ``master.cf`` as ``$ROOT/overrides/postfix/postfix.master``
- All ``$ROOT/overrides/postfix/*.map`` files
- For both ``postfix.cf`` and ``postfix.master``, you need to put one configuration per line, as they are fed line-by-line
to postfix.
- `Dovecot`_ - ``dovecot.conf`` in dovecot sub-directory;
- `Nginx`_ - All ``*.conf`` files in the ``nginx`` sub-directory;
- `Rspamd`_ - All files in the ``rspamd`` sub-directory.
@ -528,25 +530,42 @@ The above will block flagged IPs for a week, you can of course change it to you
actionstart = iptables -N f2b-bad-auth
iptables -A f2b-bad-auth -j RETURN
iptables -I FORWARD -p tcp -m multiport --dports 1:1024 -j f2b-bad-auth
iptables -I DOCKER-USER -p tcp -m multiport --dports 1:1024 -j f2b-bad-auth
actionstop = iptables -D FORWARD -p tcp -m multiport --dports 1:1024 -j f2b-bad-auth
actionstop = iptables -D DOCKER-USER -p tcp -m multiport --dports 1:1024 -j f2b-bad-auth
iptables -F f2b-bad-auth
iptables -X f2b-bad-auth
actioncheck = iptables -n -L FORWARD | grep -q 'f2b-bad-auth[ \t]'
actioncheck = iptables -n -L DOCKER-USER | grep -q 'f2b-bad-auth[ \t]'
actionban = iptables -I f2b-bad-auth 1 -s <ip> -j DROP
actionunban = iptables -D f2b-bad-auth -s <ip> -j DROP
5. Restart Fail2Ban
Using DOCKER-USER chain ensures that the blocked IPs are processed in the correct order with Docker. See more in: https://docs.docker.com/network/iptables/
5. Configure and restart the Fail2Ban service
Make sure Fail2Ban is started after the Docker service by adding a partial override which appends this to the existing configuration.
.. code-block:: bash
sudo systemctl edit fail2ban
Add the override and save the file.
.. code-block:: bash
[Unit]
After=docker.service
Restart the Fail2Ban service.
.. code-block:: bash
sudo systemctl restart fail2ban
*Issue reference:* `85`_, `116`_, `171`_, `584`_, `592`_.
*Issue reference:* `85`_, `116`_, `171`_, `584`_, `592`_, `1727`_.
Users can't change their password from webmail
``````````````````````````````````````````````
@ -670,7 +689,7 @@ iptables -t nat -A POSTROUTING -o eth0 -p tcp --dport 25 -j SNAT --to <your mx i
.. _`1090`: https://github.com/Mailu/Mailu/issues/1090
.. _`unbound`: https://nlnetlabs.nl/projects/unbound/about/
.. _`1438`: https://github.com/Mailu/Mailu/issues/1438
.. _`1727`: https://github.com/Mailu/Mailu/issues/1727
A user gets ``Sender address rejected: Access denied. Please check the`` ``message recipient […] and try again`` even though the sender is legitimate?
``````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````

@ -3,6 +3,10 @@
Kubernetes setup
================
> Hold up!
> These instructions are not recommended for setting up Mailu in a production Kubernetes environment.
> Please see [the Helm Chart documentation](https://github.com/Mailu/helm-charts/blob/master/mailu/README.md).
Prequisites
-----------

@ -154,7 +154,40 @@ Add the respective Traefik labels for your domain/configuration, like
If your Traefik is configured to automatically request certificates from *letsencrypt*, then youll have a certificate for ``mail.your.doma.in`` now. However,
``mail.your.doma.in`` might only be the location where you want the Mailu web-interfaces to live — your mail should be sent/received from ``your.doma.in``,
and this is the ``DOMAIN`` in your ``.env``?
To support that use-case, Traefik can request ``SANs`` for your domain. Lets add something like
To support that use-case, Traefik can request ``SANs`` for your domain. The configuration for this will depend on your Traefik version.
----
Traefik 2.x using labels configuration
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Add the appropriate labels for your domain(s) to the ``front`` container in ``docker-compose.yml``.
.. code-block:: yaml
services:
front:
labels:
# Enable TLS
- "traefik.http.routers.mailu-secure.tls"
# Your main domain
- "traefik.http.routers.mailu-secure.tls.domains[0].main=your.doma.in"
# Optional SANs for your main domain
- "traefik.http.routers.mailu-secure.tls.domains[0].sans=mail.your.doma.in,webmail.your.doma.in,smtp.your.doma.in"
# Optionally add other domains
- "traefik.http.routers.mailu-secure.tls.domains[1].main=mail.other.doma.in"
- "traefik.http.routers.mailu-secure.tls.domains[1].sans=mail2.other.doma.in,mail3.other.doma.in"
# Your ACME certificate resolver
- "traefik.http.routers.mailu-secure.tls.certResolver=foo"
Of course, be sure to define the Certificate Resolver ``foo`` in the static configuration as well.
Alternatively, you can define SANs in the Traefik static configuration using routers, or in the static configuration using entrypoints. Refer to the Traefik documentation for more details.
Traefik 1.x with TOML configuration
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Lets add something like
.. code-block:: yaml
@ -163,7 +196,11 @@ To support that use-case, Traefik can request ``SANs`` for your domain. Lets add
main = "your.doma.in" # this is the same as $TRAEFIK_DOMAIN!
sans = ["mail.your.doma.in", "webmail.your.doma.in", "smtp.your.doma.in"]
to your ``traefik.toml``. You might need to clear your ``acme.json``, if a certificate for one of these domains already exists.
to your ``traefik.toml``.
----
You might need to clear your ``acme.json``, if a certificate for one of these domains already exists.
You will need some solution which dumps the certificates in ``acme.json``, so you can include them in the ``mailu/front`` container.
One such example is ``mailu/traefik-certdumper``, which has been adapted for use in Mailu. You can add it to your ``docker-compose.yml`` like:

@ -1,7 +1,7 @@
Web administration interface
============================
The web administration interface is the main website for maintaining your Mailu installation.
The web administration interface is the main website for maintaining your Mailu installation.
For brevity the web administration interface will now be mentioned as admin gui.
It offers the following configuration options:
@ -30,13 +30,13 @@ It offers the following configuration options:
* Configure all email domains served by Mailu, including:
* generating dkim and dmarc keys for a domain.
* view email domain information on how to configure your SPF, DMARC, DKIM and MX dns records for an email domain.
* Add new email domains.
* For existing domains, configure users, quotas, aliases, administrators and alternative domain names.
* access the webmail site.
* lookup settings for configuring your email client.
@ -49,7 +49,7 @@ The admin GUI is by default accessed via the URL `https://<my domain>/admin`, wh
To login the admin GUI enter the email address and password of an user.
Only global administrator users have access to all configuration settings and the Rspamd webgui. Other users will be presented with settings for only their account, and domains they are managers of.
To create a user who is a global administrator for a new installation, the Mailu.env file can be adapted.
To create a user who is a global administrator for a new installation, the Mailu.env file can be adapted.
For more information see the section 'Admin account - automatic creation' in :ref:`the configuration reference <admin_account>`.
The following sections are only accessible for global administrators:
@ -69,7 +69,7 @@ The following sections are only accessible for global administrators:
Settings
--------
After logging in the web administration interface, the settings page is loaded.
After logging in the web administration interface, the settings page is loaded.
On the settings page the settings of the currently logged in user can be changed.
Changes are saved and effective immediately after clicking the Save Settings button at the bottom of the page.
@ -77,27 +77,27 @@ Changes are saved and effective immediately after clicking the Save Settings but
Display name
````````````
On the settings page the displayed name can be changed of the logged in user.
On the settings page the displayed name can be changed of the logged in user.
This display name is only used within the web administration interface.
Antispam
````````
Under the section `Antispam` the spam filter can be enabled or disabled for the logged in user. By default the spam filter is enabled.
Under the section `Antispam` the spam filter can be enabled or disabled for the logged in user. By default the spam filter is enabled.
When the spam filter is disabled, all received email messages will go to the inbox folder of the logged in user.
The exception to this rule, are email messages with an extremely high spam score. These email messages are always rejected by Rspamd.
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.
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).
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 <antispam_howto>`.
Auto-forward
`````````````
Under the section `Auto-forward`, the automatic forwarding of received email messages can be enabled. When enabled, all received email messages are forwarded to the specified email address.
Under the section `Auto-forward`, the automatic forwarding of received email messages can be enabled. When enabled, all received email messages are forwarded to the specified email address.
The option "Keep a copy of the emails" can be ticked, to keep a copy of the received email message in the inbox folder.
@ -107,7 +107,7 @@ In the destination textbox, the email addresses can be entered for automatic for
Update password
---------------
On the `update password` page, the password of the logged in user can be changed. Changes are effective immediately.
On the `update password` page, the password of the logged in user can be changed. Changes are effective immediately.
.. _webadministration_auto-reply:
@ -117,7 +117,7 @@ Auto-reply
On the `auto-reply` page, automatic replies can be configured. This is also known as out of office (ooo) or out of facility (oof) replies.
To enable automatic replies tick the checkbox 'Enable automatic reply'.
To enable automatic replies tick the checkbox 'Enable automatic reply'.
Under Reply subject the email subject for automatic replies can be configured. When a reply subject is entered, this subject will be used for the automatic reply.
@ -130,12 +130,12 @@ E.g. if the email subject of the received email message is "how are you?", then
Fetched accounts
----------------
This page is only available when the Fetchmail container is part of your Mailu deployment.
This page is only available when the Fetchmail container is part of your Mailu deployment.
Fetchmail can be enabled when creating the docker-compose.yml file with the setup utility (https://setup.mailu.io).
On the `fetched accounts` page you can configure email accounts from which email messages will be retrieved.
Only unread email messages are retrieved from the specified email account.
By default Fetchmail will retrieve email messages every 10 minutes. This can be changed in the Mailu.env file.
Only unread email messages are retrieved from the specified email account.
By default Fetchmail will retrieve email messages every 10 minutes. This can be changed in the Mailu.env file.
For more information on changing the polling interval see :ref:`the configuration reference <fetchmail>`.
@ -149,7 +149,7 @@ You can add a fetched account by clicking on the `Add an account` button on the
* Enable TLS. Tick this setting if the email server requires TLS/SSL instead of STARTTLS.
* Username. The user name for logging in to the email server. Normally this is the email address or the email address' local-part (the part before @).
* Username. The user name for logging in to the email server. Normally this is the email address or the email address' local-part (the part before @).
* Password. The password for logging in to the email server.
@ -166,8 +166,8 @@ The purpose of an authentication token is to create a unique and strong password
The application will use this authentication token instead of the logged in user's password for sending/receiving email.
This allows safe access to the logged in user's email account. At any moment, the authentication token can be deleted so that the application has no access to the logged in user's email account anymore.
By clicking on the New token button on the top right of the page, a new authentication token can be created. On this page the generated authentication token will only be displayed once.
After saving the application token it is not possible anymore to view the unique password.
By clicking on the New token button on the top right of the page, a new authentication token can be created. On this page the generated authentication token will only be displayed once.
After saving the application token it is not possible anymore to view the unique password.
The comment field can be used to enter a description for the authentication token. For example the name of the application the application token is created for.
@ -198,9 +198,9 @@ A global administrator can change `any setting` in the admin GUI. Be careful tha
Relayed domains
---------------
On the `relayed domains list` page, destination domains can be added that Mailu will relay email messages for without authentication.
This means that for these destination domains, other email clients or email servers can send email via Mailu unauthenticated via port 25 to this destination domain.
For example if the destination domain example.com is added. Any emails to example.com (john@example.com) will be relayed to example.com.
On the `relayed domains list` page, destination domains can be added that Mailu will relay email messages for without authentication.
This means that for these destination domains, other email clients or email servers can send email via Mailu unauthenticated via port 25 to this destination domain.
For example if the destination domain example.com is added. Any emails to example.com (john@example.com) will be relayed to example.com.
Example scenario's are:
* relay domain from a backup server.
@ -212,30 +212,37 @@ Example scenario's are:
On the new relayed domain page the following options can be entered for a new relayed domain:
* Relayed domain name. The domain name that is relayed. Email messages addressed to this domain (To: John@example.com), will be forwarded to this domain.
No authentication is required.
* Relayed domain name. The domain name that is relayed. Email messages addressed to this domain (To: John@example.com), will be forwarded to this domain.
No authentication is required.
* Remote host (optional). The SMPT server that will be used for relaying the email message.
When this field is blank, the Mailu server will directly send the email message to the relayed domain.
As value can be entered either a hostname or IP address of the SMPT server.
By default port 25 is used. To use a different port append ":port number" to the Remote Host. For example:
123.45.67.90:2525.
* Remote host (optional). The host that will be used for relaying the email message.
When this field is blank, the Mailu server will directly send the email message to the mail server of the relayed domain.
When a remote host is specified it can be prefixed by ``mx:`` or ``lmtp:`` and followed by a port number: ``:port``).
================ ===================================== =========================
Remote host Description postfix transport:nexthop
================ ===================================== =========================
empty use MX of relay domain smtp:domain
:port use MX of relay domain and use port smtp:domain:port
target resolve A/AAAA of target smtp:[target]
target:port resolve A/AAAA of target and use port smtp:[target]:port
mx:target resolve MX of target smtp:target
mx:target:port resolve MX of target and use port smtp:target:port
lmtp:target resolve A/AAAA of target lmtp:target
lmtp:target:port resolve A/AAAA of target and use port lmtp:target:port
================ ===================================== =========================
`target` can also be an IPv4 or IPv6 address (an IPv6 address must be enclosed in []: ``[2001:DB8::]``).
* Comment. A text field where a comment can be entered to describe the entry.
Changes are effective immediately after clicking the Save button.
NOTE: Due to bug `1588`_ email messages fail to be relayed if no Remote Host is configured.
As a workaround the HOSTNAME or IP Address of the SMPT server of the relayed domain can be entered as Remote Host.
Please note that no MX lookup is performed when entering a hostname as Remote Host. You can use the MX lookup on mxtoolbox.com to find the hostname and IP Address of the SMTP server.
.. _`1588`: https://github.com/Mailu/Mailu/issues/1588
Antispam
--------
The menu item Antispam opens the Rspamd webgui. For more information how spam filtering works in Mailu see the :ref:`Spam filtering page <antispam_howto_block>`.
The spam filtering page also contains a section that describes how to create a local blacklist for blocking email messages from specific domains.
The spam filtering page also contains a section that describes how to create a local blacklist for blocking email messages from specific domains.
The Rspamd webgui offers basic functions for setting metric actions, scores, viewing statistics and learning.
The following settings are not persisent and are *lost* when the Antispam container is recreated or restarted:
@ -266,31 +273,31 @@ On the `Mail domains` page all the domains served by Mailu are configured. Via t
Details
```````
This page is also accessible for domain managers. On the details page all DNS settings are displayed for configuring your DNS server. It contains information on what to configure as MX record and SPF record. On this page it is also possible to (re-)generate the keys for DKIM and DMARC. The option for generating keys for DKIM and DMARC is only available for global administrators. After generating the keys for DKIM and DMARC, this page will also show the DNS records for configuring the DKIM/DMARC records on the DNS server.
This page is also accessible for domain managers. On the details page all DNS settings are displayed for configuring your DNS server. It contains information on what to configure as MX record and SPF record. On this page it is also possible to (re-)generate the keys for DKIM and DMARC. The option for generating keys for DKIM and DMARC is only available for global administrators. After generating the keys for DKIM and DMARC, this page will also show the DNS records for configuring the DKIM/DMARC records on the DNS server.
Edit
````
````
This page is only accessible for global administrators. On the edit page, the global settings for the domain can be changed.
This page is only accessible for global administrators. On the edit page, the global settings for the domain can be changed.
* Maximum user count. The maximum amount of users that can be created under this domain. Once this limit is reached it is not possible anymore to add users to the domain; and it is also not possible for users to self-register.
* Maximum alias count. The maximum amount of aliases that can be created for an email account.
* Maximum user quota. The maximum amount of quota that can be assigned to a user. When creating or editing a user, this sets the limit on the maximum amount of quota that can be assigned to the user.
* Enable sign-up. When this option is ticked, self-registration is enabled. When the Admin GUI is accessed, in the menu list the option Signup becomes available.
Obviously this menu item is only visible when signed out. On the Signup page a user can create an email account.
If your Admin GUI is available to the public internet, this means your Mailu installation basically becomes a free email provider.
* Enable sign-up. When this option is ticked, self-registration is enabled. When the Admin GUI is accessed, in the menu list the option Signup becomes available.
Obviously this menu item is only visible when signed out. On the Signup page a user can create an email account.
If your Admin GUI is available to the public internet, this means your Mailu installation basically becomes a free email provider.
Use this option with care!
* Comment. Description for the domain. This description is visible on the parent domains list page.
Delete
``````
This page is only accessible for global administrators. This page allows you to delete the domain. The Admin GUI will ask for confirmation if the domain must be really deleted.
This page is only accessible for global administrators. This page allows you to delete the domain. The Admin GUI will ask for confirmation if the domain must be really deleted.
Users
@ -326,7 +333,7 @@ For adding a new user the following options can be configured.
* Enabled. Tick this checkbox to enable the user account. When an user is disabled, the user is unable to login to the Admin GUI or webmail or access his email via IMAP/POP3 or send mail.
The email inbox of the user is still retained. This option can be used to temporarily suspend an user account.
* Quota. The maximum quota for the user's email box.
* Allow IMAP access. When ticked, allows email retrieval via the IMAP protocol.
@ -337,7 +344,7 @@ For adding a new user the following options can be configured.
Aliases
```````
This page is also accessible for domain managers. On the aliases page, aliases can be added for email addresses. An alias is a way to disguise another email address.
This page is also accessible for domain managers. On the aliases page, aliases can be added for email addresses. An alias is a way to disguise another email address.
Everything sent to an alias email address is actually received in the primary email account's inbox of the destination email address.
Aliases can diversify a single email account without having to create multiple email addresses (users).
It is also possible to add multiple email addresses to the destination field. All incoming mails will be sent to each users inbox in this case.
@ -348,11 +355,11 @@ The following options are available when adding an alias:
* Use SQL LIKE Syntax (e.g. for catch-all aliases). When this option is ticked, you can use SQL LIKE syntax as alias.
The SQL LIKE syntax is used to match text values against a pattern using wildcards. There are two wildcards that can be used with SQL LIKE syntax:
* % - The percent sign represents zero, one, or multiple characters
* _ - The underscore represents a single character
Examples are:
Examples are:
* a% - Finds any values that start with "a"
* %a - Finds any values that end with "a"
* %or% - Finds any values that have "or" in any position
@ -369,7 +376,7 @@ The following options are available when adding an alias:
Managers
````````
This page is also accessible for domain managers. On the `managers list` page, managers can be added for the domain and can be deleted.
This page is also accessible for domain managers. On the `managers list` page, managers can be added for the domain and can be deleted.
Managers have access to configuration settings of the domain.
On the `add manager` page you can click on the manager email text box to access a drop down list of users that can be made a manager of the domain.
@ -377,11 +384,11 @@ On the `add manager` page you can click on the manager email text box to access
Alternatives
````````````
This page is only accessible for global administrators. On the alternatives page, alternative domains can be added for the domain.
This page is only accessible for global administrators. On the alternatives page, alternative domains can be added for the domain.
An alternative domain acts as a copy of a given domain.
Everything sent to an alternative domain, is actually received in the domain the alternative is created for.
This allows you to receive emails for multiple domains while using a single domain.
For example if the main domain has the email address user@example.com, and the alternative domain is mymail.com,
Everything sent to an alternative domain, is actually received in the domain the alternative is created for.
This allows you to receive emails for multiple domains while using a single domain.
For example if the main domain has the email address user@example.com, and the alternative domain is mymail.com,
then email send to user@mymail.com will end up in the email box of user@example.com.
New domain
@ -392,16 +399,16 @@ This page is only accessible for global administrators. Via this page a new doma
* domain name. The name of the domain.
* Maximum user count. The maximum amount of users that can be created under this domain. Once this limit is reached it is not possible anymore to add users to the domain; and it is also not possible for users to self-register.
* Maximum alias count. The maximum amount of aliases that can be made for an email account.
* Maximum user quota. The maximum amount of quota that can be assigned to a user. When creating or editing a user, this sets the limit on the maximum amount of quota that can be assigned to the user.
* Enable sign-up. When this option is ticked, self-registration is enabled. When the Admin GUI is accessed, in the menu list the option Signup becomes available.
Obviously this menu item is only visible when signed out. On the Signup page a user can create an email account.
If your Admin GUI is available to the public internet, this means your Mailu installation basically becomes a free email provider.
* Enable sign-up. When this option is ticked, self-registration is enabled. When the Admin GUI is accessed, in the menu list the option Signup becomes available.
Obviously this menu item is only visible when signed out. On the Signup page a user can create an email account.
If your Admin GUI is available to the public internet, this means your Mailu installation basically becomes a free email provider.
Use this option with care!
* Comment. Description for the domain. This description is visible on the parent domains list page.
@ -414,7 +421,7 @@ The menu item `Webmail` opens the webmail page. This option is only available if
Client setup
------------
The menu item `Client setup` shows all settings for configuring your email client for connecting to Mailu.
The menu item `Client setup` shows all settings for configuring your email client for connecting to Mailu.
Website

@ -3,6 +3,7 @@ FROM $DISTRO
# python3 shared with most images
RUN apk add --no-cache \
python3 py3-pip bash py3-multidict \
&& apk add --upgrade sudo \
&& pip3 install --upgrade pip
# Shared layer between nginx, dovecot, postfix, postgresql, rspamd, unbound, rainloop, roundcube

@ -2,7 +2,6 @@
import anosql
import psycopg2
import jinja2
import glob
import os
import subprocess
@ -38,7 +37,6 @@ if not os.listdir("/data"):
rec.write("restore_command = 'gunzip < /backup/wal_archive/%f > %p'\n")
rec.write("standby_mode = off\n")
os.system("chown postgres:postgres /data/recovery.conf")
#os.system("sudo -u postgres pg_ctl start -D /data -o '-h \"''\" '")
else:
# Bootstrap the database
os.system("sudo -u postgres initdb -D /data")

@ -2,7 +2,7 @@ server:
verbosity: 1
interface: 0.0.0.0
interface: ::0
logfile: /dev/stdout
logfile: ""
do-ip4: yes
do-ip6: yes
do-udp: yes

@ -85,6 +85,7 @@ services:
antispam:
image: ${DOCKER_ORG:-mailu}/${DOCKER_PREFIX:-}rspamd:${MAILU_VERSION:-{{ version }}}
hostname: antispam
restart: always
env_file: {{ env }}
volumes:

@ -86,7 +86,7 @@ WELCOME_SUBJECT={{ welcome_subject or 'Welcome to your new email account' }}
WELCOME_BODY={{ welcome_body or 'Welcome to your new email account, if you can read this, then it is configured properly!' }}
# Maildir Compression
# choose compression-method, default: none (value: bz2, gz)
# choose compression-method, default: none (value: gz, bz2, lz4, zstd)
COMPRESSION={{ compression }}
# change compression-level, default: 6 (value: 1-9)
COMPRESSION_LEVEL={{ compression_level }}
@ -175,3 +175,10 @@ DB_HOST={{ db_url }}
DB_NAME={{ db_name }}
{% endif %}
{% if (postgresql == 'external' or db_flavor == 'mysql') and webmail_type == 'roundcube' %}
ROUNDCUBE_DB_FLAVOR={{ db_flavor }}
ROUNDCUBE_DB_USER={{ roundcube_db_user }}
ROUNDCUBE_DB_PW={{ roundcube_db_pw }}
ROUNDCUBE_DB_HOST={{ roundcube_db_url }}
ROUNDCUBE_DB_NAME={{ roundcube_db_name }}
{% endif %}

@ -70,6 +70,7 @@ services:
antispam:
image: ${DOCKER_ORG:-mailu}/${DOCKER_PREFIX:-}rspamd:${MAILU_VERSION:-{{ version }}}
hostname: antispam
env_file: {{ env }}
volumes:
- "{{ root }}/filter:/var/lib/rspamd"

@ -57,6 +57,13 @@ $(document).ready(function() {
$("#db_pw").prop('required',true);
$("#db_url").prop('required',true);
$("#db_name").prop('required',true);
if ($("#webmail").val() == 'roundcube') {
$("#roundcube_external_db").show();
$("#roundcube_db_user").prop('required',true);
$("#roundcube_db_pw").prop('required',true);
$("#roundcube_db_url").prop('required',true);
$("#roundcube_db_name").prop('required',true);
}
} else if (this.value == 'mysql') {
$("#postgres_db").hide();
$("#external_db").show();
@ -64,6 +71,13 @@ $(document).ready(function() {
$("#db_pw").prop('required',true);
$("#db_url").prop('required',true);
$("#db_name").prop('required',true);
if ($("#webmail").val() == 'roundcube') {
$("#roundcube_external_db").show();
$("#roundcube_db_user").prop('required',true);
$("#roundcube_db_pw").prop('required',true);
$("#roundcube_db_url").prop('required',true);
$("#roundcube_db_name").prop('required',true);
}
}
});
$("#external_psql").change(function() {
@ -73,6 +87,13 @@ $(document).ready(function() {
$("#db_pw").prop('required',true);
$("#db_url").prop('required',true);
$("#db_name").prop('required',true);
if ($("#webmail").val() == 'roundcube') {
$("#roundcube_external_db").show();
$("#roundcube_db_user").prop('required',true);
$("#roundcube_db_pw").prop('required',true);
$("#roundcube_db_url").prop('required',true);
$("#roundcube_db_name").prop('required',true);
}
} else {
$("#external_db").hide();
}

@ -28,7 +28,7 @@
<br/>
</div>
<div class="form-group" id="external_db" style="display: none">
<p>Set external database parameters</p>
<p>Set external database parameters for <b>Admin UI</b></p>
<label>DB User</label>
<input class="form-control" type="text" name="db_user" placeholder="Username" id="db_user">
<label>Db Password</label>
@ -37,6 +37,18 @@
<input class="form-control" type="text" name="db_url" placeholder="URL" id="db_url">
<label>Db Name</label>
<input class="form-control" type="text" name="db_name" placeholder="Database Name" id="db_name">
<br/>
<div class="form-group" id="roundcube_external_db" style="display: none">
<p>Set external database parameters for <b>Roundcube</b></p>
<label>DB User</label>
<input class="form-control" type="text" name="roundcube_db_user" placeholder="Username" id="roundcube_db_user">
<label>DB Password</label>
<input class="form-control" type="password" name="roundcube_db_pw" placeholder="Password" id="roundcube_db_pw">
<label>DB URL</label>
<input class="form-control" type="text" name="roundcube_db_url" placeholder="URL" id="roundcube_db_url">
<label>DB Name</label>
<input class="form-control" type="text" name="roundcube_db_name" placeholder="Database Name" id="roundcube_db_name">
</div>
</div>
</div>

@ -92,7 +92,7 @@ DMARC_RUF=admin
# Maildir Compression
# choose compression-method, default: none (value: bz2, gz)
# choose compression-method, default: none (value: gz, bz2, lz4, zstd)
COMPRESSION=
# change compression-level, default: 6 (value: 1-9)
COMPRESSION_LEVEL=

@ -92,7 +92,7 @@ DMARC_RUF=admin
# Maildir Compression
# choose compression-method, default: none (value: bz2, gz)
# choose compression-method, default: none (value: gz, bz2, lz4, zstd)
COMPRESSION=
# change compression-level, default: 6 (value: 1-9)
COMPRESSION_LEVEL=

@ -0,0 +1,5 @@
echo "Creating user required for next test ..."
# Should not fail and update the password; update mode
docker-compose -f tests/compose/filters/docker-compose.yml exec -T admin flask mailu admin admin mailu.io 'password' --mode=update || exit 1
docker-compose -f tests/compose/filters/docker-compose.yml exec -T admin flask mailu user user mailu.io 'password' || exit 1
echo "User created successfully"

@ -92,7 +92,7 @@ DMARC_RUF=admin
# Maildir Compression
# choose compression-method, default: none (value: bz2, gz)
# choose compression-method, default: none (value: gz, bz2, lz4, zstd)
COMPRESSION=
# change compression-level, default: 6 (value: 1-9)
COMPRESSION_LEVEL=

@ -51,7 +51,7 @@ DISABLE_STATISTICS=False
###################################
# Expose the admin interface (value: true, false)
ADMIN=true
ADMIN=false
# Choose which webmail to run if any (values: roundcube, rainloop, none)
WEBMAIL=rainloop
@ -92,7 +92,7 @@ DMARC_RUF=admin
# Maildir Compression
# choose compression-method, default: none (value: bz2, gz)
# choose compression-method, default: none (value: gz, bz2, lz4, zstd)
COMPRESSION=
# change compression-level, default: 6 (value: 1-9)
COMPRESSION_LEVEL=

@ -51,7 +51,7 @@ DISABLE_STATISTICS=False
###################################
# Expose the admin interface (value: true, false)
ADMIN=true
ADMIN=false
# Choose which webmail to run if any (values: roundcube, rainloop, none)
WEBMAIL=roundcube
@ -92,7 +92,7 @@ DMARC_RUF=admin
# Maildir Compression
# choose compression-method, default: none (value: bz2, gz)
# choose compression-method, default: none (value: gz, bz2, lz4, zstd)
COMPRESSION=
# change compression-level, default: 6 (value: 1-9)
COMPRESSION_LEVEL=

@ -92,7 +92,7 @@ DMARC_RUF=admin
# Maildir Compression
# choose compression-method, default: none (value: bz2, gz)
# choose compression-method, default: none (value: gz, bz2, lz4, zstd)
COMPRESSION=
# change compression-level, default: 6 (value: 1-9)
COMPRESSION_LEVEL=

@ -5,7 +5,7 @@
# Retag in case of `bors try`
if [ "$TRAVIS_BRANCH" = "testing" ]; then
export DOCKER_ORG="mailutest"
export DOCKER_ORG=$DOCKER_ORG_TESTS
# Commit message is like "Try #99".
# This sets the version tag to "pr-99"
export MAILU_VERSION="pr-${TRAVIS_COMMIT_MESSAGE//[!0-9]/}"

@ -0,0 +1 @@
Add a credential cache to speedup authentication requests.

@ -0,0 +1 @@
Add documentation for Traefik 2 in Reverse Proxy

@ -0,0 +1 @@
Add cli commands config-import and config-export

@ -0,0 +1 @@
Support configuring lz4 and zstd compression for dovecot.

@ -0,0 +1,2 @@
Fix CVE-2021-23240, CVE-2021-3156 and CVE-2021-23239 for postgresql
by force-upgrading sudo.

@ -0,0 +1 @@
Switched from Travis to Github actions for CI/CD. Improved CI workflow to perform all tests in parallel.

@ -0,0 +1 @@
Make CI tests run in parallel.

@ -0,0 +1 @@
Fix roundcube environment configuration for databases

@ -0,0 +1 @@
Antispam service now uses a static hostname. Rspamd history is only retained when the service has a fixed hostname.

@ -0,0 +1 @@
Add instructions on how to create DNS records for email client auto-configuration (RFC6186 style)

@ -0,0 +1 @@
Centralize the authentication of webmails behind the admin interface

@ -35,6 +35,7 @@ RUN apt-get update && apt-get install -y \
&& rm -rf /var/lib/apt/lists
COPY include.php /var/www/html/include.php
COPY sso.php /var/www/html/sso.php
COPY php.ini /php.ini
COPY application.ini /application.ini

@ -8,6 +8,10 @@ allow_admin_panel = Off
[labs]
allow_gravatar = Off
{% if ADMIN == "true" %}
custom_login_link='sso.php'
custom_logout_link='{{ WEB_ADMIN }}/ui/logout'
{% endif %}
[contacts]
enable = On

@ -0,0 +1,31 @@
<?php
$_ENV['RAINLOOP_INCLUDE_AS_API'] = true;
if (!defined('APP_VERSION')) {
$version = file_get_contents('/data/VERSION');
if ($version) {
define('APP_VERSION', $version);
define('APP_INDEX_ROOT_FILE', __FILE__);
define('APP_INDEX_ROOT_PATH', str_replace('\\', '/', rtrim(dirname(__FILE__), '\\/').'/'));
}
}
if (file_exists(APP_INDEX_ROOT_PATH.'rainloop/v/'.APP_VERSION.'/include.php')) {
include APP_INDEX_ROOT_PATH.'rainloop/v/'.APP_VERSION.'/include.php';
} else {
echo '[105] Missing version directory';
exit(105);
}
// Retrieve email and password
if (in_array('HTTP_X_REMOTE_USER', $_SERVER) && in_array('HTTP_X_REMOTE_USER_TOKEN', $_SERVER)) {
$email = $_SERVER['HTTP_X_REMOTE_USER'];
$password = $_SERVER['HTTP_X_REMOTE_USER_TOKEN'];
$ssoHash = \RainLoop\Api::GetUserSsoHash($email, $password);
// redirect to webmail sso url
header('Location: index.php?sso&hash='.$ssoHash);
}
else {
header('HTTP/1.0 403 Forbidden');
}

@ -24,6 +24,7 @@ conf.jinja("/application.ini", os.environ, "/data/_data_/_default_/configs/appli
conf.jinja("/php.ini", os.environ, "/usr/local/etc/php/conf.d/rainloop.ini")
os.system("chown -R www-data:www-data /data")
os.system("chmod -R a+rX /var/www/html/")
os.execv("/usr/local/bin/apache2-foreground", ["apache2-foreground"])

@ -46,6 +46,7 @@ RUN apt-get update && apt-get install -y \
COPY php.ini /php.ini
COPY config.inc.php /var/www/html/config/
COPY mailu.php /var/www/html/plugins/mailu/mailu.php
COPY start.py /start.py
EXPOSE 80/tcp

@ -36,7 +36,11 @@ $config['managesieve_host'] = $imap;
$config['managesieve_usetls'] = false;
// Customization settings
$config['support_url'] = getenv('WEB_ADMIN') ? '../..' . getenv('WEB_ADMIN') : '';
if (filter_var(getenv('ADMIN'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE)) {
array_push($config['plugins'], 'mailu');
$config['support_url'] = getenv('WEB_ADMIN') ? '../..' . getenv('WEB_ADMIN') : '';
$config['sso_logout_url'] = getenv('WEB_ADMIN').'/ui/logout';
}
$config['product_name'] = 'Mailu Webmail';
// We access the IMAP and SMTP servers locally with internal names, SSL

@ -0,0 +1,59 @@
<?php
class mailu extends rcube_plugin
{
function init()
{
$this->add_hook('startup', array($this, 'startup'));
$this->add_hook('authenticate', array($this, 'authenticate'));
$this->add_hook('login_after', array($this, 'login'));
$this->add_hook('login_failed', array($this, 'login_failed'));
$this->add_hook('logout_after', array($this, 'logout'));
}
function startup($args)
{
if (empty($_SESSION['user_id'])) {
$args['action'] = 'login';
}
return $args;
}
function authenticate($args)
{
if (!in_array('HTTP_X_REMOTE_USER', $_SERVER) || !in_array('HTTP_X_REMOTE_USER_TOKEN', $_SERVER)) {
header('HTTP/1.0 403 Forbidden');
die();
}
$args['user'] = $_SERVER['HTTP_X_REMOTE_USER'];
$args['pass'] = $_SERVER['HTTP_X_REMOTE_USER_TOKEN'];
$args['cookiecheck'] = false;
$args['valid'] = true;
return $args;
}
function logout($args) {
// Redirect to global SSO logout path.
$this->load_config();
$sso_logout_url = rcmail::get_instance()->config->get('sso_logout_url');
header("Location: " . $sso_logout_url, true);
exit;
}
function login($args)
{
header('Location: index.php');
exit();
}
function login_failed($args)
{
header('Location: sso.php');
exit();
}
}

@ -8,41 +8,42 @@ import subprocess
log.basicConfig(stream=sys.stderr, level=os.environ.get("LOG_LEVEL", "WARNING"))
os.environ["MAX_FILESIZE"] = str(int(int(os.environ.get("MESSAGE_SIZE_LIMIT"))*0.66/1048576))
os.environ["MAX_FILESIZE"] = str(int(int(os.environ.get("MESSAGE_SIZE_LIMIT")) * 0.66 / 1048576))
db_flavor=os.environ.get("ROUNDCUBE_DB_FLAVOR",os.environ.get("DB_FLAVOR","sqlite"))
if db_flavor=="sqlite":
os.environ["DB_DSNW"]="sqlite:////data/roundcube.db"
elif db_flavor=="mysql":
os.environ["DB_DSNW"]="mysql://%s:%s@%s/%s" % (
os.environ.get("ROUNDCUBE_DB_USER","roundcube"),
db_flavor = os.environ.get("ROUNDCUBE_DB_FLAVOR", "sqlite")
if db_flavor == "sqlite":
os.environ["DB_DSNW"] = "sqlite:////data/roundcube.db"
elif db_flavor == "mysql":
os.environ["DB_DSNW"] = "mysql://%s:%s@%s/%s" % (
os.environ.get("ROUNDCUBE_DB_USER", "roundcube"),
os.environ.get("ROUNDCUBE_DB_PW"),
os.environ.get("ROUNDCUBE_DB_HOST",os.environ.get("DB_HOST","database")),
os.environ.get("ROUNDCUBE_DB_NAME","roundcube")
)
elif db_flavor=="postgresql":
os.environ["DB_DSNW"]="pgsql://%s:%s@%s/%s" % (
os.environ.get("ROUNDCUBE_DB_USER","roundcube"),
os.environ.get("ROUNDCUBE_DB_HOST", "database"),
os.environ.get("ROUNDCUBE_DB_NAME", "roundcube")
)
elif db_flavor == "postgresql":
os.environ["DB_DSNW"] = "pgsql://%s:%s@%s/%s" % (
os.environ.get("ROUNDCUBE_DB_USER", "roundcube"),
os.environ.get("ROUNDCUBE_DB_PW"),
os.environ.get("ROUNDCUBE_DB_HOST",os.environ.get("DB_HOST","database")),
os.environ.get("ROUNDCUBE_DB_NAME","roundcube")
)
os.environ.get("ROUNDCUBE_DB_HOST", "database"),
os.environ.get("ROUNDCUBE_DB_NAME", "roundcube")
)
else:
print("Unknown ROUNDCUBE_DB_FLAVOR: %s",db_flavor)
print("Unknown ROUNDCUBE_DB_FLAVOR: %s", db_flavor)
exit(1)
conf.jinja("/php.ini", os.environ, "/usr/local/etc/php/conf.d/roundcube.ini")
# Create dirs, setup permissions
os.system("mkdir -p /data/gpg /var/www/html/logs")
os.system("touch /var/www/html/logs/errors.log")
os.system("chown -R www-data:www-data /var/www/html/logs")
os.system("chmod -R a+rX /var/www/html/")
os.system("ln -sf /var/www/html/index.php /var/www/html/sso.php")
try:
print("Initializing database")
result=subprocess.check_output(["/var/www/html/bin/initdb.sh","--dir","/var/www/html/SQL"],stderr=subprocess.STDOUT)
result = subprocess.check_output(["/var/www/html/bin/initdb.sh", "--dir", "/var/www/html/SQL"],
stderr=subprocess.STDOUT)
print(result.decode())
except subprocess.CalledProcessError as e:
if "already exists" in e.stdout.decode():
@ -53,7 +54,7 @@ except subprocess.CalledProcessError as e:
try:
print("Upgrading database")
subprocess.check_call(["/var/www/html/bin/update.sh","--version=?","-y"],stderr=subprocess.STDOUT)
subprocess.check_call(["/var/www/html/bin/update.sh", "--version=?", "-y"], stderr=subprocess.STDOUT)
except subprocess.CalledProcessError as e:
quit(1)
@ -61,7 +62,7 @@ except subprocess.CalledProcessError as e:
os.system("chown -R www-data:www-data /data")
# Tail roundcube logs
subprocess.Popen(["tail","-f","-n","0","/var/www/html/logs/errors.log"])
subprocess.Popen(["tail", "-f", "-n", "0", "/var/www/html/logs/errors.log"])
# Run apache
os.execv("/usr/local/bin/apache2-foreground", ["apache2-foreground"])

Loading…
Cancel
Save