Merge branch 'master' into import-export

master
Dimitri Huisman 3 years ago committed by GitHub
commit 6dc1a19390
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -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+ - name: Trusted author and 1 approved review; trigger bors r+
conditions: 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) - -title~=(WIP|wip)
- -label~=^(status/wip|status/blocked|review/need2)$ - -label~=^(status/wip|status/blocked|review/need2)$
- "#approved-reviews-by>=1" - "#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 = [ status = [
"continuous-integration/travis-ci/push" "CI-Done"
] ]

@ -6,6 +6,7 @@ from simplekv.memory.redisstore import RedisStore
from mailu import utils, debug, models, manage, configuration from mailu import utils, debug, models, manage, configuration
import hmac
def create_app_from_config(config): def create_app_from_config(config):
""" Create a new application based on the given configuration """ 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.proxy.init_app(app)
utils.migrate.init_app(app, models.db) 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 # Initialize debugging tools
if app.config.get("DEBUG"): if app.config.get("DEBUG"):
debug.toolbar.init_app(app) debug.toolbar.init_app(app)

@ -7,7 +7,6 @@ import ipaddress
import socket import socket
import tenacity import tenacity
SUPPORTED_AUTH_METHODS = ["none", "plain"] 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): 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 return False
is_ok = 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 # All tokens are 32 characters hex lowercase
if len(password) == 32: if not is_ok and len(password) == 32:
for token in user.tokens: for token in user.tokens:
if (token.check_password(password) and if (token.check_password(password) and
(not token.ip or token.ip == ip)): (not token.ip or token.ip == ip)):

@ -43,6 +43,18 @@ def admin_authentication():
return "" return ""
return flask.abort(403) 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") @internal.route("/auth/basic")
def basic_authentication(): def basic_authentication():

@ -2,6 +2,7 @@ from mailu import models
from mailu.internal import internal from mailu.internal import internal
import flask import flask
import idna
import re import re
import srslib import srslib
@ -35,13 +36,67 @@ def postfix_alias_map(alias):
def postfix_transport(email): def postfix_transport(email):
if email == '*' or re.match("(^|.*@)\[.*\]$", email): if email == '*' or re.match("(^|.*@)\[.*\]$", email):
return flask.abort(404) 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) relay = models.Relay.query.get(domain_name) or flask.abort(404)
ret = "smtp:[{0}]".format(relay.smtp) target = relay.smtp.lower()
if ":" in relay.smtp: port = None
split = relay.smtp.split(':') use_lmtp = False
ret = "smtp:[{0}]:{1}".format(split[0], split[1]) use_mx = False
return flask.jsonify(ret) # 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>") @internal.route("/postfix/recipient/map/<path:recipient>")

