diff --git a/.env.dist b/.env.dist
index b35cd3db..ea63280a 100644
--- a/.env.dist
+++ b/.env.dist
@@ -55,6 +55,9 @@ EXPOSE_ADMIN=no
# Dav server implementation (value: radicale, none)
WEBDAV=none
+# Antivirus solution (value: none, clamav)
+ANTIVIRUS=clamav
+
###################################
# Mail settings
###################################
@@ -77,6 +80,10 @@ FETCHMAIL_DELAY=600
# e.g. localpart+custom@domain;tld
RECIPIENT_DELIMITER=+
+# DMARC rua and ruf email
+DMARC_RUA=admin
+DMARC_RUF=admin
+
###################################
# Nginx settings
###################################
diff --git a/admin/mailu/__init__.py b/admin/mailu/__init__.py
index df5851d1..43717181 100644
--- a/admin/mailu/__init__.py
+++ b/admin/mailu/__init__.py
@@ -27,6 +27,8 @@ default_config = {
'BOOTSTRAP_SERVE_LOCAL': True,
'DKIM_PATH': '/dkim/{domain}.{selector}.key',
'DKIM_SELECTOR': 'dkim',
+ 'DMARC_RUA': None,
+ 'DMARC_RUF': None,
'BABEL_DEFAULT_LOCALE': 'en',
'BABEL_DEFAULT_TIMEZONE': 'UTC',
'FRONTEND': 'none',
diff --git a/admin/mailu/admin/__init__.py b/admin/mailu/admin/__init__.py
index fc909cf0..fc895134 100644
--- a/admin/mailu/admin/__init__.py
+++ b/admin/mailu/admin/__init__.py
@@ -28,4 +28,6 @@ from mailu.admin.views import \
aliases, \
users, \
domains, \
+ relays, \
+ alternatives, \
fetches
diff --git a/admin/mailu/admin/forms.py b/admin/mailu/admin/forms.py
index ec3c7164..b00f4dac 100644
--- a/admin/mailu/admin/forms.py
+++ b/admin/mailu/admin/forms.py
@@ -51,6 +51,18 @@ class DomainForm(flask_wtf.FlaskForm):
submit = fields.SubmitField(_('Create'))
+class AlternativeForm(flask_wtf.FlaskForm):
+ name = fields.StringField(_('Alternative name'), [validators.DataRequired()])
+ submit = fields.SubmitField(_('Create'))
+
+
+class RelayForm(flask_wtf.FlaskForm):
+ name = fields.StringField(_('Relayed domain name'), [validators.DataRequired()])
+ smtp = fields.StringField(_('Remote host'))
+ comment = fields.StringField(_('Comment'))
+ submit = fields.SubmitField(_('Create'))
+
+
class UserForm(flask_wtf.FlaskForm):
localpart = fields.StringField(_('E-mail'), [validators.DataRequired()])
pw = fields.PasswordField(_('Password'), [validators.DataRequired()])
diff --git a/admin/mailu/admin/models.py b/admin/mailu/admin/models.py
index a1828a84..9fcfca1d 100644
--- a/admin/mailu/admin/models.py
+++ b/admin/mailu/admin/models.py
@@ -100,6 +100,36 @@ class Domain(Base):
return False
+class Alternative(Base):
+ """ Alternative name for a served domain.
+ The name "domain alias" was avoided to prevent some confusion.
+ """
+
+ __tablename__ = "alternative"
+
+ name = db.Column(db.String(80), primary_key=True, nullable=False)
+ domain_name = db.Column(db.String(80), db.ForeignKey(Domain.name))
+ domain = db.relationship(Domain,
+ backref=db.backref('alternatives', cascade='all, delete-orphan'))
+
+ def __str__(self):
+ return self.name
+
+
+class Relay(Base):
+ """ Relayed mail domain.
+ The domain is either relayed publicly or through a specified SMTP host.
+ """
+
+ __tablename__ = "relay"
+
+ name = db.Column(db.String(80), primary_key=True, nullable=False)
+ smtp = db.Column(db.String(80), nullable=True)
+
+ def __str__(self):
+ return self.name
+
+
class Email(object):
""" Abstraction for an email address (localpart and domain).
"""
diff --git a/admin/mailu/admin/templates/alternative/create.html b/admin/mailu/admin/templates/alternative/create.html
new file mode 100644
index 00000000..75461c67
--- /dev/null
+++ b/admin/mailu/admin/templates/alternative/create.html
@@ -0,0 +1,9 @@
+{% extends "form.html" %}
+
+{% block title %}
+{% trans %}Create alternative domain{% endtrans %}
+{% endblock %}
+
+{% block subtitle %}
+{{ domain }}
+{% endblock %}
diff --git a/admin/mailu/admin/templates/alternative/list.html b/admin/mailu/admin/templates/alternative/list.html
new file mode 100644
index 00000000..547d2ecb
--- /dev/null
+++ b/admin/mailu/admin/templates/alternative/list.html
@@ -0,0 +1,34 @@
+{% extends "base.html" %}
+
+{% block title %}
+{% trans %}Alternative domain list{% endtrans %}
+{% endblock %}
+
+{% block subtitle %}
+{{ domain.name }}
+{% endblock %}
+
+{% block main_action %}
+{% trans %}Add alternative{% endtrans %}
+{% endblock %}
+
+{% block box %}
+
+
+
+ {% trans %}Actions{% endtrans %} |
+ {% trans %}Name{% endtrans %} |
+ {% trans %}Created{% endtrans %} |
+
+ {% for alternative in domain.alternatives %}
+
+
+
+ |
+ {{ alternative }} |
+ {{ alternative.created_at }} |
+
+ {% endfor %}
+
+
+{% endblock %}
diff --git a/admin/mailu/admin/templates/domain/details.html b/admin/mailu/admin/templates/domain/details.html
index 9a98f944..1ae45e16 100644
--- a/admin/mailu/admin/templates/domain/details.html
+++ b/admin/mailu/admin/templates/domain/details.html
@@ -42,7 +42,7 @@
{% trans %}DNS DMARC entry{% endtrans %} |
- _dmarc.{{ domain.name }}. 600 IN TXT "v=DMARC1; p=reject; rua=mailto:{{ config["POSTMASTER"] }}@{{ config["DOMAIN"] }}; adkim=s; aspf=s" |
+ _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" |
{% endif %}
diff --git a/admin/mailu/admin/templates/domain/list.html b/admin/mailu/admin/templates/domain/list.html
index ea81e4c3..46a5d723 100644
--- a/admin/mailu/admin/templates/domain/list.html
+++ b/admin/mailu/admin/templates/domain/list.html
@@ -36,6 +36,9 @@
+ {% if current_user.global_admin %}
+
+ {% endif %}
{{ domain.name }} |
{{ domain.users | count }} / {{ domain.max_users or '∞' }} |
diff --git a/admin/mailu/admin/templates/relay/create.html b/admin/mailu/admin/templates/relay/create.html
new file mode 100644
index 00000000..b22cb001
--- /dev/null
+++ b/admin/mailu/admin/templates/relay/create.html
@@ -0,0 +1,5 @@
+{% extends "form.html" %}
+
+{% block title %}
+{% trans %}New relay domain{% endtrans %}
+{% endblock %}
diff --git a/admin/mailu/admin/templates/relay/edit.html b/admin/mailu/admin/templates/relay/edit.html
new file mode 100644
index 00000000..83dafcba
--- /dev/null
+++ b/admin/mailu/admin/templates/relay/edit.html
@@ -0,0 +1,9 @@
+{% extends "form.html" %}
+
+{% block title %}
+{% trans %}Edit relayd domain{% endtrans %}
+{% endblock %}
+
+{% block subtitle %}
+{{ relay }}
+{% endblock %}
diff --git a/admin/mailu/admin/templates/relay/list.html b/admin/mailu/admin/templates/relay/list.html
new file mode 100644
index 00000000..ba72a227
--- /dev/null
+++ b/admin/mailu/admin/templates/relay/list.html
@@ -0,0 +1,39 @@
+{% extends "base.html" %}
+
+{% block title %}
+{% trans %}Relayed domain list{% endtrans %}
+{% endblock %}
+
+{% block main_action %}
+{% if current_user.global_admin %}
+{% trans %}New relayed domain{% endtrans %}
+{% endif %}
+{% endblock %}
+
+{% block box %}
+
+
+
+ {% trans %}Actions{% endtrans %} |
+ {% trans %}Domain name{% endtrans %} |
+ {% trans %}Remote host{% endtrans %} |
+ {% trans %}Comment{% endtrans %} |
+ {% trans %}Created{% endtrans %} |
+ {% trans %}Last edit{% endtrans %} |
+
+ {% for relay in relays %}
+
+
+
+
+ |
+ {{ relay.name }} |
+ {{ relay.smtp or '-' }} |
+ {{ relay.comment or '' }} |
+ {{ relay.created_at }} |
+ {{ relay.updated_at or '' }} |
+
+ {% endfor %}
+
+
+{% endblock %}
diff --git a/admin/mailu/admin/templates/sidebar.html b/admin/mailu/admin/templates/sidebar.html
index bd7c0439..14abe241 100644
--- a/admin/mailu/admin/templates/sidebar.html
+++ b/admin/mailu/admin/templates/sidebar.html
@@ -50,6 +50,11 @@
{% trans %}Administrators{% endtrans %}
+
+
+ {% trans %}Relayed domains{% endtrans %}
+
+
{% endif %}
{% if current_user.manager_of or current_user.global_admin %}
diff --git a/admin/mailu/admin/views/alternatives.py b/admin/mailu/admin/views/alternatives.py
new file mode 100644
index 00000000..ce237d45
--- /dev/null
+++ b/admin/mailu/admin/views/alternatives.py
@@ -0,0 +1,47 @@
+from mailu.admin import app, db, models, forms, access
+
+import flask
+import wtforms_components
+
+
+@app.route('/alternative/list/', methods=['GET'])
+@access.global_admin
+def alternative_list(domain_name):
+ domain = models.Domain.query.get(domain_name) or flask.abort(404)
+ return flask.render_template('alternative/list.html', domain=domain)
+
+
+@app.route('/alternative/create/', methods=['GET', 'POST'])
+@access.global_admin
+def alternative_create(domain_name):
+ domain = models.Domain.query.get(domain_name) or flask.abort(404)
+ form = forms.AlternativeForm()
+ if form.validate_on_submit():
+ conflicting_domain = models.Domain.query.get(form.name.data)
+ conflicting_alternative = models.Alternative.query.get(form.name.data)
+ conflicting_relay = models.Relay.query.get(form.name.data)
+ if conflicting_domain or conflicting_alternative or conflicting_relay:
+ flask.flash('Domain %s is already used' % form.name.data, 'error')
+ else:
+ alternative = models.Alternative(domain=domain)
+ form.populate_obj(alternative)
+ db.session.add(alternative)
+ db.session.commit()
+ flask.flash('Alternative domain %s created' % alternative)
+ return flask.redirect(
+ flask.url_for('.alternative_list', domain_name=domain.name))
+ return flask.render_template('alternative/create.html',
+ domain=domain, form=form)
+
+
+@app.route('/alternative/delete/', methods=['GET', 'POST'])
+@access.global_admin
+@access.confirmation_required("delete {alternative}")
+def alternative_delete(alternative):
+ alternative = models.Alternative.query.get(alternative) or flask.abort(404)
+ domain = alternative.domain
+ db.session.delete(alternative)
+ db.session.commit()
+ flask.flash('Alternative %s deleted' % alternative)
+ return flask.redirect(
+ flask.url_for('.alternative_list', domain_name=domain.name))
diff --git a/admin/mailu/admin/views/domains.py b/admin/mailu/admin/views/domains.py
index 9bd26641..cebe5c8b 100644
--- a/admin/mailu/admin/views/domains.py
+++ b/admin/mailu/admin/views/domains.py
@@ -16,7 +16,10 @@ def domain_list():
def domain_create():
form = forms.DomainForm()
if form.validate_on_submit():
- if models.Domain.query.get(form.name.data):
+ conflicting_domain = models.Domain.query.get(form.name.data)
+ conflicting_alternative = models.Alternative.query.get(form.name.data)
+ conflicting_relay = models.Relay.query.get(form.name.data)
+ if conflicting_domain or conflicting_alternative or conflicting_relay:
flask.flash('Domain %s is already used' % form.name.data, 'error')
else:
domain = models.Domain()
diff --git a/admin/mailu/admin/views/relays.py b/admin/mailu/admin/views/relays.py
new file mode 100644
index 00000000..73a9ad9d
--- /dev/null
+++ b/admin/mailu/admin/views/relays.py
@@ -0,0 +1,60 @@
+from mailu.admin import app, db, models, forms, access
+from mailu import app as flask_app
+
+import flask
+import wtforms_components
+
+
+@app.route('/relay', methods=['GET'])
+@access.global_admin
+def relay_list():
+ relays = models.Relay.query.all()
+ return flask.render_template('relay/list.html', relays=relays)
+
+
+@app.route('/relay/create', methods=['GET', 'POST'])
+@access.global_admin
+def relay_create():
+ form = forms.RelayForm()
+ if form.validate_on_submit():
+ conflicting_domain = models.Domain.query.get(form.name.data)
+ conflicting_alternative = models.Alternative.query.get(form.name.data)
+ conflicting_relay = models.Relay.query.get(form.name.data)
+ if conflicting_domain or conflicting_alternative or conflicting_relay:
+ flask.flash('Domain %s is already used' % form.name.data, 'error')
+ else:
+ relay = models.Relay()
+ form.populate_obj(relay)
+ db.session.add(relay)
+ db.session.commit()
+ flask.flash('Relayed domain %s created' % relay)
+ return flask.redirect(flask.url_for('.relay_list'))
+ return flask.render_template('relay/create.html', form=form)
+
+
+@app.route('/relay/edit/', methods=['GET', 'POST'])
+@access.global_admin
+def relay_edit(relay_name):
+ relay = models.Relay.query.get(relay_name) or flask.abort(404)
+ form = forms.RelayForm(obj=relay)
+ wtforms_components.read_only(form.name)
+ form.name.validators = []
+ if form.validate_on_submit():
+ form.populate_obj(relay)
+ db.session.commit()
+ flask.flash('Relayed domain %s saved' % relay)
+ return flask.redirect(flask.url_for('.relay_list'))
+ return flask.render_template('relay/edit.html', form=form,
+ relay=relay)
+
+
+@app.route('/relay/delete/', methods=['GET', 'POST'])
+@access.global_admin
+@access.confirmation_required("delete {relay_name}")
+def relay_delete(relay_name):
+ relay = models.Relay.query.get(relay_name) or flask.abort(404)
+ db.session.delete(relay)
+ db.session.commit()
+ flask.flash('Relayed domain %s deleted' % relay)
+ return flask.redirect(flask.url_for('.relay_list'))
+
diff --git a/admin/migrations/versions/c162ac88012a_.py b/admin/migrations/versions/c162ac88012a_.py
new file mode 100644
index 00000000..914ba662
--- /dev/null
+++ b/admin/migrations/versions/c162ac88012a_.py
@@ -0,0 +1,29 @@
+""" Add relayed domains
+
+Revision ID: c162ac88012a
+Revises: c9a0b4e653cf
+Create Date: 2017-09-10 20:21:10.011969
+
+"""
+
+# revision identifiers, used by Alembic.
+revision = 'c162ac88012a'
+down_revision = 'c9a0b4e653cf'
+
+from alembic import op
+import sqlalchemy as sa
+
+
+def upgrade():
+ op.create_table('relay',
+ sa.Column('created_at', sa.Date(), nullable=False),
+ sa.Column('updated_at', sa.Date(), nullable=True),
+ sa.Column('comment', sa.String(length=255), nullable=True),
+ sa.Column('name', sa.String(length=80), nullable=False),
+ sa.Column('smtp', sa.String(length=80), nullable=True),
+ sa.PrimaryKeyConstraint('name')
+ )
+
+
+def downgrade():
+ op.drop_table('relay')
diff --git a/admin/migrations/versions/c9a0b4e653cf_.py b/admin/migrations/versions/c9a0b4e653cf_.py
new file mode 100644
index 00000000..8882d079
--- /dev/null
+++ b/admin/migrations/versions/c9a0b4e653cf_.py
@@ -0,0 +1,30 @@
+""" Add alternative domains
+
+Revision ID: c9a0b4e653cf
+Revises: 73e56bad5ec5
+Create Date: 2017-09-03 18:23:36.356527
+
+"""
+
+# revision identifiers, used by Alembic.
+revision = 'c9a0b4e653cf'
+down_revision = '73e56bad5ec5'
+
+from alembic import op
+import sqlalchemy as sa
+
+
+def upgrade():
+ op.create_table('alternative',
+ sa.Column('created_at', sa.Date(), nullable=False),
+ sa.Column('updated_at', sa.Date(), nullable=True),
+ sa.Column('comment', sa.String(length=255), nullable=True),
+ sa.Column('name', sa.String(length=80), nullable=False),
+ sa.Column('domain_name', sa.String(length=80), nullable=True),
+ sa.ForeignKeyConstraint(['domain_name'], ['domain.name'], ),
+ sa.PrimaryKeyConstraint('name')
+ )
+
+
+def downgrade():
+ op.drop_table('alternative')
diff --git a/docker-compose.yml.dist b/docker-compose.yml.dist
index 38d8e918..0dfb19ae 100644
--- a/docker-compose.yml.dist
+++ b/docker-compose.yml.dist
@@ -71,7 +71,7 @@ services:
antivirus:
# build: clamav
- image: mailu/clamav:$VERSION
+ image: mailu/$ANTIVIRUS:$VERSION
restart: always
env_file: .env
volumes:
diff --git a/postfix/conf/main.cf b/postfix/conf/main.cf
index 50f87a25..5e37543f 100644
--- a/postfix/conf/main.cf
+++ b/postfix/conf/main.cf
@@ -91,7 +91,8 @@ virtual_alias_maps = ${sql}sqlite-virtual_alias_maps.cf
virtual_mailbox_domains = ${sql}sqlite-virtual_mailbox_domains.cf
virtual_mailbox_maps = $virtual_alias_maps
-# Mails are forwarded to Dovecot for delivery
+# Mails are transported if required, then forwarded to Dovecot for delivery
+transport_maps = ${sql}sqlite-transport.cf
virtual_transport = lmtp:inet:imap:2525
# In order to prevent Postfix from running DNS query, enforce the use of the
diff --git a/postfix/conf/sqlite-transport.cf b/postfix/conf/sqlite-transport.cf
new file mode 100644
index 00000000..6295523b
--- /dev/null
+++ b/postfix/conf/sqlite-transport.cf
@@ -0,0 +1,3 @@
+dbpath = /data/main.db
+query =
+ SELECT 'smtp:['||smtp||']' FROM relay WHERE name='%s'
diff --git a/postfix/conf/sqlite-virtual_alias_maps.cf b/postfix/conf/sqlite-virtual_alias_maps.cf
index a96fb034..f53b65ae 100644
--- a/postfix/conf/sqlite-virtual_alias_maps.cf
+++ b/postfix/conf/sqlite-virtual_alias_maps.cf
@@ -4,7 +4,9 @@ query =
FROM
(SELECT destination, email, wildcard, localpart FROM alias
UNION
- SELECT (CASE WHEN forward_enabled=1 THEN (CASE WHEN forward_keep=1 THEN email||',' ELSE '' END)||forward_destination ELSE email END) AS destination, email, 0 as wildcard, localpart FROM user)
+ SELECT (CASE WHEN forward_enabled=1 THEN (CASE WHEN forward_keep=1 THEN email||',' ELSE '' END)||forward_destination ELSE email END) AS destination, email, 0 as wildcard, localpart FROM user
+ UNION
+ SELECT '@'||domain_name as destination, '@'||name as email, 0 as wildcard, '' as localpart FROM alternative)
WHERE
(
wildcard = 0
diff --git a/postfix/conf/sqlite-virtual_mailbox_domains.cf b/postfix/conf/sqlite-virtual_mailbox_domains.cf
index 2095ef2a..af453bce 100644
--- a/postfix/conf/sqlite-virtual_mailbox_domains.cf
+++ b/postfix/conf/sqlite-virtual_mailbox_domains.cf
@@ -1,2 +1,5 @@
dbpath = /data/main.db
-query = SELECT name FROM domain WHERE name='%s'
+query =
+ SELECT name FROM domain WHERE name='%s'
+ UNION
+ SELECT name FROM alternative WHERE name='%s'
diff --git a/rmilter/Dockerfile b/rmilter/Dockerfile
index 4a4e0d89..2b165524 100644
--- a/rmilter/Dockerfile
+++ b/rmilter/Dockerfile
@@ -4,6 +4,7 @@ RUN echo "@testing http://nl.alpinelinux.org/alpine/edge/testing" >> /etc/apk/re
&& apk add --no-cache rmilter@testing rsyslog bash
COPY rmilter.conf /etc/rmilter.conf
+COPY rmilter-clamav.conf /etc/rmilter-clamav.conf
COPY rsyslog.conf /etc/rsyslog.conf
COPY start.sh /start.sh
diff --git a/rmilter/rmilter-clamav.conf b/rmilter/rmilter-clamav.conf
new file mode 100644
index 00000000..b8f38f5e
--- /dev/null
+++ b/rmilter/rmilter-clamav.conf
@@ -0,0 +1,18 @@
+clamav {
+ # servers - clamav socket definitions in format:
+ servers = antivirus:3310;
+ # connect_timeout - timeout in miliseconds for connecting to clamav
+ connect_timeout = 1s;
+ # port_timeout - timeout in miliseconds for waiting for clamav port response
+ port_timeout = 4s;
+ # results_timeout - timeout in miliseconds for waiting for clamav response
+ results_timeout = 20s;
+ # error_time - time in seconds during which we are counting errors
+ error_time = 10;
+ # dead_time - time in seconds during which we are thinking that server is down
+ dead_time = 300;
+ # maxerrors - maximum number of errors that can occur during error_time to make us thinking that
+ # Default: 10
+ maxerrors = 10;
+};
+
diff --git a/rmilter/rmilter.conf b/rmilter/rmilter.conf
index abbeefad..64a0424e 100644
--- a/rmilter/rmilter.conf
+++ b/rmilter/rmilter.conf
@@ -20,24 +20,6 @@ strict_auth = no;
use_dcc = no;
use_redis = yes;
-clamav {
- # servers - clamav socket definitions in format:
- servers = antivirus:3310;
- # connect_timeout - timeout in miliseconds for connecting to clamav
- connect_timeout = 1s;
- # port_timeout - timeout in miliseconds for waiting for clamav port response
- port_timeout = 4s;
- # results_timeout - timeout in miliseconds for waiting for clamav response
- results_timeout = 20s;
- # error_time - time in seconds during which we are counting errors
- error_time = 10;
- # dead_time - time in seconds during which we are thinking that server is down
- dead_time = 300;
- # maxerrors - maximum number of errors that can occur during error_time to make us thinking that
- # Default: 10
- maxerrors = 10;
-};
-
spamd {
# servers - spamd socket definitions in format:
servers = r:antispam:11333;
diff --git a/rmilter/start.sh b/rmilter/start.sh
index 2b5bb127..ccd32981 100755
--- a/rmilter/start.sh
+++ b/rmilter/start.sh
@@ -8,5 +8,9 @@ for VARIABLE in `env | cut -f1 -d=`; do
done
rm -f /var/run/rsyslogd.pid
+if [ "$ANTIVIRUS" == "clamav" ];
+then
+ echo ".try_include /etc/rmilter-clamav.conf" >> /etc/rmilter.conf
+fi
rmilter -c /etc/rmilter.conf
rsyslogd -n