Merge remote-tracking branch 'upstream/master' into feat-psql-support

master
Ionut Filip 6 years ago
commit 9077bf7313

@ -7,7 +7,6 @@ pull_request_rules:
actions:
merge:
method: merge
strict: true
dismiss_reviews:
approved: true
@ -20,6 +19,5 @@ pull_request_rules:
actions:
merge:
method: merge
strict: true
dismiss_reviews:
approved: true

@ -76,6 +76,7 @@ v1.6.0 - unreleased
- Enhancement: Move Mailu Docker network to a fixed subnet ([#727](https://github.com/Mailu/Mailu/issues/727))
- Enhancement: Added regex validation for alias username ([#764](https://github.com/Mailu/Mailu/issues/764))
- Enhancement: Update documentation
- Enhancement: Include favicon package ([#801](https://github.com/Mailu/Mailu/issues/801), ([#802](https://github.com/Mailu/Mailu/issues/802))
- Upstream: Update Roundcube
- Upstream: Update Rainloop
- Bug: Rainloop fails with "domain not allowed" ([#93](https://github.com/Mailu/Mailu/issues/93))
@ -107,6 +108,7 @@ v1.6.0 - unreleased
- Bug: Hostname resolving in start.py should retry on failure [docker swarm] ([#555](https://github.com/Mailu/Mailu/issues/555))
- Bug: Error when trying to log in with an account without domain ([#585](https://github.com/Mailu/Mailu/issues/585))
- Bug: Fix rainloop permissions ([#637](https://github.com/Mailu/Mailu/issues/637))
- Bug: Fix broken webmail and logo url in admin ([#792](https://github.com/Mailu/Mailu/issues/792))
v1.5.1 - 2017-11-21
-------------------

@ -0,0 +1,16 @@
## What type of PR?
(Feature, enhancement, bug-fix, documentation)
## What does this PR do?
### Related issue(s)
- Mention an issue like: #001
- Auto close an issue like: closes #001
## Prerequistes
Before we can consider review and merge, please make sure the following list is done and checked.
If an entry in not applicable, you can check it or remove it from the list.
- [ ] In case of feature or enhancement: documentation updated accordingly
- [ ] Unless it's docs or a minor change: place entry in the [changelog](CHANGELOG.md), under the latest un-released version.

@ -33,5 +33,5 @@ if exists "X-Virus" {
}
{% if user.reply_active %}
vacation :days 1 :subject "{{ user.reply_subject }}" "{{ user.reply_body }}";
vacation :days 1 {% if user.displayed_name != "" %}:from "{{ user.displayed_name }} <{{ user.email }}>"{% endif %} :subject "{{ user.reply_subject }}" "{{ user.reply_body }}";
{% endif %}

@ -6,7 +6,7 @@ import flask
import socket
import os
@internal.route("/dovecot/passdb/<user_email>")
@internal.route("/dovecot/passdb/<path:user_email>")
def dovecot_passdb_dict(user_email):
user = models.User.query.get(user_email) or flask.abort(404)
allow_nets = []
@ -20,7 +20,7 @@ def dovecot_passdb_dict(user_email):
})
@internal.route("/dovecot/userdb/<user_email>")
@internal.route("/dovecot/userdb/<path:user_email>")
def dovecot_userdb_dict(user_email):
user = models.User.query.get(user_email) or flask.abort(404)
return flask.jsonify({
@ -28,7 +28,7 @@ def dovecot_userdb_dict(user_email):
})
@internal.route("/dovecot/quota/<ns>/<user_email>", methods=["POST"])
@internal.route("/dovecot/quota/<ns>/<path:user_email>", methods=["POST"])
def dovecot_quota(ns, user_email):
user = models.User.query.get(user_email) or flask.abort(404)
if ns == "storage":
@ -37,12 +37,12 @@ def dovecot_quota(ns, user_email):
return flask.jsonify(None)
@internal.route("/dovecot/sieve/name/<script>/<user_email>")
@internal.route("/dovecot/sieve/name/<script>/<path:user_email>")
def dovecot_sieve_name(script, user_email):
return flask.jsonify(script)
@internal.route("/dovecot/sieve/data/default/<user_email>")
@internal.route("/dovecot/sieve/data/default/<path:user_email>")
def dovecot_sieve_data(user_email):
user = models.User.query.get(user_email) or flask.abort(404)
return flask.jsonify(flask.render_template("default.sieve", user=user))

@ -12,13 +12,13 @@ def postfix_mailbox_domain(domain_name):
return flask.jsonify(domain.name)
@internal.route("/postfix/mailbox/<email>")
@internal.route("/postfix/mailbox/<path:email>")
def postfix_mailbox_map(email):
user = models.User.query.get(email) or flask.abort(404)
return flask.jsonify(user.email)
@internal.route("/postfix/alias/<alias>")
@internal.route("/postfix/alias/<path:alias>")
def postfix_alias_map(alias):
localpart, domain_name = models.Email.resolve_domain(alias)
if localpart is None:
@ -27,7 +27,7 @@ def postfix_alias_map(alias):
return flask.jsonify(",".join(destination)) if destination else flask.abort(404)
@internal.route("/postfix/transport/<email>")
@internal.route("/postfix/transport/<path:email>")
def postfix_transport(email):
if email == '*':
return flask.abort(404)
@ -36,7 +36,7 @@ def postfix_transport(email):
return flask.jsonify("smtp:[{}]".format(relay.smtp))
@internal.route("/postfix/sender/login/<sender>")
@internal.route("/postfix/sender/login/<path:sender>")
def postfix_sender_login(sender):
localpart, domain_name = models.Email.resolve_domain(sender)
if localpart is None:
@ -45,7 +45,7 @@ def postfix_sender_login(sender):
return flask.jsonify(",".join(destination)) if destination else flask.abort(404)
@internal.route("/postfix/sender/access/<sender>")
@internal.route("/postfix/sender/access/<path:sender>")
def postfix_sender_access(sender):
""" Simply reject any sender that pretends to be from a local domain
"""

@ -72,7 +72,7 @@ class CommaSeparatedList(db.TypeDecorator):
return ",".join(value)
def process_result_value(self, value, dialect):
return filter(bool, value.split(",")) if value else []
return list(filter(bool, value.split(","))) if value else []
class JSONEncoded(db.TypeDecorator):

@ -6,7 +6,7 @@ import flask_login
import flask_wtf
import re
LOCALPART_REGEX = "^[a-zA-Z0-9.!#$%&*+/=?^_`{|}~-]+$"
LOCALPART_REGEX = "^[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*$"
class DestinationField(fields.SelectMultipleField):
""" Allow for multiple emails selection from current user choices and
@ -32,6 +32,14 @@ class DestinationField(fields.SelectMultipleField):
if not self.validator.match(item):
raise validators.ValidationError(_('Invalid email address.'))
class MultipleEmailAddressesVerify(object):
def __init__(self,message=_('Invalid email address.')):
self.message = message
def __call__(self, form, field):
pattern = re.compile(r'^([_a-z0-9\-]+)(\.[_a-z0-9\-]+)*@([a-z0-9\-]{2,}\.)*([a-z]{2,4})(,([_a-z0-9\-]+)(\.[_a-z0-9\-]+)*@([a-z0-9\-]{2,}\.)*([a-z]{2,4}))*$')
if not pattern.match(field.data.replace(" ", "")):
raise validators.ValidationError(self.message)
class ConfirmationForm(flask_wtf.FlaskForm):
submit = fields.SubmitField(_('Confirm'))
@ -81,6 +89,7 @@ class UserForm(flask_wtf.FlaskForm):
quota_bytes = fields_.IntegerSliderField(_('Quota'), default=1000000000)
enable_imap = fields.BooleanField(_('Allow IMAP access'), default=True)
enable_pop = fields.BooleanField(_('Allow POP3 access'), default=True)
displayed_name = fields.StringField(_('Displayed name'))
comment = fields.StringField(_('Comment'))
enabled = fields.BooleanField(_('Enabled'), default=True)
submit = fields.SubmitField(_('Save'))
@ -101,9 +110,7 @@ class UserSettingsForm(flask_wtf.FlaskForm):
spam_threshold = fields_.IntegerSliderField(_('Spam filter tolerance'))
forward_enabled = fields.BooleanField(_('Enable forwarding'))
forward_keep = fields.BooleanField(_('Keep a copy of the emails'))
forward_destination = fields.StringField(
_('Destination'), [validators.Optional(), validators.Email()]
)
forward_destination = fields.StringField(_('Destination'), [validators.Optional(), MultipleEmailAddressesVerify()])
submit = fields.SubmitField(_('Save settings'))

@ -13,6 +13,13 @@
{% block head %}
{{super()}}
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="manifest" href="/site.webmanifest">
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5">
<meta name="msapplication-TileColor" content="#00aba9">
<meta name="theme-color" content="#ffffff">
{% block scripts %}
{{super()}}
<script src="{{ url_for('.static', filename='select2/js/select2.min.js') }}"></script>
@ -28,7 +35,7 @@ class="hold-transition skin-blue sidebar-mini"
<div class="wrapper">
{% block navbar %}
<header class="main-header">
<a href="/admin/" class="logo">
<a href="{{ config["WEB_ADMIN"] }}" class="logo">
<span class="logo-lg">{{ config["SITENAME"] }}</span>
</a>
</header>

@ -69,7 +69,7 @@
<li class="header">{% trans %}Go to{% endtrans %}</li>
{% if config["WEBMAIL"] != "none" %}
<li>
<a href="{{ config["WEB_WEBMAIL"] }}/">
<a href="{{ config["WEB_WEBMAIL"] }}">
<i class="fa fa-envelope-o"></i> <span>{% trans %}Webmail{% endtrans %}</span>
</a>
</li>

@ -15,6 +15,7 @@
{% call macros.box(_("General")) %}
{{ macros.form_field(form.localpart, append='<span class="input-group-addon">@'+domain.name+'</span>') }}
{{ macros.form_fields((form.pw, form.pw2)) }}
{{ macros.form_field(form.displayed_name) }}
{{ macros.form_field(form.comment) }}
{{ macros.form_field(form.enabled) }}
{% endcall %}

@ -12,6 +12,10 @@
<form class="form" method="post" role="form">
{{ form.hidden_tag() }}
{% call macros.box(title=_("Displayed name")) %}
{{ macros.form_field(form.displayed_name) }}
{% endcall %}
{% call macros.box(title=_("Antispam")) %}
{{ macros.form_field(form.spam_enabled) }}
{{ macros.form_field(form.spam_threshold, step=1, max=100,

@ -33,7 +33,7 @@ def admin_create():
return flask.render_template('admin/create.html', form=form)
@ui.route('/admin/delete/<admin>', methods=['GET', 'POST'])
@ui.route('/admin/delete/<path:admin>', methods=['GET', 'POST'])
@access.global_admin
@access.confirmation_required("delete admin {admin}")
def admin_delete(admin):

@ -36,7 +36,7 @@ def alias_create(domain_name):
domain=domain, form=form)
@ui.route('/alias/edit/<alias>', methods=['GET', 'POST'])
@ui.route('/alias/edit/<path:alias>', methods=['GET', 'POST'])
@access.domain_admin(models.Alias, 'alias')
def alias_edit(alias):
alias = models.Alias.query.get(alias) or flask.abort(404)
@ -53,7 +53,7 @@ def alias_edit(alias):
form=form, alias=alias, domain=alias.domain)
@ui.route('/alias/delete/<alias>', methods=['GET', 'POST'])
@ui.route('/alias/delete/<path:alias>', methods=['GET', 'POST'])
@access.domain_admin(models.Alias, 'alias')
@access.confirmation_required("delete {alias}")
def alias_delete(alias):

@ -6,7 +6,7 @@ import flask_login
@ui.route('/fetch/list', methods=['GET', 'POST'], defaults={'user_email': None})
@ui.route('/fetch/list/<user_email>', methods=['GET'])
@ui.route('/fetch/list/<path:user_email>', methods=['GET'])
@access.owner(models.User, 'user_email')
def fetch_list(user_email):
user_email = user_email or flask_login.current_user.email
@ -15,7 +15,7 @@ def fetch_list(user_email):
@ui.route('/fetch/create', methods=['GET', 'POST'], defaults={'user_email': None})
@ui.route('/fetch/create/<user_email>', methods=['GET', 'POST'])
@ui.route('/fetch/create/<path:user_email>', methods=['GET', 'POST'])
@access.owner(models.User, 'user_email')
def fetch_create(user_email):
user_email = user_email or flask_login.current_user.email

@ -38,7 +38,7 @@ def manager_create(domain_name):
domain=domain, form=form)
@ui.route('/manager/delete/<domain_name>/<user_email>', methods=['GET', 'POST'])
@ui.route('/manager/delete/<domain_name>/<path:user_email>', methods=['GET', 'POST'])
@access.confirmation_required("remove manager {user_email}")
@access.domain_admin(models.Domain, 'domain_name')
def manager_delete(domain_name, user_email):

@ -9,7 +9,7 @@ import wtforms_components
@ui.route('/token/list', methods=['GET', 'POST'], defaults={'user_email': None})
@ui.route('/token/list/<user_email>', methods=['GET'])
@ui.route('/token/list/<path:user_email>', methods=['GET'])
@access.owner(models.User, 'user_email')
def token_list(user_email):
user_email = user_email or flask_login.current_user.email
@ -18,7 +18,7 @@ def token_list(user_email):
@ui.route('/token/create', methods=['GET', 'POST'], defaults={'user_email': None})
@ui.route('/token/create/<user_email>', methods=['GET', 'POST'])
@ui.route('/token/create/<path:user_email>', methods=['GET', 'POST'])
@access.owner(models.User, 'user_email')
def token_create(user_email):
user_email = user_email or flask_login.current_user.email

@ -7,7 +7,6 @@ import flask_login
import wtforms
import wtforms_components
@ui.route('/user/list/<domain_name>', methods=['GET'])
@access.domain_admin(models.Domain, 'domain_name')
def user_list(domain_name):
@ -44,7 +43,7 @@ def user_create(domain_name):
domain=domain, form=form)
@ui.route('/user/edit/<user_email>', methods=['GET', 'POST'])
@ui.route('/user/edit/<path:user_email>', methods=['GET', 'POST'])
@access.domain_admin(models.User, 'user_email')
def user_edit(user_email):
user = models.User.query.get(user_email) or flask.abort(404)
@ -72,7 +71,7 @@ def user_edit(user_email):
domain=user.domain, max_quota_bytes=max_quota_bytes)
@ui.route('/user/delete/<user_email>', methods=['GET', 'POST'])
@ui.route('/user/delete/<path:user_email>', methods=['GET', 'POST'])
@access.domain_admin(models.User, 'user_email')
@access.confirmation_required("delete {user_email}")
def user_delete(user_email):
@ -86,15 +85,22 @@ def user_delete(user_email):
@ui.route('/user/settings', methods=['GET', 'POST'], defaults={'user_email': None})
@ui.route('/user/usersettings/<user_email>', methods=['GET', 'POST'])
@ui.route('/user/usersettings/<path:user_email>', methods=['GET', 'POST'])
@access.owner(models.User, 'user_email')
def user_settings(user_email):
user_email_or_current = user_email or flask_login.current_user.email
user = models.User.query.get(user_email_or_current) or flask.abort(404)
form = forms.UserSettingsForm(obj=user)
if isinstance(form.forward_destination.data,str):
data = form.forward_destination.data.replace(" ","").split(",")
else:
data = form.forward_destination.data
form.forward_destination.data = ", ".join(data)
if form.validate_on_submit():
form.forward_destination.data = form.forward_destination.data.replace(" ","").split(",")
form.populate_obj(user)
models.db.session.commit()
form.forward_destination.data = ", ".join(form.forward_destination.data)
flask.flash('Settings updated for %s' % user)
if user_email:
return flask.redirect(
@ -103,7 +109,7 @@ def user_settings(user_email):
@ui.route('/user/password', methods=['GET', 'POST'], defaults={'user_email': None})
@ui.route('/user/password/<user_email>', methods=['GET', 'POST'])
@ui.route('/user/password/<path:user_email>', methods=['GET', 'POST'])
@access.owner(models.User, 'user_email')
def user_password(user_email):
user_email_or_current = user_email or flask_login.current_user.email
@ -123,7 +129,7 @@ def user_password(user_email):
@ui.route('/user/forward', methods=['GET', 'POST'], defaults={'user_email': None})
@ui.route('/user/forward/<user_email>', methods=['GET', 'POST'])
@ui.route('/user/forward/<path:user_email>', methods=['GET', 'POST'])
@access.owner(models.User, 'user_email')
def user_forward(user_email):
user_email_or_current = user_email or flask_login.current_user.email
@ -140,7 +146,7 @@ def user_forward(user_email):
@ui.route('/user/reply', methods=['GET', 'POST'], defaults={'user_email': None})
@ui.route('/user/reply/<user_email>', methods=['GET', 'POST'])
@ui.route('/user/reply/<path:user_email>', methods=['GET', 'POST'])
@access.owner(models.User, 'user_email')
def user_reply(user_email):
user_email_or_current = user_email or flask_login.current_user.email

@ -36,7 +36,7 @@ pyOpenSSL==18.0.0
python-dateutil==2.7.5
python-editor==1.0.3
pytz==2018.7
PyYAML==3.13
PyYAML==4.2b4
redis==3.0.1
six==1.11.0
SQLAlchemy==1.2.13

@ -10,6 +10,7 @@ RUN apk add --no-cache certbot nginx nginx-mod-mail openssl curl \
&& pip3 install idna requests watchdog
COPY conf /conf
COPY static /static
COPY *.py /
EXPOSE 80/tcp 443/tcp 110/tcp 143/tcp 465/tcp 587/tcp 993/tcp 995/tcp 25/tcp 10025/tcp 10143/tcp

@ -38,6 +38,8 @@ http {
{% if KUBERNETES_INGRESS != 'true' %}
# Main HTTP server
server {
# Favicon stuff
root /static;
# Variables for proxifying
set $admin {{ HOST_ADMIN }};
set $antispam {{ HOST_ANTISPAM }};
@ -90,9 +92,9 @@ http {
{% if WEB_WEBMAIL != '/' %}
location / {
{% if WEBROOT_REDIRECT %}
return 301 {{ WEBROOT_REDIRECT }};
try_files $uri {{ WEBROOT_REDIRECT }};
{% else %}
return 404;
try_files $uri =404;
{% endif %}
}
{% endif %}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/mstile-150x150.png"/>
<TileColor>#00aba9</TileColor>
</tile>
</msapplication>
</browserconfig>

Binary file not shown.

After

Width:  |  Height:  |  Size: 978 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

@ -0,0 +1,25 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="536.000000pt" height="536.000000pt" viewBox="0 0 536.000000 536.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.11, written by Peter Selinger 2001-2013
</metadata>
<g transform="translate(0.000000,536.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M2508 5346 c-1 -2 -38 -6 -80 -10 -120 -10 -319 -44 -418 -71 -196
-53 -336 -107 -526 -200 -128 -63 -346 -197 -419 -257 -11 -9 -54 -44 -95 -78
-429 -353 -738 -840 -880 -1385 -16 -61 -24 -98 -45 -205 -3 -14 -8 -47 -11
-75 -4 -27 -8 -53 -10 -56 -7 -13 -18 -230 -18 -354 2 -261 42 -536 109 -745
8 -25 21 -65 29 -90 30 -96 100 -260 160 -376 255 -489 653 -889 1136 -1141
103 -53 245 -118 285 -129 6 -1 35 -12 65 -24 48 -18 164 -54 210 -65 8 -2 45
-10 81 -19 208 -48 333 -61 599 -61 176 0 328 8 384 20 12 2 35 6 51 9 293 45
623 162 895 319 466 267 862 694 1082 1167 27 58 53 114 58 125 61 127 159
484 176 640 3 28 7 55 9 62 8 25 19 242 18 353 -6 552 -175 1074 -495 1525
-71 101 -82 114 -189 234 -255 286 -568 513 -924 669 -178 78 -422 152 -590
178 -16 3 -41 7 -55 10 -14 2 -47 7 -75 10 -27 3 -61 8 -75 11 -28 5 -436 13
-442 9z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@ -0,0 +1,19 @@
{
"name": "",
"short_name": "",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

@ -71,6 +71,14 @@ Web settings
The ``WEB_ADMIN`` contains the path to the main admin interface, while
``WEB_WEBMAIL`` contains the path to the Web email client.
The ``WEBROOT_REDIRECT`` redirects all non-found queries to the set path.
An empty ``WEBROOT_REDIRECT`` value disables redirecting and enables classic
behavior of a 404 result when not found.
All three options need a leading slash (``/``) to work.
.. note:: ``WEBROOT_REDIRECT`` has to point to a valid path on the webserver.
This means it cannot point to any services which are not enabled.
For example, don't point it to ``/webmail`` when ``WEBMAIL=none``
Both ``SITENAME`` and ``WEBSITE`` are customization options for the panel menu
in the admin interface, while ``SITENAME`` is a customization option for

@ -18,7 +18,7 @@ Functionality
- The server is reset every day at 3am, UTC.
- You can send mail from any client to the server.
However, the stmp server is made incapable of relaying the e-mail to the destination server.
However, the SMTP server is made incapable of relaying the e-mail to the destination server.
As such, the mail will never arrive. This is to prevent abuse of the server.
- The server is capable of receiving mail for any configured domains.
- The server exposes IMAP, POP3 and SMTP as usual for connection with mail clients such as Thunderbird.

@ -89,6 +89,51 @@ our ongoing `project management`_ discussion issue.
Deployment related
------------------
What is the difference between DOMAIN and HOSTNAMES?
````````````````````````````````````````````````````
Similar questions:
- Changing domain doesn't work
- Do I need a certificate for ``DOMAIN``?
``DOMAIN`` is the main mail domain. Aka, server identification for outgoing mail. DMARC reports point to ``POSTMASTER`` @ ``DOMAIN``.
These are really the only things it is used for. You don't need a cert for ``DOMAIN``, as it is a mail domain only and not used as host in any sense.
However, it is usual that ``DOMAIN`` gets setup as one of the many mail domains. None of the mail domains ever need a certificate.
TLS certificates work on host connection level only.
``HOSTNAMES`` however, can be used to connect to the server. All host names supplied in this variable will need a certificate. When ``TLS_FLAVOR=letsencrypt`` is set,
a certificate is requested automatically for all those domains.
So when you have something like this:
.. code-block:: bash
DOMAIN=example.com
POSTMASTER=me
HOSTNAMES=mail.example.com,mail.foo.com,bar.com
TLS_FLAVOR=letsencrypt
- You'll end up with a DMARC address to ``me@example.com``.
- Server identifies itself as the SMTP server of ``@example.com`` when sending mail. Make sure your reverse DNS hostname is part of that domain!
- Your server will have certificates for the 3 hostnames. You will need to create ``A`` and ``AAAA`` records for those names,
pointing to the IP addresses of your server.
- The admin interface generates ``MX`` and ``SPF`` examples which point to the first entry of ``HOSTNAMES`` but these are only examples.
You can modify them to use any other ``HOSTNAMES`` entry.
You're mail service will be reachable for IMAP, POP3, SMTP and Webmail at the addresses:
- mail.example.com
- mail.foo.com
- bar.com
.. note::
In this case ``example.com`` is not reachable as a host and will not have a certificate.
It can be used as a mail domain if MX is setup to point to one of the ``HOSTNAMES``. However, it is possible to include ``example.com`` in ``HOSTNAMES``.
*Issue reference:* `742`_, `747`_.
How does Mailu scale up?
````````````````````````
@ -123,6 +168,16 @@ For **service** HA, please see: `How does Mailu scale up?`_
.. _`spam magnet`: https://blog.zensoftware.co.uk/2012/07/02/why-we-tend-to-recommend-not-having-a-secondary-mx-these-days/
Does Mailu run on Rancher?
``````````````````````````
There is a rancher catalog for Mailu in the `Mailu/Rancher`_ repository. The user group for Rancher is small,
so we cannot promise any support on this when you're heading into trouble. See the repository README for more details.
*Issue reference:* `125`_.
.. _`Mailu/Rancher`: https://github.com/Mailu/Rancher
Can I run Mailu without host iptables?
``````````````````````````````````````
@ -138,24 +193,67 @@ For that reason we do **not** support deployment on Docker hosts without iptable
How can I override settings?
````````````````````````````
Postfix, dovecot and Rspamd support overriding configuration files. Override files belong in
Postfix, Dovecot, Nginx and Rspamd support overriding configuration files. Override files belong in
``$ROOT/overrides``. Please refer to the official documentation of those programs for the
correct syntax. The following file names will be taken as override configuration:
- `Postfix`_ - ``postfix.cf``;
- `Dovecot`_ - ``dovecot.conf``;
- `Nginx`_ - All ``*.conf`` files in the ``nginx`` sub-directory.
- `Rspamd`_ - All files in the ``rspamd`` sub-directory.
*Issue reference:* `206`_.
I want to integrate Nextcloud with Mailu
````````````````````````````````````````
First of all you have to install dependencies required to authenticate users via imap in Nextcloud
.. code-block:: bash
apt-get update \
&& apt-get install -y libc-client-dev libkrb5-dev \
&& rm -rf /var/lib/apt/lists/* \
&& docker-php-ext-configure imap --with-kerberos --with-imap-ssl \
&& docker-php-ext-install imap
Next, you have to enable External user support from Nextcloud Apps interface
In the end you need to configure additional user backends in Nextclouds configuration config/config.php using the following syntax:
.. code-block:: bash
<?php
'user_backends' => array(
array(
'class' => 'OC_User_IMAP',
'arguments' => array(
'{imap.example.com:993/imap/ssl}', 'example.com'
),
),
),
If a domain name (e.g. example.com) is specified, then this makes sure that only users from this domain will be allowed to login.
After successfull login the domain part will be striped and the rest used as username in NextCloud. e.g. 'username@example.com' will be 'username' in NextCloud.
*Issue reference:* `575`_.
.. _`Postfix`: http://www.postfix.org/postconf.5.html
.. _`Dovecot`: https://wiki.dovecot.org/ConfigFile
.. _`NGINX`: https://nginx.org/en/docs/
.. _`Rspamd`: https://www.rspamd.com/doc/configuration/index.html
.. _`Docker swarm howto`: https://github.com/Mailu/Mailu/tree/master/docs/swarm/master
.. _`125`: https://github.com/Mailu/Mailu/issues/125
.. _`165`: https://github.com/Mailu/Mailu/issues/165
.. _`177`: https://github.com/Mailu/Mailu/issues/177
.. _`332`: https://github.com/Mailu/Mailu/issues/332
.. _`742`: https://github.com/Mailu/Mailu/issues/742
.. _`747`: https://github.com/Mailu/Mailu/issues/747
.. _`520`: https://github.com/Mailu/Mailu/issues/520
.. _`591`: https://github.com/Mailu/Mailu/issues/591
.. _`575`: https://github.com/Mailu/Mailu/issues/575
Technical issues
----------------
@ -241,8 +339,18 @@ See also :ref:`external_certs`.
*Issue reference:* `426`_, `615`_.
How do I activate DKIM and DMARC?
`````````````````````````````````
Go into the Domain Panel and choose the Domain you want to enable DKIM for.
Click the first icon on the left side (domain details).
Now click on the top right on the *"Regenerate Keys"* Button.
This will generate the DKIM and DMARC entries for you.
*Issue reference:* `102`_.
Do you support Fail2Ban?
````````````````````````
Fail2Ban is not included in Mailu. Fail2Ban needs to modify the host's IP tables in order to
ban the addresses. We consider such a program should be run on the host system and not
inside a container. The ``front`` container does use authentication rate limiting to slow
@ -265,12 +373,49 @@ spam filter weight settings.
*Issue reference:* `503`_.
rspamd: DNS query blocked on multi.uribl.com
````````````````````````````````````````````
This usually relates to the DNS server you are using. Most of the public servers block this query or there is a rate limit.
In order to solve this, you most probably are better off using a root DNS resolver, such as `unbound`_. This can be done in multiple ways:
- Use the *Mailu/unbound* container. This is an optional include when generating the ``docker-compose.yml`` file with the setup utility.
- Setup unbound on the host and make sure the host's ``/etc/resolve.conf`` points to local host.
Docker will then forward all external DNS requests to the local server.
- Set up an external DNS server with root resolving capabilities.
In any case, using a dedicated DNS server will improve the performance of your mail server.
*Issue reference:* `206`_, `554`_, `681`_.
Is there a way to support more (older) ciphers?
```````````````````````````````````````````````
See `How can I override settings?`_ .
You will need to add the protocols you wish to support in an override for the ``front`` container (Nginx).
.. code-block:: bash
ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
ssl_ciphers <list of ciphers>;
We **strongly** advice against downgrading the TLS version and ciphers!
*Issue reference:* `363`_, `698`_.
.. _`troubleshooting tag`: https://github.com/Mailu/Mailu/issues?utf8=%E2%9C%93&q=label%3Afaq%2Ftroubleshooting
.. _`85`: https://github.com/Mailu/Mailu/issues/85
.. _`102`: https://github.com/Mailu/Mailu/issues/102
.. _`116`: https://github.com/Mailu/Mailu/issues/116
.. _`171`: https://github.com/Mailu/Mailu/issues/171
.. _`206`: https://github.com/Mailu/Mailu/issues/206
.. _`363`: https://github.com/Mailu/Mailu/issues/363
.. _`426`: https://github.com/Mailu/Mailu/issues/426
.. _`503`: https://github.com/Mailu/Mailu/issues/503
.. _`554`: https://github.com/Mailu/Mailu/issues/554
.. _`584`: https://github.com/Mailu/Mailu/issues/584
.. _`592`: https://github.com/Mailu/Mailu/issues/592
.. _`615`: https://github.com/Mailu/Mailu/issues/615
.. _`681`: https://github.com/Mailu/Mailu/pull/681
.. _`698`: https://github.com/Mailu/Mailu/issues/698
.. _`unbound`: https://nlnetlabs.nl/projects/unbound/about/

@ -5,17 +5,21 @@ version: '3.6'
services:
redis:
image: redis:alpine
networks:
- default
setup_master:
image: mailu/setup:master
networks:
- web
- default
env_file: .env
environment:
this_version: "master"
labels:
- traefik.enable=true
- traefik.port=80
- traefik.docker.network=web
- traefik.main.frontend.rule=Host:${ADDRESS};PathPrefix:/master/
depends_on:
- redis
@ -24,12 +28,14 @@ services:
image: mailu/setup:${RELEASE}
networks:
- web
- default
env_file: .env
environment:
this_version: ${RELEASE}
labels:
- traefik.enable=true
- traefik.port=80
- traefik.docker.network=web
- traefik.root.frontend.redirect.regex=.*
- traefik.root.frontend.redirect.replacement=/${RELEASE}/
- traefik.root.frontend.rule=Host:${ADDRESS};PathPrefix:/
@ -40,3 +46,5 @@ services:
networks:
web:
external: true
default:
external: false

@ -11,8 +11,8 @@ in a project directory. First create your project directory.</p>
to read and check the configuration variables generated by the wizard.</p>
<pre><code>cd {{ root }}
curl {{ url_for('.file', uid=uid, filepath='docker-compose.yml', _external=True) }} > docker-compose.yml
curl {{ url_for('.file', uid=uid, filepath='mailu.env', _external=True) }} > mailu.env
wget {{ url_for('.file', uid=uid, filepath='docker-compose.yml', _external=True) }}
wget {{ url_for('.file', uid=uid, filepath='mailu.env', _external=True) }}
</pre></code>
{% endcall %}

@ -11,8 +11,8 @@ in a project directory. First create your project directory.</p>
to read and check the configuration variables generated by the wizard.</p>
<pre><code>cd {{ root }}
curl {{ url_for('.file', uid=uid, filepath='docker-compose.yml', _external=True) }} > docker-compose.yml
curl {{ url_for('.file', uid=uid, filepath='mailu.env', _external=True) }} > mailu.env
wget {{ url_for('.file', uid=uid, filepath='docker-compose.yml', _external=True) }}
wget {{ url_for('.file', uid=uid, filepath='mailu.env', _external=True) }}
</pre></code>
{% endcall %}

@ -11,7 +11,9 @@ import ipaddress
import hashlib
app = flask.Flask(__name__)
version = os.getenv("this_version")
static_url_path = "/" + version + "/static"
app = flask.Flask(__name__, static_url_path=static_url_path)
flask_bootstrap.Bootstrap(app)
db = redis.StrictRedis(host='redis', port=6379, db=0)
@ -41,29 +43,37 @@ def build_app(path):
def app_context():
return dict(versions=os.getenv("VERSIONS","master").split(','))
version = os.getenv("this_version")
bp = flask.Blueprint(version, __name__)
bp.jinja_loader = jinja2.ChoiceLoader([
prefix_bp = flask.Blueprint(version, __name__)
prefix_bp.jinja_loader = jinja2.ChoiceLoader([
jinja2.FileSystemLoader(os.path.join(path, "templates")),
jinja2.FileSystemLoader(os.path.join(path, "flavors"))
])
@bp.context_processor
root_bp = flask.Blueprint("root", __name__)
root_bp.jinja_loader = jinja2.ChoiceLoader([
jinja2.FileSystemLoader(os.path.join(path, "templates")),
jinja2.FileSystemLoader(os.path.join(path, "flavors"))
])
@prefix_bp.context_processor
@root_bp.context_processor
def bp_context(version=version):
return dict(version=version)
@bp.route("/")
@prefix_bp.route("/")
@root_bp.route("/")
def wizard():
return flask.render_template('wizard.html')
@bp.route("/submit_flavor", methods=["POST"])
@prefix_bp.route("/submit_flavor", methods=["POST"])
@root_bp.route("/submit_flavor", methods=["POST"])
def submit_flavor():
data = flask.request.form.copy()
steps = sorted(os.listdir(os.path.join(path, "templates", "steps", data["flavor"])))
return flask.render_template('wizard.html', flavor=data["flavor"], steps=steps)
@bp.route("/submit", methods=["POST"])
@prefix_bp.route("/submit", methods=["POST"])
@root_bp.route("/submit", methods=["POST"])
def submit():
data = flask.request.form.copy()
data['uid'] = str(uuid.uuid4())
@ -71,14 +81,16 @@ def build_app(path):
db.set(data['uid'], json.dumps(data))
return flask.redirect(flask.url_for('.setup', uid=data['uid']))
@bp.route("/setup/<uid>", methods=["GET"])
@prefix_bp.route("/setup/<uid>", methods=["GET"])
@root_bp.route("/setup/<uid>", methods=["GET"])
def setup(uid):
data = json.loads(db.get(uid))
flavor = data.get("flavor", "compose")
rendered = render_flavor(flavor, "setup.html", data)
return flask.render_template("setup.html", contents=rendered)
@bp.route("/file/<uid>/<filepath>", methods=["GET"])
@prefix_bp.route("/file/<uid>/<filepath>", methods=["GET"])
@root_bp.route("/file/<uid>/<filepath>", methods=["GET"])
def file(uid, filepath):
data = json.loads(db.get(uid))
flavor = data.get("flavor", "compose")
@ -87,7 +99,8 @@ def build_app(path):
mimetype="application/text"
)
app.register_blueprint(bp, url_prefix="/{}".format(version))
app.register_blueprint(prefix_bp, url_prefix="/{}".format(version))
app.register_blueprint(root_bp)
if __name__ == "__main__":

@ -2,7 +2,8 @@ FROM php:7.2-apache
#Shared layer between rainloop and roundcube
RUN apt-get update && apt-get install -y \
python3 curl \
&& rm -rf /var/lib/apt/lists
&& rm -rf /var/lib/apt/lists \
&& echo "ServerSignature Off" >> /etc/apache2/apache2.conf
ENV RAINLOOP_URL https://github.com/RainLoop/rainloop-webmail/releases/download/v1.12.1/rainloop-community-1.12.1.zip

@ -2,7 +2,8 @@ FROM php:7.2-apache
#Shared layer between rainloop and roundcube
RUN apt-get update && apt-get install -y \
python3 curl \
&& rm -rf /var/lib/apt/lists
&& rm -rf /var/lib/apt/lists \
&& echo "ServerSignature Off" >> /etc/apache2/apache2.conf
ENV ROUNDCUBE_URL https://github.com/roundcube/roundcubemail/releases/download/1.3.8/roundcubemail-1.3.8-complete.tar.gz

Loading…
Cancel
Save