@ -15,6 +15,11 @@ import sqlalchemy
import passlib.context import passlib.context
import passlib.hash import passlib.hash
import passlib.registry import passlib.registry
import time
import os
import glob
import hmac
import smtplib
import idna import idna
import dns import dns
@ -436,6 +441,7 @@ class User(Base, Email):
__tablename__ = 'user' __tablename__ = 'user'
_ctx = None _ctx = None
_credential_cache = {}
domain = db.relationship(Domain, domain = db.relationship(Domain,
backref=db.backref('users', cascade='all, delete-orphan')) backref=db.backref('users', cascade='all, delete-orphan'))
@ -522,6 +528,16 @@ class User(Base, Email):
""" verifies password against stored hash """ verifies password against stored hash
and updates hash if outdated and updates hash if outdated
""" """
cache_result = self._credential_cache.get(self.get_id())
current_salt = self.password.split('$')[3] if len(self.password.split('$')) == 5 else None
if cache_result and current_salt:
cache_salt, cache_hash = cache_result
if cache_salt == current_salt:
return hash.pbkdf2_sha256.verify(password, cache_hash)
else:
# the cache is local per gunicorn; the password has changed
# so the local cache can be invalidated
del self._credential_cache[self.get_id()]
reference = self.password reference = self.password
# strip {scheme} if that's something mailu has added # strip {scheme} if that's something mailu has added
# passlib will identify *crypt based hashes just fine # passlib will identify *crypt based hashes just fine
@ -534,6 +550,17 @@ class User(Base, Email):
self.password = new_hash self.password = new_hash
db.session.add(self) db.session.add(self)
db.session.commit() db.session.commit()
if result:
"""The credential cache uses a low number of rounds to be fast.
While it's not meant to be persisted to cold-storage, no additional measures
are taken to ensure it isn't (mlock(), encrypted swap, ...) on the basis that
we have little control over GC and string interning anyways.
An attacker that can dump the process' memory is likely to find credentials
in clear-text regardless of the presence of the cache.
"""
self._credential_cache[self.get_id()] = (self.password.split('$')[3], hash.pbkdf2_sha256.using(rounds=1).hash(password))
return result return result
def set_password(self, password, raw=False): def set_password(self, password, raw=False):
@ -574,6 +601,15 @@ class User(Base, Email):
user = cls.query.get(email) user = cls.query.get(email)
return user if (user and user.enabled and user.check_password(password)) else None return user if (user and user.enabled and user.check_password(password)) else None
@classmethod
def get_temp_token(cls, email):
user = cls.query.get(email)
return hmac.new(app.temp_token_key, bytearray("{}|{}".format(datetime.utcnow().strftime("%Y%m%d"), email), 'utf-8'), 'sha256').hexdigest() if (user and user.enabled) else None
def verify_temp_token(self, token):
return hmac.compare_digest(self.get_temp_token(self.email), token)
class Alias(Base, Email): class Alias(Base, Email):
""" An alias is an email address that redirects to some destination. """ An alias is an email address that redirects to some destination.

@ -528,7 +528,7 @@ msgstr "Alternatieve naam"
#: mailu/ui/forms.py:70 #: mailu/ui/forms.py:70
msgid "Relayed domain name" msgid "Relayed domain name"
msgstr "Relayed domainnaam" msgstr "Relayed domeinnaam"
#: mailu/ui/forms.py:71 mailu/ui/templates/relay/list.html:18 #: mailu/ui/forms.py:71 mailu/ui/templates/relay/list.html:18
msgid "Remote host" msgid "Remote host"
@ -536,7 +536,7 @@ msgstr "Externe host"
#: mailu/ui/templates/sidebar.html:54 #: mailu/ui/templates/sidebar.html:54
msgid "Relayed domains" msgid "Relayed domains"
msgstr "Relayed domainen" msgstr "Relayed domeinen"
#: mailu/ui/templates/alternative/create.html:4 #: mailu/ui/templates/alternative/create.html:4
msgid "Create alternative domain" 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> <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> </tr>
{% endif %} {% 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 %} {% endcall %}
{% endblock %} {% endblock %}

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

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

