Merge branch 'master' into feat-fuzzyhashes

master
ofthesun9 6 years ago
commit cec5c1b16b

@ -7,7 +7,7 @@ COPY requirements-prod.txt requirements.txt
RUN apk add --no-cache openssl \
&& apk add --no-cache --virtual build-dep openssl-dev libffi-dev python-dev build-base \
&& pip install -r requirements.txt \
&& apk del build-dep
&& apk del --no-cache build-dep
COPY mailu ./mailu
COPY migrations ./migrations

@ -11,7 +11,6 @@ import os
import docker
import socket
import uuid
import redis
from werkzeug.contrib import fixers
@ -58,7 +57,7 @@ default_config = {
'RECAPTCHA_PUBLIC_KEY': '',
'RECAPTCHA_PRIVATE_KEY': '',
# Advanced settings
'PASSWORD_SCHEME': 'SHA512-CRYPT',
'PASSWORD_SCHEME': 'BLF-CRYPT',
# Host settings
'HOST_IMAP': 'imap',
'HOST_POP3': 'imap',
@ -89,9 +88,6 @@ manager.add_command('db', flask_migrate.MigrateCommand)
babel = flask_babel.Babel(app)
translations = list(map(str, babel.list_translations()))
# Quota manager
quota = redis.Redis.from_url(app.config.get("QUOTA_STORAGE_URL"))
@babel.localeselector
def get_locale():
return flask.request.accept_languages.best_match(translations)

@ -6,7 +6,8 @@ import socket
import flask
internal = flask.Blueprint('internal', __name__)
internal = flask.Blueprint('internal', __name__, template_folder='templates')
@internal.app_errorhandler(RateLimitExceeded)
def rate_limit_handler(e):
@ -17,6 +18,7 @@ def rate_limit_handler(e):
response.headers['Auth-Wait'] = '3'
return response
@limiter.request_filter
def whitelist_webmail():
try:
@ -26,4 +28,4 @@ def whitelist_webmail():
return False
from mailu.internal import views
from mailu.internal.views import *

@ -8,8 +8,6 @@ require "regex";
require "relational";
require "date";
require "comparator-i;ascii-numeric";
require "vnd.dovecot.extdata";
require "vnd.dovecot.execute";
require "spamtestplus";
require "editheader";
require "index";
@ -20,21 +18,23 @@ if header :index 2 :matches "Received" "from * by * for <*>; *"
addheader "Delivered-To" "<${3}>";
}
if allof (string :is "${extdata.spam_enabled}" "1",
spamtest :percent :value "gt" :comparator "i;ascii-numeric" "${extdata.spam_threshold}")
{% if user.spam_enabled %}
if spamtest :percent :value "gt" :comparator "i;ascii-numeric" "{{ user.spam_threshold }}"
{
setflag "\\seen";
fileinto :create "Junk";
stop;
}
{% endif %}
if exists "X-Virus" {
discard;
stop;
}
if allof (string :is "${extdata.reply_enabled}" "1",
currentdate :value "le" "date" "${extdata.reply_enddate}")
{% if user.reply_enabled %}
if currentdate :value "le" "date" "{{ user.reply_enddate }}"
{
vacation :days 1 :subject "${extdata.reply_subject}" "${extdata.reply_body}";
vacation :days 1 :subject "{{ user.reply_subject }}" "{{ user.reply_body }}";
}
{% endif %}

@ -0,0 +1,3 @@
__all__ = [
'auth', 'postfix', 'dovecot', 'fetch'
]

@ -4,7 +4,6 @@ from mailu.internal import internal, nginx
import flask
import flask_login
import base64
import urllib
@internal.route("/auth/email")