@ -136,9 +136,33 @@ http {
include /etc/nginx/proxy.conf; include /etc/nginx/proxy.conf;
client_max_body_size {{ MESSAGE_SIZE_LIMIT|int + 8388608 }}; client_max_body_size {{ MESSAGE_SIZE_LIMIT|int + 8388608 }};
proxy_pass http://$webmail; 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' %} {% if ADMIN == 'true' %}
location {{ WEB_ADMIN }} { location {{ WEB_ADMIN }} {
return 301 {{ WEB_ADMIN }}/ui; return 301 {{ WEB_ADMIN }}/ui;

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

@ -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! WELCOME_BODY=Welcome to your new email account, if you can read this, then it is configured properly!
# Maildir Compression # 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) # change compression-level, default: 6 (value: 1-9)
COMPRESSION_LEVEL= 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 When using ``*_ADDRESS``, the hostnames must be full-qualified hostnames. Otherwise nginx will not be able to
resolve the hostnames. 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. 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. 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, 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. After a successful test, the actual master gets fast-forwarded to that point.
System requirements System requirements
@ -201,16 +201,16 @@ us on `Matrix`_.
Test images 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. Some primitive auto testing is done.
The resulting images get uploaded to Docker hub, under the 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: For example, to test PR #500 against master, reviewers can use:
.. code-block:: bash .. code-block:: bash
export DOCKER_ORG="mailutest" export DOCKER_ORG="mailuci"
export MAILU_VERSION="pr-500" export MAILU_VERSION="pr-500"
docker-compose pull docker-compose pull
docker-compose up -d docker-compose up -d
@ -232,8 +232,8 @@ after Bors confirms a successful build.
When bors try fails When bors try fails
``````````````````` ```````````````````
Sometimes Travis fails when another PR triggers a ``bors try`` command, Sometimes the Github Action workflow fails when another PR triggers a ``bors try`` command,
before Travis cloned the git repository. 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. 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, If you see something like the following error on top of the logs,
feel free to write a comment with ``bors retry``. 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. 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. Test images get uploaded to a separate section in Docker hub.
Reviewers will check the PR and test the resulting images. Reviewers will check the PR and test the resulting images.
See the :ref:`testing` section for more info. See the :ref:`testing` section for more info.

@ -8,7 +8,8 @@ This functionality should still be considered experimental!
Mailu Postgresql 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: This images has the following features:
- Automatic creation of users, db, extensions and password; - 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: We currently maintain a strict work flow:
#. Someone writes a solution and sends a pull request; #. 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 #. The pull request needs to be code-reviewed and tested by at least two members
from the contributors team. 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`` - ``main.cf`` as ``$ROOT/overrides/postfix/postfix.cf``
- ``master.cf`` as ``$ROOT/overrides/postfix/postfix.master`` - ``master.cf`` as ``$ROOT/overrides/postfix/postfix.master``
- All ``$ROOT/overrides/postfix/*.map`` files - 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; - `Dovecot`_ - ``dovecot.conf`` in dovecot sub-directory;
- `Nginx`_ - All ``*.conf`` files in the ``nginx`` sub-directory; - `Nginx`_ - All ``*.conf`` files in the ``nginx`` sub-directory;
- `Rspamd`_ - All files in the ``rspamd`` 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 actionstart = iptables -N f2b-bad-auth
iptables -A f2b-bad-auth -j RETURN 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 -F f2b-bad-auth
iptables -X 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 actionban = iptables -I f2b-bad-auth 1 -s <ip> -j DROP
actionunban = iptables -D f2b-bad-auth -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 .. code-block:: bash
sudo systemctl restart fail2ban 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 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 .. _`1090`: https://github.com/Mailu/Mailu/issues/1090
.. _`unbound`: https://nlnetlabs.nl/projects/unbound/about/ .. _`unbound`: https://nlnetlabs.nl/projects/unbound/about/
.. _`1438`: https://github.com/Mailu/Mailu/issues/1438 .. _`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? 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 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 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, 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``, ``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``? 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 .. 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! main = "your.doma.in" # this is the same as $TRAEFIK_DOMAIN!
sans = ["mail.your.doma.in", "webmail.your.doma.in", "smtp.your.doma.in"] 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. 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: 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:

@ -215,22 +215,29 @@ On the new relayed domain page the following options can be entered for a new re
* 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. * 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. No authentication is required.
* Remote host (optional). The SMPT server that will be used for relaying the email message. * 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 relayed domain. When this field is blank, the Mailu server will directly send the email message to the mail server of the relayed domain.
As value can be entered either a hostname or IP address of the SMPT server. When a remote host is specified it can be prefixed by ``mx:`` or ``lmtp:`` and followed by a port number: ``:port``).
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 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. * Comment. A text field where a comment can be entered to describe the entry.
Changes are effective immediately after clicking the Save button. 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 Antispam
-------- --------

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

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

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

@ -26,7 +26,7 @@ services:
{% if bind4 %} {% if bind4 %}
- "{{ bind4 }}:{{ port }}:{{ port }}" - "{{ bind4 }}:{{ port }}:{{ port }}"
{% endif %} {% endif %}
{% if bind6 %} {% if ipv6_enabled and bind6 %}
- "{{ bind6 }}:{{ port }}:{{ port }}" - "{{ bind6 }}:{{ port }}:{{ port }}"
{% endif %} {% endif %}
{% endfor %} {% endfor %}
@ -85,6 +85,7 @@ services:
antispam: antispam:
image: ${DOCKER_ORG:-mailu}/${DOCKER_PREFIX:-}rspamd:${MAILU_VERSION:-{{ version }}} image: ${DOCKER_ORG:-mailu}/${DOCKER_PREFIX:-}rspamd:${MAILU_VERSION:-{{ version }}}
hostname: antispam
restart: always restart: always
env_file: {{ env }} env_file: {{ env }}
volumes: 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!' }} WELCOME_BODY={{ welcome_body or 'Welcome to your new email account, if you can read this, then it is configured properly!' }}
# Maildir Compression # Maildir Compression
# choose compression-method, default: none (value: bz2, gz) # choose compression-method, default: none (value: gz, bz2, lz4, zstd)
COMPRESSION={{ compression }} COMPRESSION={{ compression }}
# change compression-level, default: 6 (value: 1-9) # change compression-level, default: 6 (value: 1-9)
COMPRESSION_LEVEL={{ compression_level }} COMPRESSION_LEVEL={{ compression_level }}
@ -175,3 +175,10 @@ DB_HOST={{ db_url }}
DB_NAME={{ db_name }} DB_NAME={{ db_name }}
{% endif %} {% 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: antispam:
image: ${DOCKER_ORG:-mailu}/${DOCKER_PREFIX:-}rspamd:${MAILU_VERSION:-{{ version }}} image: ${DOCKER_ORG:-mailu}/${DOCKER_PREFIX:-}rspamd:${MAILU_VERSION:-{{ version }}}
hostname: antispam
env_file: {{ env }} env_file: {{ env }}
volumes: volumes:
- "{{ root }}/filter:/var/lib/rspamd" - "{{ root }}/filter:/var/lib/rspamd"

@ -57,6 +57,13 @@ $(document).ready(function() {
$("#db_pw").prop('required',true); $("#db_pw").prop('required',true);
$("#db_url").prop('required',true); $("#db_url").prop('required',true);
$("#db_name").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') { } else if (this.value == 'mysql') {
$("#postgres_db").hide(); $("#postgres_db").hide();
$("#external_db").show(); $("#external_db").show();
@ -64,6 +71,13 @@ $(document).ready(function() {
$("#db_pw").prop('required',true); $("#db_pw").prop('required',true);
$("#db_url").prop('required',true); $("#db_url").prop('required',true);
$("#db_name").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() { $("#external_psql").change(function() {
@ -73,6 +87,13 @@ $(document).ready(function() {
$("#db_pw").prop('required',true); $("#db_pw").prop('required',true);
$("#db_url").prop('required',true); $("#db_url").prop('required',true);
$("#db_name").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 { } else {
$("#external_db").hide(); $("#external_db").hide();
} }

@ -28,7 +28,7 @@
<br/> <br/>
</div> </div>
<div class="form-group" id="external_db" style="display: none"> <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> <label>DB User</label>
<input class="form-control" type="text" name="db_user" placeholder="Username" id="db_user"> <input class="form-control" type="text" name="db_user" placeholder="Username" id="db_user">
<label>Db Password</label> <label>Db Password</label>
@ -37,6 +37,18 @@
<input class="form-control" type="text" name="db_url" placeholder="URL" id="db_url"> <input class="form-control" type="text" name="db_url" placeholder="URL" id="db_url">
<label>Db Name</label> <label>Db Name</label>
<input class="form-control" type="text" name="db_name" placeholder="Database Name" id="db_name"> <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>
</div> </div>

@ -92,7 +92,7 @@ DMARC_RUF=admin
# Maildir Compression # 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) # change compression-level, default: 6 (value: 1-9)
COMPRESSION_LEVEL= COMPRESSION_LEVEL=

@ -92,7 +92,7 @@ DMARC_RUF=admin
# Maildir Compression # 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) # change compression-level, default: 6 (value: 1-9)
COMPRESSION_LEVEL= 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 # 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) # change compression-level, default: 6 (value: 1-9)
COMPRESSION_LEVEL= COMPRESSION_LEVEL=

@ -51,7 +51,7 @@ DISABLE_STATISTICS=False
################################### ###################################
# Expose the admin interface (value: true, false) # Expose the admin interface (value: true, false)
ADMIN=true ADMIN=false
# Choose which webmail to run if any (values: roundcube, rainloop, none) # Choose which webmail to run if any (values: roundcube, rainloop, none)
WEBMAIL=rainloop WEBMAIL=rainloop
@ -92,7 +92,7 @@ DMARC_RUF=admin
# Maildir Compression # 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) # change compression-level, default: 6 (value: 1-9)
COMPRESSION_LEVEL= COMPRESSION_LEVEL=

@ -51,7 +51,7 @@ DISABLE_STATISTICS=False
################################### ###################################
# Expose the admin interface (value: true, false) # Expose the admin interface (value: true, false)
ADMIN=true ADMIN=false
# Choose which webmail to run if any (values: roundcube, rainloop, none) # Choose which webmail to run if any (values: roundcube, rainloop, none)
WEBMAIL=roundcube WEBMAIL=roundcube
@ -92,7 +92,7 @@ DMARC_RUF=admin
# Maildir Compression # 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) # change compression-level, default: 6 (value: 1-9)
COMPRESSION_LEVEL= COMPRESSION_LEVEL=

@ -92,7 +92,7 @@ DMARC_RUF=admin
# Maildir Compression # 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) # change compression-level, default: 6 (value: 1-9)
COMPRESSION_LEVEL= COMPRESSION_LEVEL=

@ -5,7 +5,7 @@
# Retag in case of `bors try` # Retag in case of `bors try`
if [ "$TRAVIS_BRANCH" = "testing" ]; then if [ "$TRAVIS_BRANCH" = "testing" ]; then
export DOCKER_ORG="mailutest" export DOCKER_ORG=$DOCKER_ORG_TESTS
# Commit message is like "Try #99". # Commit message is like "Try #99".
# This sets the version tag to "pr-99" # This sets the version tag to "pr-99"
export MAILU_VERSION="pr-${TRAVIS_COMMIT_MESSAGE//[!0-9]/}" 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 @@
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 && rm -rf /var/lib/apt/lists
COPY include.php /var/www/html/include.php COPY include.php /var/www/html/include.php
COPY sso.php /var/www/html/sso.php
COPY php.ini /php.ini COPY php.ini /php.ini
COPY application.ini /application.ini COPY application.ini /application.ini

@ -8,6 +8,10 @@ allow_admin_panel = Off
[labs] [labs]
allow_gravatar = Off allow_gravatar = Off
{% if ADMIN == "true" %}
custom_login_link='sso.php'
custom_logout_link='{{ WEB_ADMIN }}/ui/logout'
{% endif %}
[contacts] [contacts]
enable = On 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") 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("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"]) 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 php.ini /php.ini
COPY config.inc.php /var/www/html/config/ COPY config.inc.php /var/www/html/config/
COPY mailu.php /var/www/html/plugins/mailu/mailu.php
COPY start.py /start.py COPY start.py /start.py
EXPOSE 80/tcp EXPOSE 80/tcp

@ -36,7 +36,11 @@ $config['managesieve_host'] = $imap;
$config['managesieve_usetls'] = false; $config['managesieve_usetls'] = false;
// Customization settings // 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'; $config['product_name'] = 'Mailu Webmail';
// We access the IMAP and SMTP servers locally with internal names, SSL // 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")) 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")) db_flavor = os.environ.get("ROUNDCUBE_DB_FLAVOR", "sqlite")
if db_flavor=="sqlite": if db_flavor == "sqlite":
os.environ["DB_DSNW"]="sqlite:////data/roundcube.db" os.environ["DB_DSNW"] = "sqlite:////data/roundcube.db"
elif db_flavor=="mysql": elif db_flavor == "mysql":
os.environ["DB_DSNW"]="mysql://%s:%s@%s/%s" % ( os.environ["DB_DSNW"] = "mysql://%s:%s@%s/%s" % (
os.environ.get("ROUNDCUBE_DB_USER","roundcube"), os.environ.get("ROUNDCUBE_DB_USER", "roundcube"),
os.environ.get("ROUNDCUBE_DB_PW"), os.environ.get("ROUNDCUBE_DB_PW"),
os.environ.get("ROUNDCUBE_DB_HOST",os.environ.get("DB_HOST","database")), os.environ.get("ROUNDCUBE_DB_HOST", "database"),
os.environ.get("ROUNDCUBE_DB_NAME","roundcube") os.environ.get("ROUNDCUBE_DB_NAME", "roundcube")
) )
elif db_flavor=="postgresql": elif db_flavor == "postgresql":
os.environ["DB_DSNW"]="pgsql://%s:%s@%s/%s" % ( os.environ["DB_DSNW"] = "pgsql://%s:%s@%s/%s" % (
os.environ.get("ROUNDCUBE_DB_USER","roundcube"), os.environ.get("ROUNDCUBE_DB_USER", "roundcube"),
os.environ.get("ROUNDCUBE_DB_PW"), os.environ.get("ROUNDCUBE_DB_PW"),
os.environ.get("ROUNDCUBE_DB_HOST",os.environ.get("DB_HOST","database")), os.environ.get("ROUNDCUBE_DB_HOST", "database"),
os.environ.get("ROUNDCUBE_DB_NAME","roundcube") os.environ.get("ROUNDCUBE_DB_NAME", "roundcube")
) )
else: else:
print("Unknown ROUNDCUBE_DB_FLAVOR: %s",db_flavor) print("Unknown ROUNDCUBE_DB_FLAVOR: %s", db_flavor)
exit(1) exit(1)
conf.jinja("/php.ini", os.environ, "/usr/local/etc/php/conf.d/roundcube.ini") conf.jinja("/php.ini", os.environ, "/usr/local/etc/php/conf.d/roundcube.ini")
# Create dirs, setup permissions # Create dirs, setup permissions
os.system("mkdir -p /data/gpg /var/www/html/logs") os.system("mkdir -p /data/gpg /var/www/html/logs")
os.system("touch /var/www/html/logs/errors.log") os.system("touch /var/www/html/logs/errors.log")
os.system("chown -R www-data:www-data /var/www/html/logs") 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: try:
print("Initializing database") 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()) print(result.decode())
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
if "already exists" in e.stdout.decode(): if "already exists" in e.stdout.decode():
@ -53,7 +54,7 @@ except subprocess.CalledProcessError as e:
try: try:
print("Upgrading database") 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: except subprocess.CalledProcessError as e:
quit(1) quit(1)
@ -61,7 +62,7 @@ except subprocess.CalledProcessError as e:
os.system("chown -R www-data:www-data /data") os.system("chown -R www-data:www-data /data")
# Tail roundcube logs # 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 # Run apache
os.execv("/usr/local/bin/apache2-foreground", ["apache2-foreground"]) os.execv("/usr/local/bin/apache2-foreground", ["apache2-foreground"])

Loading…
Cancel
Save