@ -0,0 +1,40 @@
from mailu import db, models
from mailu.internal import internal
import flask
@internal.route("/dovecot/passdb/<user_email>")
def dovecot_passdb_dict(user_email):
user = models.User.query.get(user_email) or flask.abort(404)
return flask.jsonify({
"password": user.password,
})
@internal.route("/dovecot/userdb/<user_email>")
def dovecot_userdb_dict(user_email):
user = models.User.query.get(user_email) or flask.abort(404)
return flask.jsonify({
"quota_rule": "*:bytes={}".format(user.quota_bytes)
})
@internal.route("/dovecot/quota/<ns>/<user_email>", methods=["POST"])
def dovecot_quota(ns, user_email):
user = models.User.query.get(user_email) or flask.abort(404)
if ns == "storage":
user.quota_bytes_used = flask.request.get_json()
db.session.commit()
return flask.jsonify(None)
@internal.route("/dovecot/sieve/name/<script>/<user_email>")
def dovecot_sieve_name(script, user_email):
return flask.jsonify(script)
@internal.route("/dovecot/sieve/data/default/<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))

@ -0,0 +1,32 @@
from mailu import db, models
from mailu.internal import internal
import flask
import datetime
@internal.route("/fetch")
def fetch_list():
return flask.jsonify([
{
"id": fetch.id,
"tls": fetch.tls,
"keep": fetch.keep,
"user_email": fetch.user_email,
"protocol": fetch.protocol,
"host": fetch.host,
"port": fetch.port,
"username": fetch.username,
"password": fetch.password
} for fetch in models.Fetch.query.all()
])
@internal.route("/fetch/<fetch_id>", methods=["POST"])
def fetch_done(fetch_id):
fetch = models.Fetch.query.get(fetch_id) or flask.abort(404)
fetch.last_check = datetime.datetime.now()
fetch.error_message = str(flask.request.get_json())
db.session.add(fetch)
db.session.commit()
return ""

@ -0,0 +1,54 @@
from mailu import db, models
from mailu.internal import internal
import flask
@internal.route("/postfix/domain/<domain_name>")
def postfix_mailbox_domain(domain_name):
domain = models.Domain.query.get(domain_name) or flask.abort(404)
return flask.jsonify(domain.name)
@internal.route("/postfix/mailbox/<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>")
def postfix_alias_map(alias):
localpart, domain = alias.split('@', 1) if '@' in alias else (None, alias)
alternative = models.Alternative.query.get(domain)
if alternative:
domain = alternative.domain_name
email = '{}@{}'.format(localpart, domain)
if localpart is None:
return flask.jsonify(domain)
else:
alias_obj = models.Alias.resolve(localpart, domain)
if alias_obj:
return flask.jsonify(",".join(alias_obj.destination))
user_obj = models.User.query.get(email)
if user_obj:
return flask.jsonify(user_obj.destination)
return flask.abort(404)
@internal.route("/postfix/transport/<email>")
def postfix_transport(email):
localpart, domain = email.split('@', 1) if '@' in email else (None, email)
relay = models.Relay.query.get(domain) or flask.abort(404)
return flask.jsonify("smtp:[{}]".format(relay.smtp))
@internal.route("/postfix/sender/<sender>")
def postfix_sender(sender):
""" Simply reject any sender that pretends to be from a local domain
"""
localpart, domain_name = sender.split('@', 1) if '@' in sender else (None, sender)
domain = models.Domain.query.get(domain_name)
alternative = models.Alternative.query.get(domain_name)
if domain or alternative:
return flask.jsonify("REJECT")
return flask.abort(404)

@ -1,11 +1,11 @@
from mailu import app, db, dkim, login_manager, quota
from mailu import app, db, dkim, login_manager
from sqlalchemy.ext import declarative
from passlib import context, hash
from datetime import datetime, date
from email.mime import text
import sqlalchemy
import re
import time
import os
@ -235,6 +235,7 @@ class User(Base, Email):
backref=db.backref('users', cascade='all, delete-orphan'))
password = db.Column(db.String(255), nullable=False)
quota_bytes = db.Column(db.Integer(), nullable=False, default=10**9)
quota_bytes_used = db.Column(db.Integer(), nullable=False, default=0)
global_admin = db.Column(db.Boolean(), nullable=False, default=False)
enabled = db.Column(db.Boolean(), nullable=False, default=True)
@ -266,10 +267,17 @@ class User(Base, Email):
return self.email
@property
def quota_bytes_used(self):
return quota.get(self.email + "/quota/storage") or 0
def destination(self):
if self.forward_enabled:
result = self.self.forward_destination
if self.forward_keep:
result += ',' + self.email
return result
else:
return self.email
scheme_dict = {'SHA512-CRYPT': "sha512_crypt",
scheme_dict = {'BLF-CRYPT': "bcrypt",
'SHA512-CRYPT': "sha512_crypt",
'SHA256-CRYPT': "sha256_crypt",
'MD5-CRYPT': "md5_crypt",
'CRYPT': "des_crypt"}
@ -329,6 +337,22 @@ class Alias(Base, Email):
wildcard = db.Column(db.Boolean(), nullable=False, default=False)
destination = db.Column(CommaSeparatedList, nullable=False, default=[])
@classmethod
def resolve(cls, localpart, domain_name):
return cls.query.filter(
sqlalchemy.and_(cls.domain_name == domain_name,
sqlalchemy.or_(
sqlalchemy.and_(
cls.wildcard == False,
cls.localpart == localpart
), sqlalchemy.and_(
cls.wildcard == True,
sqlalchemy.bindparam("l", localpart).like(cls.localpart)
)
)
)
).first()
class Token(Base):
""" A token is an application password for a given user.

@ -0,0 +1,28 @@
""" Add a column for used quota
Revision ID: 25fd6c7bcb4a
Revises: 049fed905da7
Create Date: 2018-07-25 21:56:09.729153
"""
# revision identifiers, used by Alembic.
revision = '25fd6c7bcb4a'
down_revision = '049fed905da7'
from alembic import op
import sqlalchemy as sa
from alembic import op
import sqlalchemy as sa
def upgrade():
with op.batch_alter_table('user') as batch:
batch.add_column(sa.Column('quota_bytes_used', sa.Integer(), nullable=False, server_default='0'))
def downgrade():
with op.batch_alter_table('user') as batch:
batch.drop_column('user', 'quota_bytes_used')

@ -1,6 +1,7 @@
alembic==0.9.9
asn1crypto==0.24.0
Babel==2.5.3
bcrypt==3.1.4
blinker==1.4
certifi==2018.4.16
cffi==1.11.5

@ -17,3 +17,4 @@ tabulate
PyYAML
PyOpenSSL
dnspython
bcrypt

@ -1,14 +1,12 @@
FROM alpine:3.7
FROM alpine:3.8
RUN echo "@testing http://nl.alpinelinux.org/alpine/edge/testing" >> /etc/apk/repositories \
&& apk add --no-cache \
dovecot dovecot-sqlite dovecot-pigeonhole-plugin dovecot-pigeonhole-plugin-extdata \
dovecot-fts-lucene rspamd-client@testing python py-jinja2 py-pip \
&& pip install --upgrade pip \
&& pip install tenacity
RUN apk add --no-cache \
dovecot dovecot-pigeonhole-plugin dovecot-fts-lucene rspamd-client \
python3 py3-pip \
&& pip3 install --upgrade pip \
&& pip3 install jinja2 podop tenacity
COPY conf /conf
COPY sieve /var/lib/dovecot
COPY start.py /start.py
EXPOSE 110/tcp 143/tcp 993/tcp 4190/tcp 2525/tcp

@ -0,0 +1,5 @@
uri = proxy:/tmp/podop.socket:auth
iterate_disable = yes
default_pass_scheme = plain
password_key = passdb/%u
user_key = userdb/%u

@ -1,18 +0,0 @@
driver = sqlite
connect = /data/main.db
# Return the user hashed password
password_query = \
SELECT NULL as password, 'Y' as nopassword, '{% if POD_ADDRESS_RANGE %}{{ POD_ADDRESS_RANGE }}{% else %}{{ FRONT_ADDRESS }}{% if WEBMAIL_ADDRESS %},{{ WEBMAIL_ADDRESS }}{% endif %}{% endif %}' as allow_nets \
FROM user \
WHERE user.email = '%u'
# Mostly get the user quota
user_query = \
SELECT '*:bytes=' || user.quota_bytes AS quota_rule \
FROM user \
WHERE user.email = '%u'
# For using doveadm -A:
iterate_query = \
SELECT user.email AS user FROM user

@ -7,17 +7,6 @@ postmaster_address = {{ POSTMASTER }}@{{ DOMAIN }}
hostname = {{ HOSTNAMES.split(",")[0] }}
submission_host = {{ FRONT_ADDRESS }}
service dict {
unix_listener dict {
group = mail
mode = 0660
}
}
dict {
sieve = sqlite:/etc/dovecot/pigeonhole-sieve.dict
}
###############
# Full-text search
###############
@ -50,28 +39,18 @@ mail_plugins = $mail_plugins quota quota_clone zlib
namespace inbox {
inbox = yes
mailbox Trash {
{% for mailbox in ("Trash", "Drafts", "Sent", "Junk") %}
mailbox {{ mailbox }} {
auto = subscribe
special_use = \Trash
}
mailbox Drafts {
auto = subscribe
special_use = \Drafts
}
mailbox Sent {
auto = subscribe
special_use = \Sent
}
mailbox Junk {
auto = subscribe
special_use = \Junk
special_use = \{{ mailbox }}
}
{% endfor %}
}
plugin {
quota = count:User quota
quota_vsizes = yes
quota_clone_dict = redis:host={{ REDIS_ADDRESS }}:port=6379:db=1
quota_clone_dict = proxy:/tmp/podop.socket:quota
{% if COMPRESSION in [ 'gz', 'bz2' ] %}
zlib_save = {{ COMPRESSION }}
@ -87,16 +66,15 @@ plugin {
###############
auth_mechanisms = plain login
disable_plaintext_auth = no
ssl_protocols = !SSLv3
passdb {
driver = sql
args = /etc/dovecot/dovecot-sql.conf.ext
driver = dict
args = /etc/dovecot/auth.conf
}
userdb {
driver = sql
args = /etc/dovecot/dovecot-sql.conf.ext
driver = dict
args = /etc/dovecot/auth.conf
}
service auth {
@ -117,7 +95,6 @@ service auth-worker {
###############
# IMAP & POP
###############
protocol imap {
mail_plugins = $mail_plugins imap_quota imap_sieve
}
@ -135,7 +112,6 @@ service imap-login {
###############
# Delivery
###############
protocol lmtp {
mail_plugins = $mail_plugins sieve
recipient_delimiter = {{ RECIPIENT_DELIMITER }}
@ -147,11 +123,9 @@ service lmtp {
}
}
###############
# Filtering
###############
service managesieve-login {
inet_listener sieve {
port = 4190
@ -162,16 +136,13 @@ service managesieve {
}
plugin {
sieve = file:~/sieve;active=~/.dovecot.sieve
sieve_plugins = sieve_extdata sieve_imapsieve sieve_extprograms
sieve_global_extensions = +vnd.dovecot.extdata +spamtest +spamtestplus +vnd.dovecot.execute +editheader
sieve_before = /var/lib/dovecot/before.sieve
sieve_default = /var/lib/dovecot/default.sieve
sieve_after = /var/lib/dovecot/after.sieve
sieve_extdata_dict_uri = proxy::sieve
sieve = dict:proxy:/tmp/podop.socket:sieve
sieve_plugins = sieve_imapsieve sieve_extprograms
sieve_extensions = +spamtest +spamtestplus +editheader
sieve_global_extensions = +vnd.dovecot.execute
# Sieve execute
sieve_execute_bin_dir = /var/lib/dovecot/bin
sieve_execute_bin_dir = /conf/bin
# Send vacation replies even for aliases
# See the Pigeonhole documentation about warnings: http://wiki2.dovecot.org/Pigeonhole/Sieve/Extensions/Vacation
@ -190,11 +161,11 @@ plugin {
# Learn from spam
imapsieve_mailbox1_name = Junk
imapsieve_mailbox1_causes = COPY
imapsieve_mailbox1_before = file:/var/lib/dovecot/report-spam.sieve
imapsieve_mailbox1_before = file:/conf/report-spam.sieve
imapsieve_mailbox2_name = *
imapsieve_mailbox2_from = Junk
imapsieve_mailbox2_causes = COPY
imapsieve_mailbox2_before = file:/var/lib/dovecot/report-ham.sieve
imapsieve_mailbox2_before = file:/conf/report-ham.sieve
}
###############

@ -1,43 +0,0 @@
connect = /data/main.db
map {
pattern = priv/spam_enabled
table = user
username_field = email
value_field = spam_enabled
}
map {
pattern = priv/spam_threshold
table = user
username_field = email
value_field = spam_threshold
}
map {
pattern = priv/reply_enabled
table = user
username_field = email
value_field = reply_enabled
}
map {
pattern = priv/reply_subject
table = user
username_field = email
value_field = reply_subject
}
map {
pattern = priv/reply_body
table = user
username_field = email
value_field = reply_body
}
map {
pattern = priv/reply_enddate
table = user
username_field = email
value_field = reply_enddate
}

@ -1,11 +1,23 @@
#!/usr/bin/python
#!/usr/bin/python3
import jinja2
import os
import socket
import glob
import multiprocessing
import tenacity
from tenacity import retry
from podop import run_server
def start_podop():
os.setuid(8)
run_server(3 if "DEBUG" in os.environ else 0, "dovecot", "/tmp/podop.socket", [
("quota", "url", "http://admin/internal/dovecot/§"),
("auth", "url", "http://admin/internal/dovecot/§"),
("sieve", "url", "http://admin/internal/dovecot/§"),
])
convert = lambda src, dst: open(dst, "w").write(jinja2.Template(open(src).read()).render(**os.environ))
@ -18,9 +30,11 @@ def resolve():
# Actual startup script
resolve()
for dovecot_file in glob.glob("/conf/*"):
for dovecot_file in glob.glob("/conf/*.conf"):
convert(dovecot_file, os.path.join("/etc/dovecot", os.path.basename(dovecot_file)))
# Run postfix
# Run Podop, then postfix
multiprocessing.Process(target=start_podop).start()
os.system("chown -R mail:mail /mail /var/lib/dovecot")
os.execv("/usr/sbin/dovecot", ["dovecot", "-c", "/etc/dovecot/dovecot.conf", "-F"])

@ -1,6 +1,9 @@
FROM alpine:3.7
FROM alpine:3.8
RUN apk add --no-cache nginx nginx-mod-mail python py-jinja2 certbot openssl
RUN apk add --no-cache certbot nginx nginx-mod-mail openssl \
python py-jinja2 py-requests-toolbelt py-pip \
&& pip install --upgrade pip \
&& pip install idna
COPY conf /conf
COPY *.py /

@ -1,5 +1,5 @@
# This is an idle image to dynamically replace any component if disabled.
FROM alpine
FROM alpine:3.8
CMD sleep 1000000d

@ -1,8 +1,9 @@
FROM alpine:3.7
FROM alpine:3.8
RUN apk add --no-cache postfix postfix-sqlite postfix-pcre rsyslog python py-jinja2 py-pip \
&& pip install --upgrade pip \
&& pip install tenacity
RUN apk add --no-cache postfix postfix-pcre rsyslog \
python3 py3-pip \
&& pip3 install --upgrade pip \
&& pip3 install jinja2 podop tenacity
COPY conf /conf
COPY start.py /start.py

@ -19,8 +19,8 @@ mynetworks = 127.0.0.1/32 [::1]/128 {{ RELAYNETS }}
# Empty alias list to override the configuration variable and disable NIS
alias_maps =
# SQLite configuration
sql = sqlite:${config_directory}/
# Podop configuration
podop = socketmap:unix:/tmp/podop.socket:
# Only accept virtual emails
mydestination =
@ -56,13 +56,14 @@ smtp_tls_session_cache_database = btree:${data_directory}/smtp_scache
# The alias map actually returns both aliases and local mailboxes, which is
# required for reject_unlisted_sender to work properly
virtual_alias_maps = ${sql}sqlite-virtual_alias_maps.cf
virtual_mailbox_domains = ${sql}sqlite-virtual_mailbox_domains.cf
virtual_mailbox_maps = $virtual_alias_maps
virtual_alias_domains =
virtual_alias_maps = ${podop}alias
virtual_mailbox_domains = ${podop}domain
virtual_mailbox_maps = ${podop}mailbox
# Mails are transported if required, then forwarded to Dovecot for delivery
relay_domains = ${sql}sqlite-transport.cf
transport_maps = ${sql}sqlite-transport.cf
relay_domains = ${podop}transport
transport_maps = ${podop}transport
virtual_transport = lmtp:inet:{{ HOST_LMTP }}
# In order to prevent Postfix from running DNS query, enforce the use of the
@ -82,15 +83,20 @@ smtpd_sender_login_maps = $virtual_alias_maps
# Restrictions for incoming SMTP, other restrictions are applied in master.cf
smtpd_helo_required = yes
smtpd_recipient_restrictions =
smtpd_client_restrictions =
permit_mynetworks,
check_sender_access ${sql}sqlite-reject-spoofed.cf,
check_sender_access ${podop}sender,
reject_non_fqdn_sender,
reject_unknown_sender_domain,
reject_unknown_recipient_domain,
reject_unverified_recipient,
permit
smtpd_relay_restrictions =
permit_mynetworks,
permit_sasl_authenticated,
reject_unauth_destination
unverified_recipient_reject_reason = Address lookup failure
###############

@ -7,7 +7,7 @@ smtp inet n - n - - smtpd
# Internal SMTP service
10025 inet n - n - - smtpd
-o smtpd_sasl_auth_enable=yes
-o smtpd_recipient_restrictions=reject_unlisted_sender,reject_authenticated_sender_login_mismatch,permit
-o smtpd_client_restrictions=reject_unlisted_sender,reject_authenticated_sender_login_mismatch,permit
-o smtpd_reject_unlisted_recipient={% if REJECT_UNLISTED_RECIPIENT %}{{ REJECT_UNLISTED_RECIPIENT }}{% else %}no{% endif %}
-o cleanup_service_name=outclean
outclean unix n - n - 0 cleanup

@ -1,5 +0,0 @@
dbpath = /data/main.db
query =
SELECT 'REJECT' FROM domain WHERE name='%s'
UNION
SELECT 'REJECT' FROM alternative WHERE name='%s'

@ -1,3 +0,0 @@
dbpath = /data/main.db
query =
SELECT 'smtp:['||smtp||']' FROM relay WHERE name='%s'

@ -1,23 +0,0 @@
dbpath = /data/main.db
query =
SELECT destination
FROM
(SELECT destination, email, wildcard, localpart, localpart||'@'||alternative.name AS alt_email FROM alias LEFT JOIN alternative ON alias.domain_name = alternative.domain_name
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, localpart||'@'||alternative.name as alt_email FROM user LEFT JOIN alternative ON user.domain_name = alternative.domain_name
UNION
SELECT '@'||domain_name as destination, '@'||name as email, 0 as wildcard, '' as localpart, NULL AS alt_email FROM alternative)
WHERE
(
wildcard = 0
AND
(email = '%s' OR alt_email = '%s')
) OR (
wildcard = 1
AND
'%s' LIKE email
)
ORDER BY
wildcard ASC,
length(localpart) DESC
LIMIT 1

@ -1,5 +0,0 @@
dbpath = /data/main.db
query =
SELECT name FROM domain WHERE name='%s'
UNION
SELECT name FROM alternative WHERE name='%s'

@ -1,4 +1,4 @@
#!/usr/bin/python
#!/usr/bin/python3
import jinja2
import os
@ -6,7 +6,21 @@ import socket
import glob
import shutil
import tenacity
import multiprocessing
from tenacity import retry
from podop import run_server
def start_podop():
os.setuid(100)
run_server(3 if "DEBUG" in os.environ else 0, "postfix", "/tmp/podop.socket", [
("transport", "url", "http://admin/internal/postfix/transport/§"),
("alias", "url", "http://admin/internal/postfix/alias/§"),
("domain", "url", "http://admin/internal/postfix/domain/§"),
("mailbox", "url", "http://admin/internal/postfix/mailbox/§"),
("sender", "url", "http://admin/internal/postfix/sender/§")
])
convert = lambda src, dst: open(dst, "w").write(jinja2.Template(open(src).read()).render(**os.environ))
@ -38,7 +52,8 @@ for map_file in glob.glob("/overrides/*.map"):
convert("/conf/rsyslog.conf", "/etc/rsyslog.conf")
# Run postfix
# Run Podop and Postfix
multiprocessing.Process(target=start_podop).start()
if os.path.exists("/var/run/rsyslogd.pid"):
os.remove("/var/run/rsyslogd.pid")
os.system("/usr/lib/postfix/post-install meta_directory=/etc/postfix create-missing")

@ -120,12 +120,18 @@ WEBSITE=https://mailu.io
# Advanced settings
###################################
# Log driver for front service. Possible values:
# json-file (default)
# journald (On systemd platforms, useful for Fail2Ban integration)
# syslog (Non systemd platforms, Fail2Ban integration. Disables `docker-compose log` for front!)
LOG_DRIVER=json-file
# Docker-compose project name, this will prepended to containers names.
COMPOSE_PROJECT_NAME=mailu
# Default password scheme used for newly created accounts and changed passwords
# (value: SHA512-CRYPT, SHA256-CRYPT, MD5-CRYPT, CRYPT)
PASSWORD_SCHEME=SHA512-CRYPT
# (value: BLF-CRYPT, SHA512-CRYPT, SHA256-CRYPT, MD5-CRYPT, CRYPT)
PASSWORD_SCHEME=BLF-CRYPT
# Header to take the real ip from
REAL_IP_HEADER=

@ -6,6 +6,8 @@ services:
image: mailu/nginx:$VERSION
restart: always
env_file: .env
logging:
driver: $LOG_DRIVER
ports:
- "$BIND_ADDRESS4:80:80"
- "$BIND_ADDRESS4:443:443"
@ -39,7 +41,6 @@ services:
restart: always
env_file: .env
volumes:
- "$ROOT/data:/data"
- "$ROOT/mail:/mail"
- "$ROOT/overrides:/overrides"
depends_on:
@ -50,7 +51,6 @@ services:
restart: always
env_file: .env
volumes:
- "$ROOT/data:/data"
- "$ROOT/overrides:/overrides"
depends_on:
- front
@ -104,5 +104,3 @@ services:
image: mailu/fetchmail:$VERSION
restart: always
env_file: .env
volumes:
- "$ROOT/data:/data"

@ -26,36 +26,61 @@ for the ``VERSION_TAG`` branch, use:
wget https://mailu.io/VERSION_TAG/_downloads/docker-compose.yml
wget https://mailu.io/VERSION_TAG/_downloads/.env
Then open the ``.env`` file to setup the mail server. Modify the ``ROOT`` setting
to match your setup directory if different from ``/mailu``.
Important configuration variables
---------------------------------
Modify the ``VERSION`` configuration in the ``.env`` file to reflect the version you picked.
Open the ``.env`` file and review the following variable settings:
Set the common configuration values
-----------------------------------
- Change ``ROOT`` if you have your setup directory in a different location then ``/mailu``.
- Check ``VERSION`` to reflect the version you picked. (``master`` or ``1.5``).
Open the ``.env`` file and set configuration settings after reading the configuration
documentation. Some settings are specific to the Docker Compose setup.
Make sure to read the comments in the file and instructions from the :ref:`common_cfg` section.
Modify ``BIND_ADDRESS4`` to match the public IP address assigned to your server.
This address should be configured on one of the network interfaces of the server.
If the address is not configured directly (NAT) on any of the network interfaces or if
you would simply like the server to listen on all interfaces, use ``0.0.0.0``.
Modify ``BIND_ADDRESS6`` to match the public IPv6 address assigned to your server.
The behavior is identical to ``BIND_ADDRESS4``.
TLS certificates
````````````````
Set the ``TLS_FLAVOR`` to one of the following
values:
- ``cert`` is the default and requires certificates to be setup manually;
- ``letsencrypt`` will use the Letsencrypt! CA to generate automatic ceriticates;
- ``letsencrypt`` will use the *Letsencrypt!* CA to generate automatic ceriticates;
- ``mail`` is similar to ``cert`` except that TLS will only be served for
emails (IMAP and SMTP), not HTTP (use it behind reverse proxies);
- ``mail-letsencrypt`` is similar to ``letsencrypt`` except that TLS will only be served for
emails (IMAP and SMTP), not HTTP (use it behind reverse proxies);
- ``notls`` will disable TLS, this is not recommended except for testing.
.. note::
When using *Letsencrypt!* you have to make sure that the DNS ``A`` and ``AAAA`` records for the
all hostnames mentioned in the ``HOSTNAMES`` variable match with the ip adresses of you server.
Or else certificate generation will fail! See also: :ref:`dns_setup`.
Bind address
````````````
Modify ``BIND_ADDRESS4`` and ``BIND_ADDRESS6`` to match the public IP addresses assigned to your server. For IPv6 you will need the ``<global>`` scope address.
You can find those addresses by running the following:
.. code-block:: bash
[root@mailu ~]$ ifconfig eth0
eth0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST> mtu 1500
inet 125.189.138.127 netmask 255.255.255.0 broadcast 5.189.138.255
inet6 fd21:aab2:717c:cc5a::1 prefixlen 64 scopeid 0x0<global>
inet6 fe2f:2a73:43a8:7a1b::1 prefixlen 64 scopeid 0x20<link>
ether 00:50:56:3c:b2:23 txqueuelen 1000 (Ethernet)
RX packets 174866612 bytes 127773819607 (118.9 GiB)
RX errors 0 dropped 0 overruns 0 frame 0
TX packets 19905110 bytes 2191519656 (2.0 GiB)
TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0
If the address is not configured directly (NAT) on any of the network interfaces or if
you would simply like the server to listen on all interfaces, use ``0.0.0.0`` and ``::``. Note that running is this mode is not supported and can lead to `issues`_.
.. _issues: https://github.com/Mailu/Mailu/issues/641
Enable optional features
------------------------

@ -1,12 +1,20 @@
Mailu configuration settings
============================
.. _common_cfg:
Common configuration
--------------------
The ``SECRET_KEY`` **must** be changed for every setup and set to a 16 bytes
randomly generated value. It is intended to secure authentication cookies
among other critical uses.
among other critical uses. This can be generated with a utility such as *pwgen*,
which can be installed on most Linux systems:
.. code-block:: bash
apt-get install pwgen
pwgen 16 1
The ``DOMAIN`` holds the main e-mail domain for the server. This email domain
is used for bounce emails, for generating the postmaster email and other

@ -5,39 +5,51 @@ Docker containers
-----------------
The development environment is quite similar to the production one. You should always use
the ``master`` version when developing. Simply add a build directive to the images
you are working on in the ``docker-compose.yml``:
the ``master`` version when developing.
.. code-block:: yaml
Building images
```````````````
webdav:
build: ./optional/radicale
image: mailu/$WEBDAV:$VERSION
restart: always
env_file: .env
volumes:
- "$ROOT/dav:/data"
admin:
build: ./core/admin
image: mailu/admin:$VERSION
restart: always
env_file: .env
volumes:
- "$ROOT/data:/data"
- "$ROOT/dkim:/dkim"
- /var/run/docker.sock:/var/run/docker.sock:ro
depends_on:
- redis
The build these containers.
We supply a separate ``test/build.yml`` file for
convenience. To build all Mailu containers:
.. code-block:: bash
docker-compose build admin webdav
docker-compose -f tests/build.yml build
Then you can simply start the stack as normal, newly-built images will be used.
The ``build.yml`` file has two variables:
#. ``$DOCKER_ORG``: First part of the image tag. Defaults to *mailu* and needs to be changed
only when pushing to your own Docker hub account.
#. ``$VERSION``: Last part of the image tag. Defaults to *local* to differentiate from pulled
images.
To re-build only specific containers at a later time.
.. code-block:: bash
docker-compose -f tests/build.yml build admin webdav
If you have to push the images to Docker Hub for testing in Docker Swarm or a remote
host, you have to define ``DOCKER_ORG`` (usually your Docker user-name) and login to
the hub.
.. code-block:: bash
docker login
Username: Foo
Password: Bar
export DOCKER_ORG="Foo"
export VERSION="feat-extra-app"
docker-compose -f tests/build.yml build
docker-compose -f tests/build.yml push
Running containers
``````````````````
To run the newly created images: ``cd`` to your project directory. Edit ``.env`` to set
``VERSION`` to the same value as used during the build, which defaults to ``local``.
After that you can run:
.. code-block:: bash

@ -1,3 +1,5 @@
.. _dns_setup:
Setting up your DNS
===================

@ -6,6 +6,7 @@ One of Mailu use cases is as part of a larger services platform, where maybe oth
In such a configuration, one would usually run a frontend reverse proxy to serve all Web contents based on criteria like the requested hostname (virtual hosts) and/or the requested path. Mailu Web frontend is disabled in the default setup for security reasons, it is however expected that most users will enable it at some point. Also, due to Docker Compose configuration structure, it is impossible for us to make disabling the Web frontend completely available through a configuration variable. This guide was written to help users setup such an architecture.
There are basically three options, from the most to the least recommended one:
- have Mailu Web frontend listen locally and use your own Web frontend on top of it
- override Mailu Web frontend configuration
- disable Mailu Web frontend completely and use your own

@ -32,7 +32,7 @@ user. Make sure you complete the requirements for the flavor you chose.
You should also have at least a DNS hostname and a DNS name for receiving
emails. Some instructions are provided on the matter in the article
[Setup your DNS](dns).
:ref:`dns_setup`.
.. _`MFAshby's fork`: https://github.com/MFAshby/Mailu
@ -68,10 +68,9 @@ Make sure that you test properly before going live!
- Try to receive an email from an external service
- Check the logs (``docker-compose logs -f servicenamehere``) to look for
warnings or errors
- Use an open relay checker like `mailradar`_
- Use an open relay checker like `mxtoolbox`_
to ensure you're not contributing to the spam problem on the internet.
All tests there should result in "Relay denied".
- If using DMARC, be sure to check the reports you get to verify that legitimate
email is getting through and forgeries are being properly blocked.
.. _mailradar: http://www.mailradar.com/openrelay/
.. _mxtoolbox: https://mxtoolbox.com/diagnostic.aspx

@ -1,4 +1,4 @@
FROM alpine:edge
FROM alpine:3.8
RUN apk add --no-cache clamav rsyslog wget clamav-libunrar

@ -1,7 +1,9 @@
FROM python:alpine
FROM python:3-alpine
RUN apk add --no-cache fetchmail ca-certificates
RUN apk add --no-cache fetchmail ca-certificates \
&& pip install requests
COPY fetchmail.py /fetchmail.py
USER fetchmail
CMD ["/fetchmail.py"]

@ -1,12 +1,12 @@
#!/usr/bin/env python
import sqlite3
import time
import os
import tempfile
import shlex
import subprocess
import re
import requests
FETCHMAIL = """
@ -15,6 +15,7 @@ fetchmail -N \
-f {}
"""
RC_LINE = """
poll "{host}" proto {protocol} port {port}
user "{username}" password "{password}"
@ -24,10 +25,12 @@ poll "{host}" proto {protocol} port {port}
sslproto 'AUTO'
"""
def extract_host_port(host_and_port, default_port):
host, _, port = re.match('^(.*)(:([0-9]*))?$', host_and_port).groups()
return host, int(port) if port else default_port
def escape_rc_string(arg):
return arg.replace("\\", "\\\\").replace('"', '\\"')
@ -41,30 +44,26 @@ def fetchmail(fetchmailrc):
return output
def run(connection, cursor, debug):
cursor.execute("""
SELECT user_email, protocol, host, port, tls, username, password, keep
FROM fetch
""")
def run(debug):
fetches = requests.get("http://admin/internal/fetch").json()
smtphost, smtpport = extract_host_port(os.environ.get("HOST_SMTP", "smtp"), None)
if smtpport is None:
smtphostport = smtphost
else:
smtphostport = "%s/%d" % (smtphost, smtpport)
for line in cursor.fetchall():
for fetch in fetches:
fetchmailrc = ""
user_email, protocol, host, port, tls, username, password, keep = line
options = "options antispam 501, 504, 550, 553, 554"
options += " ssl" if tls else ""
options += " keep" if keep else " fetchall"
options += " ssl" if fetch["tls"] else ""
options += " keep" if fetch["keep"] else " fetchall"
fetchmailrc += RC_LINE.format(
user_email=escape_rc_string(user_email),
protocol=protocol,
host=escape_rc_string(host),
port=port,
user_email=escape_rc_string(fetch["user_email"]),
protocol=fetch["protocol"],
host=escape_rc_string(fetch["host"]),
port=fetch["port"],
smtphost=smtphostport,
username=escape_rc_string(username),
password=escape_rc_string(password),
username=escape_rc_string(fetch["username"]),
password=escape_rc_string(fetch["password"]),
options=options
)
if debug:
@ -77,26 +76,20 @@ def run(connection, cursor, debug):
# No mail is not an error
if not error_message.startswith("fetchmail: No mail"):
print(error_message)
user_info = "for %s at %s" % (user_email, host)
user_info = "for %s at %s" % (fetch["user_email"], fetch["host"])
# Number of messages seen is not a error as well
if ("messages" in error_message and
"(seen " in error_message and
user_info in error_message):
print(error_message)
finally:
cursor.execute("""
UPDATE fetch SET error=?, last_check=datetime('now')
WHERE user_email=?
""", (error_message.split("\n")[0], user_email))
connection.commit()
requests.post("http://admin/internal/fetch/{}".format(fetch["id"]),
json=error_message.split("\n")[0]
)
if __name__ == "__main__":
debug = os.environ.get("DEBUG", None) == "True"
db_path = os.environ.get("DB_PATH", "/data/main.db")
connection = sqlite3.connect(db_path)
while True:
cursor = connection.cursor()
run(connection, cursor, debug)
cursor.close()
time.sleep(int(os.environ.get("FETCHMAIL_DELAY", 60)))
run(os.environ.get("DEBUG", None) == "True")

@ -1,4 +1,4 @@
FROM alpine:edge
FROM alpine:3.8
RUN apk add --no-cache python py-jinja2 rspamd rspamd-controller rspamd-proxy rspamd-fuzzy ca-certificates py-pip \
&& pip install --upgrade pip \

@ -3,54 +3,54 @@ version: '3'
services:
front:
image: $DOCKER_ORG/nginx:$VERSION
image: ${DOCKER_ORG:-mailu}/nginx:${VERSION:-local}
build: ../core/nginx
imap:
image: $DOCKER_ORG/dovecot:$VERSION
image: ${DOCKER_ORG:-mailu}/dovecot:${VERSION:-local}
build: ../core/dovecot
smtp:
image: $DOCKER_ORG/postfix:$VERSION
image: ${DOCKER_ORG:-mailu}/postfix:${VERSION:-local}
build: ../core/postfix
antispam:
image: $DOCKER_ORG/rspamd:$VERSION
image: ${DOCKER_ORG:-mailu}/rspamd:${VERSION:-local}
build: ../services/rspamd
antivirus:
image: $DOCKER_ORG/clamav:$VERSION
image: ${DOCKER_ORG:-mailu}/clamav:${VERSION:-local}
build: ../optional/clamav
webdav:
image: $DOCKER_ORG/radicale:$VERSION
image: ${DOCKER_ORG:-mailu}/radicale:${VERSION:-local}
build: ../optional/radicale
admin:
image: $DOCKER_ORG/admin:$VERSION
image: ${DOCKER_ORG:-mailu}/admin:${VERSION:-local}
build: ../core/admin
roundcube:
image: $DOCKER_ORG/roundcube:$VERSION
image: ${DOCKER_ORG:-mailu}/roundcube:${VERSION:-local}
build: ../webmails/roundcube
rainloop:
image: $DOCKER_ORG/rainloop:$VERSION
image: ${DOCKER_ORG:-mailu}/rainloop:${VERSION:-local}
build: ../webmails/rainloop
fetchmail:
image: $DOCKER_ORG/fetchmail:$VERSION
image: ${DOCKER_ORG:-mailu}/fetchmail:${VERSION:-local}
build: ../services/fetchmail
none:
image: $DOCKER_ORG/none:$VERSION
image: ${DOCKER_ORG:-mailu}/none:${VERSION:-local}
build: ../core/none
docs:
image: $DOCKER_ORG/docs:$VERSION
image: ${DOCKER_ORG:-mailu}/docs:${VERSION:-local}
build: ../docs
setup:
image: $DOCKER_ORG/setup:$VERSION
image: ${DOCKER_ORG:-mailu}/setup:${VERSION:-local}
build: ../setup

@ -120,6 +120,12 @@ WEBSITE=https://mailu.io
# Advanced settings
###################################
# Log driver for front service. Possible values:
# json-file (default)
# journald (On systemd platforms, useful for Fail2Ban integration)
# syslog (Non systemd platforms, Fail2Ban integration. Disables `docker-compose log` for front!)
LOG_DRIVER=json-file
# Docker-compose project name, this will prepended to containers names.
#COMPOSE_PROJECT_NAME=mailu

@ -6,6 +6,8 @@ services:
image: $DOCKER_ORG/nginx:$VERSION
restart: 'no'
env_file: $PWD/.env
logging:
driver: $LOG_DRIVER
ports:
- "$BIND_ADDRESS4:80:80"
- "$BIND_ADDRESS4:443:443"

@ -0,0 +1,17 @@
import smtplib
import sys
from email import mime
from email.mime.image import MIMEImage
from email.mime.multipart import MIMEMultipart
msg = mime.multipart.MIMEMultipart()
msg['Subject'] = 'Test email'
msg['From'] = sys.argv[1]
msg['To'] = sys.argv[2]
msg.preamble = 'Test email'
s = smtplib.SMTP('localhost')
s.set_debuglevel(1)
s.send_message(msg)
s.quit()

@ -1,20 +1,21 @@
FROM php:5-apache
RUN apt-get update && apt-get install -y \
unzip python3 python3-jinja2
FROM php:7.2-apache
ENV RAINLOOP_URL https://github.com/RainLoop/rainloop-webmail/releases/download/v1.12.1/rainloop-community-1.12.1.zip
RUN rm -rf /var/www/html/ \
RUN apt-get update && apt-get install -y \
unzip python3 python3-jinja2 \
&& rm -rf /var/www/html/ \
&& mkdir /var/www/html \
&& cd /var/www/html \
&& curl -L -O ${RAINLOOP_URL} \
&& unzip *.zip \
&& unzip -q *.zip \
&& rm -f *.zip \
&& rm -rf data/ \
&& find . -type d -exec chmod 755 {} \; \
&& find . -type f -exec chmod 644 {} \; \
&& chown -R www-data: *
&& chown -R www-data: * \
&& apt-get purge -y unzip \
&& rm -rf /var/lib/apt/lists
COPY include.php /var/www/html/include.php
COPY php.ini /usr/local/etc/php/conf.d/rainloop.ini

@ -18,4 +18,7 @@ os.makedirs(base + "configs", exist_ok=True)
convert("/default.ini", "/data/_data_/_default_/domains/default.ini")
convert("/config.ini", "/data/_data_/_default_/configs/config.ini")
os.system("chown -R www-data:www-data /data")
os.execv("/usr/local/bin/apache2-foreground", ["apache2-foreground"])

@ -1,14 +1,12 @@
FROM php:7.2-apache
RUN apt-get update && apt-get install -y \
zlib1g-dev \
&& docker-php-ext-install zip
ENV ROUNDCUBE_URL https://github.com/roundcube/roundcubemail/releases/download/1.3.7/roundcubemail-1.3.7-complete.tar.gz
RUN echo date.timezone=UTC > /usr/local/etc/php/conf.d/timezone.ini
RUN rm -rf /var/www/html/ \
RUN apt-get update && apt-get install -y \
zlib1g-dev \
&& docker-php-ext-install zip \
&& echo date.timezone=UTC > /usr/local/etc/php/conf.d/timezone.ini \
&& rm -rf /var/www/html/ \
&& cd /var/www \
&& curl -L -O ${ROUNDCUBE_URL} \
&& tar -xf *.tar.gz \
@ -17,7 +15,8 @@ RUN rm -rf /var/www/html/ \
&& cd html \
&& rm -rf CHANGELOG INSTALL LICENSE README.md UPGRADING composer.json-dist installer \
&& sed -i 's,mod_php5.c,mod_php7.c,g' .htaccess \
&& chown -R www-data: logs temp
&& chown -R www-data: logs temp \
&& rm -rf /var/lib/apt/lists
COPY php.ini /usr/local/etc/php/conf.d/roundcube.ini

Loading…
Cancel
Save