diff --git a/.gitignore b/.gitignore
index 84ee07d3..845a97ee 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,6 +9,7 @@ pip-selfcheck.json
/docs/lib*
/docs/bin
/docs/include
+/docs/contributors/mailu-network-diagram.svg
/docs/_build
/.env
/.venv
diff --git a/core/admin/Dockerfile b/core/admin/Dockerfile
index 1e54dec3..c73bbd85 100644
--- a/core/admin/Dockerfile
+++ b/core/admin/Dockerfile
@@ -23,7 +23,7 @@ RUN set -euxo pipefail \
RUN echo $VERSION >/version
#EXPOSE 8080/tcp
-HEALTHCHECK CMD curl -skfLo /dev/null http://localhost:8080/ping
+HEALTHCHECK CMD curl -m3 -skfLo /dev/null http://localhost:8080/ping
VOLUME ["/data","/dkim"]
diff --git a/core/admin/assets/assets/app.js b/core/admin/assets/assets/app.js
index 33f63433..12baec4c 100644
--- a/core/admin/assets/assets/app.js
+++ b/core/admin/assets/assets/app.js
@@ -21,6 +21,9 @@ function sha1(string) {
}
function hibpCheck(pwd) {
+ if (pwd === null || pwd === undefined || pwd.length === 0) {
+ return;
+ }
// We hash the pwd first
sha1(pwd).then(function(hash){
// We send the first 5 chars of the hash to hibp's API
diff --git a/core/admin/mailu/internal/nginx.py b/core/admin/mailu/internal/nginx.py
index ebd677d0..b9cbe879 100644
--- a/core/admin/mailu/internal/nginx.py
+++ b/core/admin/mailu/internal/nginx.py
@@ -18,7 +18,11 @@ STATUSES = {
"sieve": "AuthFailed"
}),
"encryption": ("Must issue a STARTTLS command first", {
- "smtp": "530 5.7.0"
+ "imap": "PRIVACYREQUIRED",
+ "smtp": "530 5.7.0",
+ "submission": "530 5.7.0",
+ "pop3": "-ERR Authentication canceled.",
+ "sieve": "ENCRYPT-NEEDED"
}),
"ratelimit": ("Temporary authentication failure (rate-limit)", {
"imap": "LIMIT",
@@ -68,7 +72,7 @@ def handle_authentication(headers):
# Incoming mail, no authentication
if method in ['', 'none'] and protocol in ['smtp', 'lmtp']:
server, port = get_server(protocol, False)
- if app.config["INBOUND_TLS_ENFORCE"]:
+ if app.config["INBOUND_TLS_ENFORCE"] and protocol == 'smtp':
if "Auth-SSL" in headers and headers["Auth-SSL"] == "on":
return {
"Auth-Status": "OK",
@@ -91,20 +95,14 @@ def handle_authentication(headers):
# Authenticated user
elif method in ['plain', 'login']:
is_valid_user = False
- # According to RFC2616 section 3.7.1 and PEP 3333, HTTP headers should
- # be ASCII and are generally considered ISO8859-1. However when passing
- # the password, nginx does not transcode the input UTF string, thus
- # we need to manually decode.
- raw_user_email = urllib.parse.unquote(headers["Auth-User"])
- raw_password = urllib.parse.unquote(headers["Auth-Pass"])
user_email = 'invalid'
password = 'invalid'
try:
- user_email = raw_user_email.encode("iso8859-1").decode("utf8")
- password = raw_password.encode("iso8859-1").decode("utf8")
+ user_email = urllib.parse.unquote(headers["Auth-User"])
+ password = urllib.parse.unquote(headers["Auth-Pass"])
ip = urllib.parse.unquote(headers["Client-Ip"])
except:
- app.logger.warn(f'Received undecodable user/password from nginx: {raw_user_email!r}/{raw_password!r}')
+ app.logger.warn(f'Received undecodable user/password from front: {headers.get("Auth-User", "")!r}')
else:
try:
user = models.User.query.get(user_email) if '@' in user_email else None
diff --git a/core/admin/mailu/internal/views/auth.py b/core/admin/mailu/internal/views/auth.py
index 4aa31407..c74bcc9e 100644
--- a/core/admin/mailu/internal/views/auth.py
+++ b/core/admin/mailu/internal/views/auth.py
@@ -29,7 +29,6 @@ def nginx_authentication():
response.headers['Auth-Status'] = status
response.headers['Auth-Error-Code'] = code
return response
- raw_password = urllib.parse.unquote(headers['Auth-Pass']) if 'Auth-Pass' in headers else ''
headers = nginx.handle_authentication(flask.request.headers)
response = flask.Response()
for key, value in headers.items():
@@ -50,14 +49,8 @@ def nginx_authentication():
if not is_port_25:
utils.limiter.exempt_ip_from_ratelimits(client_ip)
elif is_valid_user:
- password = None
- try:
- password = raw_password.encode("iso8859-1").decode("utf8")
- except:
- app.logger.warn(f'Received undecodable password for {username} from nginx: {raw_password!r}')
- utils.limiter.rate_limit_user(username, client_ip, password=None)
- else:
- utils.limiter.rate_limit_user(username, client_ip, password=password)
+ password = urllib.parse.unquote(headers.get('Auth-Pass', ''))
+ utils.limiter.rate_limit_user(username, client_ip, password=password)
elif not is_from_webmail:
utils.limiter.rate_limit_ip(client_ip, username)
return response
diff --git a/core/admin/mailu/models.py b/core/admin/mailu/models.py
index 0896bc88..88b8bd46 100644
--- a/core/admin/mailu/models.py
+++ b/core/admin/mailu/models.py
@@ -279,7 +279,7 @@ class Domain(Base):
f'_{proto}._tcp.{self.name}. 600 IN SRV {prio} 1 {port} {hostname}.' if port in ports else f'_{proto}._tcp.{self.name}. 600 IN SRV 0 0 0 .'
for proto, port, prio
in protocols
- ]+[f'autoconfig.{self.name}. 600 IN CNAME {hostname}.']
+ ]+[f'autoconfig.{self.name}. 600 IN CNAME {hostname}.', f'autodiscover.{self.name}. 600 IN CNAME {hostname}.']
@cached_property
def dns_tlsa(self):
@@ -680,7 +680,7 @@ in clear-text regardless of the presence of the cache.
set() containing the sessions to keep
"""
self.password = password if raw else User.get_password_context().hash(password)
- if keep_sessions is not True:
+ if keep_sessions is not True and self.email is not None:
utils.MailuSessionExtension.prune_sessions(uid=self.email, keep=keep_sessions)
def get_managed_domains(self):
diff --git a/core/admin/mailu/ui/views/users.py b/core/admin/mailu/ui/views/users.py
index 569730c8..57867cb9 100644
--- a/core/admin/mailu/ui/views/users.py
+++ b/core/admin/mailu/ui/views/users.py
@@ -24,7 +24,7 @@ def user_create(domain_name):
flask.url_for('.user_list', domain_name=domain.name))
form = forms.UserForm()
form.pw.validators = [wtforms.validators.DataRequired()]
- form.quota_bytes.default = app.config['DEFAULT_QUOTA']
+ form.quota_bytes.default = int(app.config['DEFAULT_QUOTA'])
if domain.max_quota_bytes:
form.quota_bytes.validators = [
wtforms.validators.NumberRange(max=domain.max_quota_bytes)]
@@ -93,12 +93,12 @@ def user_settings(user_email):
form = forms.UserSettingsForm(obj=user)
utils.formatCSVField(form.forward_destination)
if form.validate_on_submit():
- if form.forward_enabled.data and (form.forward_destination.data in ['', None] or type(form.forward_destination.data) is list):
+ user.forward_enabled = bool(flask.request.form.get('forward_enabled', False))
+ if user.forward_enabled and not form.forward_destination.data:
flask.flash('Destination email address is missing', 'error')
- user.forward_enabled = True
- return flask.render_template('user/settings.html', form=form, user=user)
- if form.forward_enabled.data:
- form.forward_destination.data = form.forward_destination.data.replace(" ","").split(",")
+ return flask.redirect(
+ flask.url_for('.user_settings', user_email=user_email))
+ 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)
@@ -107,8 +107,9 @@ def user_settings(user_email):
return flask.redirect(
flask.url_for('.user_list', domain_name=user.domain.name))
elif form.is_submitted() and not form.validate():
- user.forward_enabled = form.forward_enabled.data
- return flask.render_template('user/settings.html', form=form, user=user)
+ flask.flash('Error validating the form', 'error')
+ return flask.redirect(
+ flask.url_for('.user_settings', user_email=user_email))
return flask.render_template('user/settings.html', form=form, user=user)
def _process_password_change(form, user_email):
diff --git a/core/admin/mailu/utils.py b/core/admin/mailu/utils.py
index f248dd56..f23cc50d 100644
--- a/core/admin/mailu/utils.py
+++ b/core/admin/mailu/utils.py
@@ -698,6 +698,7 @@ def isBadOrPwned(form):
def formatCSVField(field):
if not field.data:
+ field.data = ''
return
if isinstance(field.data,str):
data = field.data.replace(" ","").split(",")
diff --git a/core/admin/start.py b/core/admin/start.py
index 9574bbb7..24f31e07 100755
--- a/core/admin/start.py
+++ b/core/admin/start.py
@@ -64,7 +64,7 @@ test_unsupported()
cmdline = [
"gunicorn",
- "--threads", f"{os.cpu_count()}",
+ "--threads", os.environ.get('CPU_COUNT', '1'),
# If SUBNET6 is defined, gunicorn must listen on IPv6 as well as IPv4
"-b", f"{'[::]' if os.environ.get('SUBNET6') else '0.0.0.0'}:8080",
"--logger-class mailu.Logger",
diff --git a/core/base/Dockerfile b/core/base/Dockerfile
index 39d9f29c..5e3ad092 100644
--- a/core/base/Dockerfile
+++ b/core/base/Dockerfile
@@ -3,7 +3,7 @@
# base system image (intermediate)
# Note when updating the alpine tag, first manually run the workflow .github/workflows/mirror.yml.
# Just run the workflow with the tag that must be synchronised.
-ARG DISTRO=ghcr.io/mailu/alpine:3.20
+ARG DISTRO=ghcr.io/mailu/alpine:3.20.3
FROM $DISTRO as system
ENV TZ=Etc/UTC LANG=C.UTF-8
diff --git a/core/base/libs/socrate/socrate/system.py b/core/base/libs/socrate/socrate/system.py
index fe233a5b..d92caf0c 100644
--- a/core/base/libs/socrate/socrate/system.py
+++ b/core/base/libs/socrate/socrate/system.py
@@ -31,30 +31,29 @@ def _coerce_value(value):
class LogFilter(object):
def __init__(self, stream, re_patterns):
- self.stream = stream
- if isinstance(re_patterns, list):
- self.pattern = re.compile('|'.join([fr'(?:{pattern})' for pattern in re_patterns]))
- elif isinstance(re_patterns, str):
- self.pattern = re.compile(re_patterns)
- else:
- self.pattern = re_patterns
- self.found = False
+ self.stream = stream
+ self.pattern = re.compile(b'|'.join([b''.join([b'(?:', pattern, b')']) for pattern in re_patterns]))
+ self.buffer = b''
def __getattr__(self, attr_name):
return getattr(self.stream, attr_name)
def write(self, data):
- if data == '\n' and self.found:
- self.found = False
- else:
- if not self.pattern.search(data):
- self.stream.write(data)
+ if type(data) is str:
+ data = data.encode('utf-8')
+ self.buffer += data
+ while b'\n' in self.buffer:
+ line, cr, rest = self.buffer.partition(b'\n')
+ if not self.pattern.search(line):
+ self.stream.buffer.write(line)
+ self.stream.buffer.write(cr)
self.stream.flush()
- else:
- # caught bad pattern
- self.found = True
+ self.buffer = rest
def flush(self):
+ # write out buffer on flush even if it's not a complete line
+ if self.buffer and not self.pattern.search(self.buffer):
+ self.stream.buffer.write(self.buffer)
self.stream.flush()
def _is_compatible_with_hardened_malloc():
@@ -100,7 +99,7 @@ def set_env(required_secrets=[], log_filters=[]):
for secret in required_secrets:
os.environ[f'{secret}_KEY'] = hmac.new(bytearray(secret_key, 'utf-8'), bytearray(secret, 'utf-8'), 'sha256').hexdigest()
- os.system('find /run -xdev -type f -name \*.pid -print -delete')
+ os.system(r'find /run -xdev -type f -name \*.pid -print -delete')
return {
key: _coerce_value(os.environ.get(key, value))
@@ -108,8 +107,41 @@ def set_env(required_secrets=[], log_filters=[]):
}
def clean_env():
- """ remove all secret keys """
+ """ remove all secret keys, normalize PROXY_PROTOCOL """
[os.environ.pop(key, None) for key in os.environ.keys() if key.endswith("_KEY")]
+ # Configure PROXY_PROTOCOL
+ PROTO_MAIL=['25', '110', '995', '143', '993', '587', '465', '4190']
+ PROTO_ALL_BUT_HTTP=PROTO_MAIL.copy()
+ PROTO_ALL_BUT_HTTP.extend(['443'])
+ PROTO_ALL=PROTO_ALL_BUT_HTTP.copy()
+ PROTO_ALL.extend(['80'])
+ for item in os.environ.get('PROXY_PROTOCOL', '').split(','):
+ if item.isdigit():
+ os.environ[f'PROXY_PROTOCOL_{item}']='True'
+ elif item == 'mail':
+ for p in PROTO_MAIL: os.environ[f'PROXY_PROTOCOL_{p}']='True'
+ elif item == 'all-but-http':
+ for p in PROTO_ALL_BUT_HTTP: os.environ[f'PROXY_PROTOCOL_{p}']='True'
+ elif item == 'all':
+ for p in PROTO_ALL: os.environ[f'PROXY_PROTOCOL_{p}']='True'
+ elif item == '':
+ pass
+ else:
+ log.error(f'Not sure what to do with {item} in PROXY_PROTOCOL ({args.get("PROXY_PROTOCOL")})')
+
+ PORTS_REQUIRING_TLS=['443', '465', '993', '995']
+ ALL_PORTS='25,80,443,465,993,995,4190'
+ for item in os.environ.get('PORTS', ALL_PORTS).split(','):
+ if item in PORTS_REQUIRING_TLS and os.environ.get('TLS_FLAVOR','') == 'notls':
+ continue
+ os.environ[f'PORT_{item}']='True'
+
+ if os.environ.get('TLS_FLAVOR', '') != 'notls':
+ for item in os.environ.get('TLS', ALL_PORTS).split(','):
+ if item in PORTS_REQUIRING_TLS:
+ os.environ[f'TLS_{item}']='True'
+ if 'CPU_COUNT' not in os.environ:
+ os.environ['CPU_COUNT'] = str(os.cpu_count())
def drop_privs_to(username='mailu'):
pwnam = getpwnam(username)
@@ -127,7 +159,7 @@ def forward_text_lines(src, dst):
# runs a process and passes its standard/error output to the standard/error output of the current python script
def run_process_and_forward_output(cmd):
- process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
+ process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
stdout_thread = threading.Thread(target=forward_text_lines, args=(process.stdout, sys.stdout))
stdout_thread.daemon = True
@@ -137,4 +169,7 @@ def run_process_and_forward_output(cmd):
stderr_thread.daemon = True
stderr_thread.start()
- process.wait()
+ rc = process.wait()
+ sys.stdout.flush()
+ sys.stderr.flush()
+ return rc
diff --git a/core/dovecot/conf/dovecot.conf b/core/dovecot/conf/dovecot.conf
index 9c92aca6..e16ba8a6 100644
--- a/core/dovecot/conf/dovecot.conf
+++ b/core/dovecot/conf/dovecot.conf
@@ -137,12 +137,22 @@ service imap-login {
inet_listener imap {
port = 143
}
+ service_count = 0
+ client_limit = 25000
+ process_min_avail = {{ CPU_COUNT }}
+ process_limit = {{ CPU_COUNT }}
+ vsz_limit = 256M
}
service pop3-login {
inet_listener pop3 {
port = 110
}
+ service_count = 0
+ client_limit = 25000
+ process_min_avail = {{ CPU_COUNT }}
+ process_limit = {{ CPU_COUNT }}
+ vsz_limit = 256M
}
###############
@@ -166,6 +176,11 @@ service managesieve-login {
inet_listener sieve {
port = 4190
}
+ service_count = 0
+ client_limit = 25000
+ process_min_avail = {{ CPU_COUNT }}
+ process_limit = {{ CPU_COUNT }}
+ vsz_limit = 256M
}
protocol sieve {
diff --git a/core/dovecot/start.py b/core/dovecot/start.py
index b162db95..83afa1de 100755
--- a/core/dovecot/start.py
+++ b/core/dovecot/start.py
@@ -7,7 +7,9 @@ import multiprocessing
from podop import run_server
from socrate import system, conf
-system.set_env(log_filters=[r'Error\: SSL context initialization failed, disabling SSL\: Can\'t load SSL certificate \(ssl_cert setting\)\: The certificate is empty$'])
+system.set_env(log_filters=[
+ rb'Error\: SSL context initialization failed, disabling SSL\: Can\'t load SSL certificate \(ssl_cert setting\)\: The certificate is empty$'
+])
def start_podop():
system.drop_privs_to('mail')
diff --git a/core/nginx/Dockerfile b/core/nginx/Dockerfile
index cacb6c99..41992f30 100644
--- a/core/nginx/Dockerfile
+++ b/core/nginx/Dockerfile
@@ -29,7 +29,7 @@ RUN echo $VERSION >/version
EXPOSE 80/tcp 443/tcp 110/tcp 143/tcp 465/tcp 587/tcp 993/tcp 995/tcp 25/tcp 4190/tcp
# EXPOSE 10025/tcp 10143/tcp 14190/tcp
-HEALTHCHECK --start-period=60s CMD curl -skfLo /dev/null http://127.0.0.1:10204/health && kill -0 `cat /run/dovecot/master.pid`
+HEALTHCHECK --start-period=60s CMD curl -m3 -skfLo /dev/null http://127.0.0.1:10204/health && kill -0 `cat /run/dovecot/master.pid`
VOLUME ["/certs", "/overrides"]
diff --git a/core/nginx/certwatcher.py b/core/nginx/certwatcher.py
index 0e83666e..48d128ed 100755
--- a/core/nginx/certwatcher.py
+++ b/core/nginx/certwatcher.py
@@ -27,9 +27,9 @@ class ChangeHandler(FileSystemEventHandler):
if exists("/var/run/nginx.pid"):
print("Reloading a running nginx")
system("nginx -s reload")
- if os.path.exists("/run/dovecot/master.pid"):
+ if exists("/run/dovecot/master.pid"):
print("Reloading a running dovecot")
- os.system("doveadm reload")
+ system("doveadm reload")
@staticmethod
def reexec_config():
diff --git a/core/nginx/conf/nginx.conf b/core/nginx/conf/nginx.conf
index 97a32113..2dba0130 100644
--- a/core/nginx/conf/nginx.conf
+++ b/core/nginx/conf/nginx.conf
@@ -66,6 +66,9 @@ http {
listen [::]:80{% if PROXY_PROTOCOL_80 %} proxy_protocol{% endif %};
{% endif %}
{% if TLS_FLAVOR in ['letsencrypt', 'mail-letsencrypt'] %}
+ location ^~ /.well-known/acme-challenge/testing {
+ return 204;
+ }
location ^~ /.well-known/acme-challenge/ {
proxy_pass http://127.0.0.1:8008;
}
@@ -95,6 +98,7 @@ http {
set $webdav {{ WEBDAV_ADDRESS }}:5232;
{% endif %}
client_max_body_size {{ MESSAGE_SIZE_LIMIT|int + 8388608 }};
+ http2 on;
# Listen on HTTP only in kubernetes or behind reverse proxy
{% if TLS_FLAVOR in [ 'mail-letsencrypt', 'notls', 'mail' ] %}
@@ -109,7 +113,6 @@ http {
listen 443 ssl{% if PROXY_PROTOCOL_443 %} proxy_protocol{% endif %};
{% if SUBNET6 %}
listen [::]:443 ssl{% if PROXY_PROTOCOL_443 %} proxy_protocol{% endif %};
- http2 on;
{% endif %}
include /etc/nginx/tls.conf;
@@ -159,6 +162,9 @@ http {
}
{% if TLS_FLAVOR in ['letsencrypt', 'mail-letsencrypt'] %}
+ location ^~ /.well-known/acme-challenge/testing {
+ return 204;
+ }
location ^~ /.well-known/acme-challenge/ {
proxy_pass http://127.0.0.1:8008;
}
diff --git a/core/nginx/config.py b/core/nginx/config.py
index 4a381c2c..96812dba 100755
--- a/core/nginx/config.py
+++ b/core/nginx/config.py
@@ -70,38 +70,6 @@ with open("/etc/resolv.conf") as handle:
resolver = content[content.index("nameserver") + 1]
args["RESOLVER"] = f"[{resolver}]" if ":" in resolver else resolver
-# Configure PROXY_PROTOCOL
-PROTO_MAIL=['25', '110', '995', '143', '993', '587', '465', '4190']
-PROTO_ALL_BUT_HTTP=PROTO_MAIL.copy()
-PROTO_ALL_BUT_HTTP.extend(['443'])
-PROTO_ALL=PROTO_ALL_BUT_HTTP.copy()
-PROTO_ALL.extend(['80'])
-for item in args.get('PROXY_PROTOCOL', '').split(','):
- if item.isdigit():
- args[f'PROXY_PROTOCOL_{item}']=True
- elif item == 'mail':
- for p in PROTO_MAIL: args[f'PROXY_PROTOCOL_{p}']=True
- elif item == 'all-but-http':
- for p in PROTO_ALL_BUT_HTTP: args[f'PROXY_PROTOCOL_{p}']=True
- elif item == 'all':
- for p in PROTO_ALL: args[f'PROXY_PROTOCOL_{p}']=True
- elif item == '':
- pass
- else:
- log.error(f'Not sure what to do with {item} in PROXY_PROTOCOL ({args.get("PROXY_PROTOCOL")})')
-
-PORTS_REQUIRING_TLS=['443', '465', '993', '995']
-ALL_PORTS='25,80,443,465,993,995,4190'
-for item in args.get('PORTS', ALL_PORTS).split(','):
- if item in PORTS_REQUIRING_TLS and args['TLS_FLAVOR'] == 'notls':
- continue
- args[f'PORT_{item}']=True
-
-if args['TLS_FLAVOR'] != 'notls':
- for item in args.get('TLS', ALL_PORTS).split(','):
- if item in PORTS_REQUIRING_TLS:
- args[f'TLS_{item}']=True
-
# TLS configuration
cert_name = args.get("TLS_CERT_FILENAME", "cert.pem")
keypair_name = args.get("TLS_KEYPAIR_FILENAME", "key.pem")
diff --git a/core/nginx/dovecot/login.lua b/core/nginx/dovecot/login.lua
index d24de149..442833f1 100644
--- a/core/nginx/dovecot/login.lua
+++ b/core/nginx/dovecot/login.lua
@@ -10,18 +10,28 @@ local http_client = dovecot.http.client {
max_attempts = 3;
}
+-- on the other end we use urllib.parse.unquote()
+function urlEncode(str)
+ return str:gsub("[^%w_.-~]", function(c)
+ return string.format("%%%02X", string.byte(c))
+ end)
+end
+
function auth_passdb_lookup(req)
local auth_request = http_client:request {
url = "http://{{ ADMIN_ADDRESS }}:8080/internal/auth/email";
}
auth_request:add_header('Auth-Port', req.local_port)
- auth_request:add_header('Auth-User', req.user)
+ local user = urlEncode(req.user)
+ auth_request:add_header('Auth-User', user)
if req.password ~= nil
then
- auth_request:add_header('Auth-Pass', req.password)
+ local password = urlEncode(req.password)
+ auth_request:add_header('Auth-Pass', password)
end
auth_request:add_header('Auth-Protocol', req.service)
- auth_request:add_header('Client-IP', req.remote_ip)
+ local client_ip = urlEncode(req.remote_ip)
+ auth_request:add_header('Client-Ip', client_ip)
auth_request:add_header('Client-Port', req.remote_port)
auth_request:add_header('Auth-SSL', req.secured)
auth_request:add_header('Auth-Method', req.mechanism)
diff --git a/core/nginx/dovecot/proxy.conf b/core/nginx/dovecot/proxy.conf
index 8decfc77..bd3d1d6a 100644
--- a/core/nginx/dovecot/proxy.conf
+++ b/core/nginx/dovecot/proxy.conf
@@ -87,6 +87,11 @@ service managesieve-login {
inet_listener sieve-webmail {
port = 14190
}
+ service_count = 0
+ client_limit = 25000
+ process_min_avail = {{ CPU_COUNT }}
+ process_limit = {{ CPU_COUNT }}
+ vsz_limit = 256M
}
{% endif %}
@@ -114,6 +119,11 @@ service imap-login {
inet_listener imap-webmail {
port = 10143
}
+ service_count = 0
+ client_limit = 25000
+ process_min_avail = {{ CPU_COUNT }}
+ process_limit = {{ CPU_COUNT }}
+ vsz_limit = 256M
}
service pop3-login {
@@ -132,6 +142,11 @@ service pop3-login {
{% endif %}
}
{% endif %}
+ service_count = 0
+ client_limit = 25000
+ process_min_avail = {{ CPU_COUNT }}
+ process_limit = {{ CPU_COUNT }}
+ vsz_limit = 256M
}
recipient_delimiter = {{ RECIPIENT_DELIMITER }}
@@ -161,4 +176,11 @@ service submission-login {
inet_listener submission-webmail {
port = 10025
}
+ service_count = 0
+ client_limit = 25000
+ process_min_avail = {{ CPU_COUNT }}
+ process_limit = {{ CPU_COUNT }}
+ vsz_limit = 256M
}
+
+!include_try /overrides/dovecot/proxy.conf
diff --git a/core/nginx/letsencrypt.py b/core/nginx/letsencrypt.py
index a8abbee7..e728ba52 100755
--- a/core/nginx/letsencrypt.py
+++ b/core/nginx/letsencrypt.py
@@ -6,8 +6,6 @@ import requests
import sys
import subprocess
import time
-from threading import Thread
-from http.server import HTTPServer, SimpleHTTPRequestHandler
log.basicConfig(stream=sys.stderr, level="WARNING")
hostnames = ','.join(set(host.strip() for host in os.environ['HOSTNAMES'].split(',')))
@@ -22,6 +20,7 @@ command = [
"--preferred-challenges", "http", "--http-01-port", "8008",
"--keep-until-expiring",
"--allow-subset-of-names",
+ "--key-type", "rsa",
"--renew-with-new-domains",
"--config-dir", "/certs/letsencrypt",
"--post-hook", "/config.py"
@@ -45,33 +44,21 @@ command2 = [
# Wait for nginx to start
time.sleep(5)
-class MyRequestHandler(SimpleHTTPRequestHandler):
- def do_GET(self):
- if self.path == '/.well-known/acme-challenge/testing':
- self.send_response(204)
- else:
- self.send_response(404)
- self.send_header('Content-Type', 'text/plain')
- self.end_headers()
-
-def serve_one_request():
- with HTTPServer(("127.0.0.1", 8008), MyRequestHandler) as server:
- server.handle_request()
-
# Run certbot every day
while True:
while True:
hostname = os.environ['HOSTNAMES'].split(',')[0]
target = f'http://{hostname}/.well-known/acme-challenge/testing'
- thread = Thread(target=serve_one_request)
- thread.start()
- r = requests.get(target)
- if r.status_code != 204:
- log.critical(f"Can't reach {target}!, please ensure it's fixed or change the TLS_FLAVOR.")
- time.sleep(5)
- else:
- break
- thread.join()
+ try:
+ r = requests.get(target)
+ if r.status_code != 204:
+ log.critical(f"Can't reach {target}!, please ensure it's fixed or change the TLS_FLAVOR.")
+ time.sleep(5)
+ else:
+ break
+ except Exception as e:
+ log.error(f"Exception while fetching {target}!", exc_info = e)
+ time.sleep(15)
subprocess.call(command)
subprocess.call(command2)
diff --git a/core/nginx/start.py b/core/nginx/start.py
index a50abec2..48f62511 100755
--- a/core/nginx/start.py
+++ b/core/nginx/start.py
@@ -4,7 +4,9 @@ import os
import subprocess
from socrate import system
-system.set_env(log_filters=r'could not be resolved \(\d\: [^\)]+\) while in resolving client address, client\: [^,]+, server: [^\:]+\:(25|110|143|587|465|993|995)$')
+system.set_env(log_filters=[
+ rb'could not be resolved \(\d\: [^\)]+\) while in resolving client address, client\: [^,]+, server: [^\:]+\:(25|110|143|587|465|993|995)$'
+])
# Check if a stale pid file exists
if os.path.exists("/var/run/nginx.pid"):
diff --git a/core/postfix/start.py b/core/postfix/start.py
index 7daf7c3b..aaa141a8 100755
--- a/core/postfix/start.py
+++ b/core/postfix/start.py
@@ -11,10 +11,10 @@ from podop import run_server
from socrate import system, conf
system.set_env(log_filters=[
- r'(dis)?connect from localhost\[(\:\:1|127\.0\.0\.1)\]( quit=1 commands=1)?$',
- r'haproxy read\: short protocol header\: QUIT$',
- r'discarding EHLO keywords\: PIPELINING$'
- ])
+ rb'(dis)?connect from localhost\[(\:\:1|127\.0\.0\.1)\]( quit=1 commands=1)?$',
+ rb'haproxy read\: short protocol header\: QUIT$',
+ rb'discarding EHLO keywords\: PIPELINING$'
+])
os.system("flock -n /queue/pid/master.pid rm /queue/pid/master.pid")
diff --git a/core/rspamd/Dockerfile b/core/rspamd/Dockerfile
index 9f61f7d0..16f4a610 100644
--- a/core/rspamd/Dockerfile
+++ b/core/rspamd/Dockerfile
@@ -17,7 +17,7 @@ COPY start.py /
RUN echo $VERSION >/version
#EXPOSE 11332/tcp 11334/tcp 11335/tcp
-HEALTHCHECK --start-period=350s CMD curl -skfLo /dev/null http://localhost:11334/
+HEALTHCHECK --start-period=350s CMD curl -m3 -skfLo /dev/null http://localhost:11334/
VOLUME ["/var/lib/rspamd"]
diff --git a/docs/Dockerfile b/docs/Dockerfile
index 25ecc496..08e725fc 100644
--- a/docs/Dockerfile
+++ b/docs/Dockerfile
@@ -9,7 +9,7 @@ COPY . /docs
RUN set -euxo pipefail \
; machine="$(uname -m)" \
- ; deps="gcc musl-dev" \
+ ; deps="gcc musl-dev graphviz" \
; [[ "${machine}" != x86_64 ]] && \
deps="${deps} cargo" \
; apk add --no-cache --virtual .build-deps ${deps} \
@@ -17,7 +17,8 @@ RUN set -euxo pipefail \
mkdir -p /root/.cargo/registry/index && \
git clone --bare https://github.com/rust-lang/crates.io-index.git /root/.cargo/registry/index/github.com-1285ae84e5963aae \
; pip3 install -r /requirements.txt \
- ; mkdir -p /build/$VERSION \
+ ; mkdir -p /build/$VERSION/ \
+ ; dot -Tsvg /docs/mailu-network-diagram.dot -o /docs/contributors/mailu-network-diagram.svg \
; sphinx-build -W /docs /build/$VERSION \
; apk del .build-deps \
; rm -rf /root/.cargo
diff --git a/docs/antispam.rst b/docs/antispam.rst
index 5042ff0c..51dffe22 100644
--- a/docs/antispam.rst
+++ b/docs/antispam.rst
@@ -113,7 +113,7 @@ The following steps have to be taken to configure an additional symbol (rule) th
* soft reject: temporarily delay message (this is used, for instance, to greylist or rate-limit messages)
To move an email message to the Junk (Spam) folder, a score of 15 can be used in combination with the action "add header".
- The above example configuration will reject all emails send from domains that are listed in '/etc/rspamd/override.d/blacklist.inc'.
+ The above example configuration will reject all emails send from domains that are listed in '/overrides/blacklist.inc'.
2. In the Rspamd overrides folder create a map that contains the domains to be blocked. You can use # to add comments.
@@ -137,12 +137,12 @@ The following steps have to be taken to configure an additional symbol (rule) th
The symbol is only displayed if the symbol has no pre-filter (action= line) configured. Changes made in this screen are not saved to the configuration file.
-5. Check if the map is available. In rspamd webgui to to configuration. A map is available with the path:
- /etc/rspamd/override.d/blacklist.inc Senders domain part is on the local blacklist
+5. Check if the map is available. In rspamd webgui go to configuration, a map is available with the path:
+ /overrides/blacklist.inc Senders domain part is on the local blacklist
.. image:: assets/screenshots/RspamdMapBlacklist.png
- When clicking on this map, you can live-edit the map via the GUI. Changes are effective immediately. Only changes made to maps in the overrides folder are persistent. Changes made to other maps will be reverted when the Rspamd container is recreated. It is also possible to make direct changes to the map on filesystem. These changes are also effective immediately.
+ When clicking on this map, you can live-edit the map via the GUI. Please note that only changes made to maps in the ``/overrides`` folder are persistent as changes made interractively though the GUI will be reverted when the Rspamd container is recreated. All changes (whether through the GUI or on the filesystem) are effective immediately.
For more information on using the multimap filter see the official `multimap documentation`_ of Rspamd.
diff --git a/docs/contributors/firewalling.rst b/docs/contributors/firewalling.rst
new file mode 100644
index 00000000..01cc2b8c
--- /dev/null
+++ b/docs/contributors/firewalling.rst
@@ -0,0 +1,11 @@
+Firewalling
+===========
+
+Network flows within Mailu
+--------------------------
+
+The following diagram may prove useful in understanding how the different components interact.
+
+.. image:: mailu-network-diagram.svg
+ :target: ../_images/mailu-network-diagram.svg
+
diff --git a/docs/faq.rst b/docs/faq.rst
index 6b3011a1..1111481d 100644
--- a/docs/faq.rst
+++ b/docs/faq.rst
@@ -256,8 +256,10 @@ correct syntax. The following file names will be taken as override configuration
- For both ``postfix.cf`` and ``postfix.master``, you need to put one configuration per line, as they are fed line-by-line
to postfix.
- ``logrotate.conf`` as ``$ROOT/overrides/postfix/logrotate.conf`` - Replaces the logrotate.conf file used for rotating ``POSTFIX_LOG_FILE``.
-- `Dovecot`_ - ``dovecot.conf`` in dovecot sub-directory;
-- `Nginx`_ - All ``*.conf`` files in the ``nginx`` sub-directory;
+- `Dovecot`_ - ``dovecot.conf`` in dovecot sub-directory.
+- `Nginx`_ :
+ - All ``*.conf`` files in the ``nginx`` sub-directory.
+ - ``proxy.conf`` in the ``nginx/dovecot`` sub-directory.
- `Rspamd`_ - All files in the ``rspamd`` sub-directory.
- `Roundcube`_ - All ``*.inc.php`` files in the ``roundcube`` sub directory.
diff --git a/docs/index.rst b/docs/index.rst
index f2cf56f3..77c95df9 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -81,3 +81,4 @@ the version of Mailu that you are running.
contributors/database
contributors/memo
contributors/localization
+ contributors/firewalling
diff --git a/docs/mailu-network-diagram.dot b/docs/mailu-network-diagram.dot
new file mode 100644
index 00000000..eb57b393
--- /dev/null
+++ b/docs/mailu-network-diagram.dot
@@ -0,0 +1,138 @@
+digraph mailu {
+ label = "Mailu network flows";
+ fontname = "arial";
+
+ node [shape = record; fontname = "arial"; fontsize = 8; style = filled; color = "#d3edea";];
+ splines = "compound";
+ // node [shape = "box"; fontsize = "10";];
+ edge [fontsize = 8; arrowsize = 0.5;];
+
+ # Components
+ internet [label = "Internet"; color = "red";];
+ proxy [label = "Proxy (optional)"; color = "darkorange";];
+ front [label="Front"; color="dodgerblue";];
+ admin [label="Admin"; color="green"; fontcolor="white";];
+ smtp [label="SMTP"; color="orchid";];
+ redis [label="Redis"; color="turquoise";];
+ antispam [label="Antispam"; color="magenta";];
+ antivirus [label="Antivirus"; color="purple"; fontcolor="white";];
+ imap [label="IMAP"; color="cyan";];
+ webdav [label="WebDAV"; color="yellow";];
+ webmail [label="Webmail"; color="darkgoldenrod";];
+ fetchmail [label="Fetchmail"; color="chocolate";];
+ oletools [label="Oletools"; color="limegreen";];
+ fts_attachments [label="Tika"; color="sienna";];
+
+ rankdir=LR;
+ {rank=min; internet};
+ // {rank=3; proxy};
+ // {rank=4; front};
+ // {rank=same; admin smtp redis antispam antivirus imap};
+ {rank=max; fetchmail};
+
+ # Proxy from internet
+
+ internet -> proxy [
+ color="red";
+ fontcolor="red";
+ label = <
+
+
+ | 80/tcp |
+ 443/tcp |
+
+
+ | 25/tcp |
+ 465/tcp |
+
+
+ | 587/tcp |
+ 110/tcp |
+
+
+ | 995/tcp |
+ 143/tcp |
+
+
+ | 993/tcp |
+ 4190/tcp |
+
+
+ >;
+ ];
+
+ # Front from proxy
+ proxy -> front [
+ color="darkorange";
+ fontcolor="darkorange";
+ label = <
+
+
+ | 80/tcp |
+ 443/tcp |
+
+
+ | 25/tcp |
+ 465/tcp |
+
+
+ | 587/tcp |
+ 110/tcp |
+
+
+ | 995/tcp |
+ 143/tcp |
+
+
+ | 993/tcp |
+ 4190/tcp |
+
+
+ >;
+ ];
+
+ front -> front [label = "8008/tcp"; color="dodgerblue"; fontcolor="dodgerblue";];
+ front -> front [label = "8000/tcp"; color="dodgerblue"; fontcolor="dodgerblue";];
+ front -> admin [label = "8080/tcp"; color="dodgerblue"; fontcolor="dodgerblue";];
+ front -> imap [label = "4190/tcp"; color="dodgerblue"; fontcolor="dodgerblue";];
+ front -> imap [label = "2525/tcp"; color="dodgerblue"; fontcolor="dodgerblue";];
+ front -> imap [label = "143/tcp"; color="dodgerblue"; fontcolor="dodgerblue";];
+ front -> imap [label = "110/tcp"; color="dodgerblue"; fontcolor="dodgerblue";];
+ front -> smtp [label = "25/tcp"; color="dodgerblue"; fontcolor="dodgerblue";];
+ front -> smtp [label = "10025/tcp"; color="dodgerblue"; fontcolor="dodgerblue";];
+ front -> webmail [label = "80/tcp"; color="dodgerblue"; fontcolor="dodgerblue";];
+ front -> antispam [label = "11334/tcp"; color="dodgerblue"; fontcolor="dodgerblue";];
+ front -> webdav [label = "5232/tcp"; color="dodgerblue"; fontcolor="dodgerblue";];
+
+ smtp -> admin [label = "8080/tcp"; color="orchid"; fontcolor="orchid";];
+ smtp -> front [label = "2525/tcp"; color="orchid"; fontcolor="orchid";];
+ smtp -> antispam [label = "11332/tcp"; color="orchid"; fontcolor="orchid";];
+
+ imap -> admin [label = "8080/tcp"; color="cyan"; fontcolor="cyan";];
+ imap -> antispam [label = "11334/tcp"; color="cyan"; fontcolor="cyan";];
+ imap -> proxy [label = "25/tcp"; color="cyan"; fontcolor="cyan";];
+ imap -> fts_attachments [label = "9998/tcp"; color="cyan"; fontcolor="cyan";];
+
+ webmail -> front [label = "14190/tcp"; color="darkgoldenrod"; fontcolor="darkgoldenrod";];
+ webmail -> front [label = "10025/tcp"; color="darkgoldenrod"; fontcolor="darkgoldenrod";];
+ webmail -> front [label = "10143/tcp"; color="darkgoldenrod"; fontcolor="darkgoldenrod";];
+ # carddav
+ webmail -> proxy [label = "443/tcp"; color="darkgoldenrod"; fontcolor="darkgoldenrod";];
+
+ admin -> redis [label = "6379/tcp"; color="green"; fontcolor="green";];
+ admin -> front [label = "2525/tcp"; color="green"; fontcolor="green";];
+
+ antispam -> redis [label = "6379/tcp"; color="magenta"; fontcolor="magenta";];
+ antispam -> admin [label = "8080/tcp"; color="magenta"; fontcolor="magenta";];
+ antispam -> oletools [label = "11343/tcp"; color="magenta"; fontcolor="magenta";];
+ antispam -> antivirus [label = "3310/tcp"; color="magenta"; fontcolor="magenta";];
+
+ fetchmail -> admin [label = "8080/tcp"; color="chocolate"; fontcolor="chocolate";];
+ fetchmail -> proxy [label = "25/tcp"; color="chocolate"; fontcolor="chocolate";];
+ fetchmail -> front [label = "2525/tcp"; color="chocolate"; fontcolor="chocolate";];
+ #
+ # those don't need internet:
+ # oletools
+ # fts_attachments
+ # redis
+}
diff --git a/docs/mailu-network-diagram.ipynb b/docs/mailu-network-diagram.ipynb
deleted file mode 100644
index 7b4336a5..00000000
--- a/docs/mailu-network-diagram.ipynb
+++ /dev/null
@@ -1,517 +0,0 @@
-{
- "cells": [
- {
- "cell_type": "code",
- "execution_count": 2,
- "metadata": {},
- "outputs": [
- {
- "data": {
- "image/svg+xml": [
- "\n",
- "\n",
- "\n",
- "\n",
- "\n"
- ],
- "text/plain": [
- ""
- ]
- },
- "execution_count": 2,
- "metadata": {},
- "output_type": "execute_result"
- }
- ],
- "source": [
- "import graphviz\n",
- "\n",
- "a = \"\"\"\n",
- "digraph mailu {\n",
- " label = \"Mailu\";\n",
- " fontname = \"arial\";\n",
- " \n",
- " node [shape = box; fontname = \"arial\"; fontsize = 8; style = filled; color = \"#d3edea\";];\n",
- " splines = \"compound\";\n",
- " // node [shape = \"box\"; fontsize = \"10\";];\n",
- " edge [fontsize = \"8\";];\n",
- " \n",
- " # Components\n",
- " internet [label = \"Internet\";];\n",
- " front [label = \"Front\";];\n",
- " admin [label = \"Admin\";];\n",
- " smtp [label = \"SMTP\";];\n",
- " redis [label = \"Redis\";];\n",
- " antispam [label = \"Antispam\";];\n",
- " antivirus [label = \"Anti-Virus\";];\n",
- " imap [label = \"IMAP\";];\n",
- " webdav [label = \"WebDAV\";];\n",
- " webmail [label = \"Webmail\";];\n",
- " fetchmail [label = \"Fetchmail\";];\n",
- " oletools [label = \"Oletools\"];\n",
- " fts_attachments [label = \"Tika\"];\n",
- " \n",
- " # Front from internet\n",
- " internet -> front [label = \"80/tcp\";];\n",
- " internet -> front [label = \"443/tcp\";];\n",
- " internet -> front [label = \"25/tcp\";];\n",
- " internet -> front [label = \"465/tcp\";];\n",
- " internet -> front [label = \"587/tcp\";];\n",
- " internet -> front [label = \"110/tcp\";];\n",
- " internet -> front [label = \"995/tcp\";];\n",
- " internet -> front [label = \"143/tcp\";];\n",
- " internet -> front [label = \"993/tcp\";];\n",
- " internet -> front [label = \"4190/tcp\";];\n",
- " \n",
- " front -> front [label = \"8008/tcp\";];\n",
- " front -> front [label = \"8000/tcp\";];\n",
- " front -> admin [label = \"8080/tcp\";];\n",
- " front -> imap [label = \"4190/tcp\";];\n",
- " front -> imap [label = \"143/tcp\";];\n",
- " front -> imap [label = \"110/tcp\";];\n",
- " front -> smtp [label = \"25/tcp\";];\n",
- " front -> smtp [label = \"10025/tcp\";];\n",
- " front -> webmail [label = \"80/tcp\";];\n",
- " front -> antispam [label = \"11334/tcp\";];\n",
- " front -> webdav [label = \"5232/tcp\";];\n",
- " \n",
- " smtp -> admin [label = \"8080/tcp\";];\n",
- " smtp -> front [label = \"2525/tcp\";];\n",
- " smtp -> antispam [label = \"11332/tcp\";];\n",
- " \n",
- " imap -> admin [label = \"8080/tcp\";];\n",
- " imap -> antispam [label = \"11334/tcp\";];\n",
- " imap -> front [label = \"25/tcp\";];\n",
- " imap -> fts_attachments [label = \"9998/tcp\";];\n",
- " \n",
- " webmail -> front [label = \"14190/tcp\";];\n",
- " webmail -> front [label = \"10025/tcp\";];\n",
- " webmail -> front [label = \"10143/tcp\";];\n",
- " \n",
- " admin -> redis [label = \"6379/tcp\";];\n",
- " admin -> imap [label = \"2525/tcp\";];\n",
- " \n",
- " antispam -> redis [label = \"6379/tcp\";];\n",
- " antispam -> admin [label = \"80/tcp\";];\n",
- " antispam -> oletools [label = \"11343/tcp\";];\n",
- " antispam -> antivirus [label = \"3310/tcp\";];\n",
- " \n",
- " fetchmail -> admin [label = \"8080/tcp\"]\n",
- " fetchmail -> front [label = \"25/tcp\"]\n",
- " fetchmail -> front [label = \"2525/tcp\"]\n",
- " #\n",
- " # those don't need internet:\n",
- " # oletools\n",
- " # fts_attachments\n",
- " # redis\n",
- "}\n",
- "\"\"\"\n",
- "\n",
- "dot = graphviz.Source(a)\n",
- "dot\n"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {},
- "outputs": [],
- "source": []
- },
- {
- "cell_type": "code",
- "execution_count": null,
- "metadata": {},
- "outputs": [],
- "source": []
- }
- ],
- "metadata": {
- "kernelspec": {
- "display_name": "Python 3 (ipykernel)",
- "language": "python",
- "name": "python3"
- },
- "language_info": {
- "codemirror_mode": {
- "name": "ipython",
- "version": 3
- },
- "file_extension": ".py",
- "mimetype": "text/x-python",
- "name": "python",
- "nbconvert_exporter": "python",
- "pygments_lexer": "ipython3",
- "version": "3.11.2"
- }
- },
- "nbformat": 4,
- "nbformat_minor": 2
-}
diff --git a/docs/maintain.rst b/docs/maintain.rst
index eff8f5c4..fd01a5bf 100644
--- a/docs/maintain.rst
+++ b/docs/maintain.rst
@@ -46,7 +46,8 @@ In the case of *certbot* you could write a script to be executed as `deploy hook
#!/bin/sh
cp /etc/letsencrypt/live/domain.com/privkey.pem /mailu/certs/key.pem || exit 1
cp /etc/letsencrypt/live/domain.com/fullchain.pem /mailu/certs/cert.pem || exit 1
- docker exec mailu_front_1 nginx -s reload
+ docker exec mailu-front-1 nginx -s reload
+ docker exec mailu-front-1 doveadm reload
And the certbot command you will use in crontab would look something like:
diff --git a/optional/fetchmail/fetchmail.py b/optional/fetchmail/fetchmail.py
index a298cf53..96f387dd 100755
--- a/optional/fetchmail/fetchmail.py
+++ b/optional/fetchmail/fetchmail.py
@@ -1,5 +1,6 @@
#!/usr/bin/env python3
+import binascii
import time
import os
from pathlib import Path
@@ -32,6 +33,19 @@ poll "{host}" proto {protocol} port {port}
{lmtp}
"""
+def imaputf7encode(s):
+ """Encode a string into RFC2060 aka IMAP UTF7"""
+ out = ''
+ enc = ''
+ for c in s.replace('&','&-') + 'X':
+ if '\x20' <= c <= '\x7f':
+ if enc:
+ out += f'&{binascii.b2a_base64(enc.encode("utf-16-be")).rstrip(b"\n=").replace(b"/", b",").decode("ascii")}-'
+ enc = ''
+ out += c
+ else:
+ enc += c
+ return out[:-1]
def escape_rc_string(arg):
return "".join("\\x%2x" % ord(char) for char in arg)
@@ -54,13 +68,13 @@ def run(debug):
options = "options antispam 501, 504, 550, 553, 554"
options += " ssl" if fetch["tls"] else ""
options += " keep" if fetch["keep"] else " fetchall"
- folders = "folders %s" % ((','.join('"' + item + '"' for item in fetch['folders'])) if fetch['folders'] else '"INBOX"')
+ folders = f"folders {",".join(f'"{imaputf7encode(item).replace('"',r"\34")}"' for item in fetch["folders"]) or '"INBOX"'}"
fetchmailrc += RC_LINE.format(
user_email=escape_rc_string(fetch["user_email"]),
protocol=fetch["protocol"],
host=escape_rc_string(fetch["host"]),
port=fetch["port"],
- smtphost=f'{os.environ["FRONT_ADDRESS"]}' if fetch['scan'] else f'{os.environ["FRONT_ADDRESS"]}/2525',
+ smtphost=f'{os.environ["HOSTNAMES"].split(",")[0]}' if fetch['scan'] and os.environ.get('PROXY_PROTOCOL_25', False) else f'{os.environ["FRONT_ADDRESS"]}' if fetch['scan'] else f'{os.environ["FRONT_ADDRESS"]}/2525',
username=escape_rc_string(fetch["username"]),
password=escape_rc_string(fetch["password"]),
options=options,
diff --git a/optional/radicale/radicale.conf b/optional/radicale/radicale.conf
index bcea34f8..7530338e 100644
--- a/optional/radicale/radicale.conf
+++ b/optional/radicale/radicale.conf
@@ -1,6 +1,6 @@
[server]
ssl = False
-hosts = 0.0.0.0:5232
+hosts = 0.0.0.0:5232, [::]:5232
[encoding]
request = utf-8
diff --git a/setup/Dockerfile b/setup/Dockerfile
index a410871d..4ddfe683 100644
--- a/setup/Dockerfile
+++ b/setup/Dockerfile
@@ -15,7 +15,7 @@ COPY main.py ./main.py
RUN echo $VERSION >> /version
EXPOSE 80/tcp
-HEALTHCHECK --start-period=350s CMD curl -skfLo /dev/null http://localhost/
+HEALTHCHECK --start-period=350s CMD curl -m3 -skfLo /dev/null http://localhost/
USER mailu
CMD gunicorn -w 4 -b :80 --access-logfile - --error-logfile - --preload main:app
diff --git a/setup/flavors/compose/docker-compose.yml b/setup/flavors/compose/docker-compose.yml
index 3eeba1d4..428d6995 100644
--- a/setup/flavors/compose/docker-compose.yml
+++ b/setup/flavors/compose/docker-compose.yml
@@ -217,7 +217,7 @@ services:
# Optional services
{% if antivirus_enabled %}
antivirus:
- image: clamav/clamav-debian:1.2.0-6
+ image: clamav/clamav-debian:1.2.3-45
restart: always
logging:
driver: journald
diff --git a/tests/compose/core/00_create_users.sh b/tests/compose/core/00_create_users.sh
index 142c3cb3..0bed6baf 100755
--- a/tests/compose/core/00_create_users.sh
+++ b/tests/compose/core/00_create_users.sh
@@ -8,5 +8,5 @@ docker compose -f tests/compose/core/docker-compose.yml exec -T admin flask mail
docker compose -f tests/compose/core/docker-compose.yml exec -T admin flask mailu admin admin mailu.io 'password' --mode=update || exit 1
docker compose -f tests/compose/core/docker-compose.yml exec -T admin flask mailu user user mailu.io 'password' || exit 1
docker compose -f tests/compose/core/docker-compose.yml exec -T admin flask mailu user 'user/with/slash' mailu.io 'password' || exit 1
-docker compose -f tests/compose/core/docker-compose.yml exec -T admin flask mailu user 'user_UTF8' mailu.io 'password€' || exit 1
+docker compose -f tests/compose/core/docker-compose.yml exec -T admin flask mailu user 'user_UTF8' mailu.io 'pa…ss%e9word€' || exit 1
echo "User testing successful!"
diff --git a/tests/compose/core/05_connectivity.py b/tests/compose/core/05_connectivity.py
index 5cc12069..44bb4553 100755
--- a/tests/compose/core/05_connectivity.py
+++ b/tests/compose/core/05_connectivity.py
@@ -8,7 +8,7 @@ import managesieve
SERVER='localhost'
USERNAME='user_UTF8@mailu.io'
-PASSWORD='password€'
+PASSWORD='pa…ss%e9word€'
#https://github.com/python/cpython/issues/73936
#SMTPlib does not support UTF8 passwords.
USERNAME_ASCII='user@mailu.io'
@@ -139,4 +139,4 @@ if __name__ == '__main__':
test_SMTP(SERVER, USERNAME_ASCII, PASSWORD_ASCII)
test_managesieve(SERVER, USERNAME, PASSWORD)
#https://github.com/python/cpython/issues/73936
-#SMTPlib does not support UTF8 passwords.
\ No newline at end of file
+#SMTPlib does not support UTF8 passwords.
diff --git a/towncrier/newsfragments/2296.bugfix b/towncrier/newsfragments/2296.bugfix
new file mode 100644
index 00000000..493238ff
--- /dev/null
+++ b/towncrier/newsfragments/2296.bugfix
@@ -0,0 +1 @@
+Ensure fetchmail can deal with special characters in folder names
diff --git a/towncrier/newsfragments/3272.bugfix b/towncrier/newsfragments/3272.bugfix
new file mode 100644
index 00000000..726e9e78
--- /dev/null
+++ b/towncrier/newsfragments/3272.bugfix
@@ -0,0 +1 @@
+Increase the size of buffers for webmail
diff --git a/towncrier/newsfragments/3347.bugfix b/towncrier/newsfragments/3347.bugfix
new file mode 100644
index 00000000..6668768f
--- /dev/null
+++ b/towncrier/newsfragments/3347.bugfix
@@ -0,0 +1,5 @@
+Update to a newer clamav 1.2.3-45
+Update to snappymail 2.36.4
+Update to roundcube 1.6.8 (CVE-2024-42009, CVE-2024-42008, CVE-2024-42010)
+Add a new DNS entry for autodiscover (old MUA autoconfiguration)
+Clarify the language in the documentation related to rspamd overrides
diff --git a/towncrier/newsfragments/3349.bugfix b/towncrier/newsfragments/3349.bugfix
new file mode 100644
index 00000000..b1dd1e0a
--- /dev/null
+++ b/towncrier/newsfragments/3349.bugfix
@@ -0,0 +1 @@
+Fix email-forwarding when set from the web interface
diff --git a/towncrier/newsfragments/3364.bugfix b/towncrier/newsfragments/3364.bugfix
new file mode 100644
index 00000000..10e56cf3
--- /dev/null
+++ b/towncrier/newsfragments/3364.bugfix
@@ -0,0 +1 @@
+Fix a bug preventing percent characters from being used in passwords
diff --git a/towncrier/newsfragments/3379.bugfix b/towncrier/newsfragments/3379.bugfix
new file mode 100644
index 00000000..c1842c52
--- /dev/null
+++ b/towncrier/newsfragments/3379.bugfix
@@ -0,0 +1 @@
+Fix #3379: DEFAULT_QUOTA
diff --git a/towncrier/newsfragments/3384.bugfix b/towncrier/newsfragments/3384.bugfix
new file mode 100644
index 00000000..e82c78d8
--- /dev/null
+++ b/towncrier/newsfragments/3384.bugfix
@@ -0,0 +1 @@
+Ensure that file:// protocol is not allowed in CURL
diff --git a/towncrier/newsfragments/3389.misc b/towncrier/newsfragments/3389.misc
new file mode 100644
index 00000000..0cd5b88f
--- /dev/null
+++ b/towncrier/newsfragments/3389.misc
@@ -0,0 +1 @@
+Update roundcube to 1.6.9
diff --git a/towncrier/newsfragments/3398.misc b/towncrier/newsfragments/3398.misc
new file mode 100644
index 00000000..895b6f1d
--- /dev/null
+++ b/towncrier/newsfragments/3398.misc
@@ -0,0 +1,2 @@
+Disable HARDENED_MALLOC unless the requirements are met
+Ensure the healthchecks timeout
diff --git a/towncrier/newsfragments/3401.bugfix b/towncrier/newsfragments/3401.bugfix
new file mode 100644
index 00000000..5309408d
--- /dev/null
+++ b/towncrier/newsfragments/3401.bugfix
@@ -0,0 +1 @@
+Fix an error that can occur when using snappymail
diff --git a/towncrier/newsfragments/3402.bugfix b/towncrier/newsfragments/3402.bugfix
new file mode 100644
index 00000000..21ddd2ee
--- /dev/null
+++ b/towncrier/newsfragments/3402.bugfix
@@ -0,0 +1 @@
+Fix a potential problem with SO_REUSEADDR that may prevent admin from starting up
diff --git a/towncrier/newsfragments/3403.bugfix b/towncrier/newsfragments/3403.bugfix
new file mode 100644
index 00000000..d8169ba1
--- /dev/null
+++ b/towncrier/newsfragments/3403.bugfix
@@ -0,0 +1 @@
+fix INBOUND_TLS_ENFORCE
diff --git a/towncrier/newsfragments/3405.bugfix b/towncrier/newsfragments/3405.bugfix
new file mode 100644
index 00000000..07db93a8
--- /dev/null
+++ b/towncrier/newsfragments/3405.bugfix
@@ -0,0 +1 @@
+Update the documentation: ensure that users reload dovecot too if they manually configure certificates
diff --git a/towncrier/newsfragments/3411.bugfix b/towncrier/newsfragments/3411.bugfix
new file mode 100644
index 00000000..3b1e80ed
--- /dev/null
+++ b/towncrier/newsfragments/3411.bugfix
@@ -0,0 +1 @@
+Ensure we do not nuke all web-sessions when a password is changed using the command line
diff --git a/towncrier/newsfragments/3420.bugfix b/towncrier/newsfragments/3420.bugfix
new file mode 100644
index 00000000..dbc78435
--- /dev/null
+++ b/towncrier/newsfragments/3420.bugfix
@@ -0,0 +1 @@
+The reload functionality of nginx/dovecot upon change of the certificates failed with an error.
\ No newline at end of file
diff --git a/towncrier/newsfragments/3450.bugfix b/towncrier/newsfragments/3450.bugfix
new file mode 100644
index 00000000..50c75f22
--- /dev/null
+++ b/towncrier/newsfragments/3450.bugfix
@@ -0,0 +1,2 @@
+Ensure we can do more than 100 parallel sessions.
+Allow dovecot's config to be overriden in front too
diff --git a/towncrier/newsfragments/3467.bugfix b/towncrier/newsfragments/3467.bugfix
new file mode 100644
index 00000000..fe7cdc8c
--- /dev/null
+++ b/towncrier/newsfragments/3467.bugfix
@@ -0,0 +1 @@
+Fix broken overrides in 2024.06.17
diff --git a/towncrier/newsfragments/3531.bugfix b/towncrier/newsfragments/3531.bugfix
new file mode 100644
index 00000000..9deddb45
--- /dev/null
+++ b/towncrier/newsfragments/3531.bugfix
@@ -0,0 +1 @@
+Ensure we have both RSA and ECDSA certs when using letsencrypt
diff --git a/towncrier/newsfragments/3613.bugfix b/towncrier/newsfragments/3613.bugfix
new file mode 100644
index 00000000..0618c857
--- /dev/null
+++ b/towncrier/newsfragments/3613.bugfix
@@ -0,0 +1 @@
+HTTP/2 does not require ipv6; in fact it does not require SSL certs either
diff --git a/towncrier/newsfragments/3618.bugfix b/towncrier/newsfragments/3618.bugfix
new file mode 100644
index 00000000..fb72e3a2
--- /dev/null
+++ b/towncrier/newsfragments/3618.bugfix
@@ -0,0 +1 @@
+Filter logs line based and in binary mode without decoding utf-8
diff --git a/towncrier/newsfragments/3622.misc b/towncrier/newsfragments/3622.misc
new file mode 100644
index 00000000..2e749d74
--- /dev/null
+++ b/towncrier/newsfragments/3622.misc
@@ -0,0 +1 @@
+Upgrade to alpine 3.20.3
diff --git a/towncrier/newsfragments/3648.bugfix b/towncrier/newsfragments/3648.bugfix
new file mode 100644
index 00000000..95a98590
--- /dev/null
+++ b/towncrier/newsfragments/3648.bugfix
@@ -0,0 +1 @@
+Upgrade snappymail to v2.38.2 ; this is a security fix for GHSA-2rq7-79vp-ffxm (mXSS)
diff --git a/towncrier/newsfragments/3650.bugfix b/towncrier/newsfragments/3650.bugfix
new file mode 100644
index 00000000..97d9aa8f
--- /dev/null
+++ b/towncrier/newsfragments/3650.bugfix
@@ -0,0 +1 @@
+Don't check empty passwords against HIBP
diff --git a/webmails/Dockerfile b/webmails/Dockerfile
index 950c3f3c..95d4a374 100644
--- a/webmails/Dockerfile
+++ b/webmails/Dockerfile
@@ -28,7 +28,7 @@ RUN set -euxo pipefail \
; mkdir -p /run/nginx /conf
# roundcube
-ENV ROUNDCUBE_URL https://github.com/roundcube/roundcubemail/releases/download/1.6.7/roundcubemail-1.6.7-complete.tar.gz
+ENV ROUNDCUBE_URL https://github.com/roundcube/roundcubemail/releases/download/1.6.9/roundcubemail-1.6.9-complete.tar.gz
ENV CARDDAV_URL https://github.com/mstilkerich/rcmcarddav/releases/download/v5.1.0/carddav-v5.1.0.tar.gz
RUN set -euxo pipefail \
@@ -54,7 +54,7 @@ COPY roundcube/config/config.inc.carddav.php /var/www/roundcube/plugins/carddav/
# snappymail
-ENV SNAPPYMAIL_URL https://github.com/the-djmaze/snappymail/releases/download/v2.36.1/snappymail-2.36.1.tar.gz
+ENV SNAPPYMAIL_URL https://github.com/the-djmaze/snappymail/releases/download/v2.38.2/snappymail-2.38.2.tar.gz
RUN set -euxo pipefail \
; mkdir /var/www/snappymail \
@@ -94,6 +94,6 @@ VOLUME /overrides
CMD /start.py
-HEALTHCHECK CMD curl -f -L http://localhost/ping || exit 1
+HEALTHCHECK CMD curl -m3 -f -L http://localhost/ping || exit 1
RUN echo $VERSION >> /version
diff --git a/webmails/nginx-webmail.conf b/webmails/nginx-webmail.conf
index 8772c8c8..d403eea4 100644
--- a/webmails/nginx-webmail.conf
+++ b/webmails/nginx-webmail.conf
@@ -55,6 +55,16 @@ server {
{% else %}
fastcgi_param SCRIPT_NAME {{WEB_WEBMAIL}}/$fastcgi_script_name;
{% endif %}
+
+ # fastcgi buffers for php-fpm #
+ fastcgi_buffers 16 32k;
+ fastcgi_buffer_size 64k;
+ fastcgi_busy_buffers_size 64k;
+
+ # nginx buffers #
+ proxy_buffer_size 128k;
+ proxy_buffers 4 256k;
+ proxy_busy_buffers_size 256k;
}
location ~ (^|/)\. {
diff --git a/webmails/snuffleupagus.rules b/webmails/snuffleupagus.rules
index 4cbe966d..3d4713f8 100644
--- a/webmails/snuffleupagus.rules
+++ b/webmails/snuffleupagus.rules
@@ -39,7 +39,9 @@ sp.disable_function.function("chmod").param("permissions").value("438").drop();
sp.disable_function.function("chmod").param("permissions").value("511").drop();
# Prevent various `mail`-related vulnerabilities
+# Uncommend the second rule if you're using php8.3+
sp.disable_function.function("mail").param("additional_parameters").value_r("\\-").drop();
+sp.disable_function.function("mail").param("additional_params").value_r("\\-").drop();
# Since it's now burned, me might as well mitigate it publicly
sp.disable_function.function("putenv").param("assignment").value_r("LD_").drop()
@@ -52,8 +54,7 @@ sp.disable_function.function("putenv").param("assignment").value_r("GCONV_").dro
sp.disable_function.function("extract").param("array").value_r("^_").drop()
sp.disable_function.function("extract").param("flags").value("0").drop()
-# This is also burned:
-# ini_set('open_basedir','..');chdir('..');…;chdir('..');ini_set('open_basedir','/');echo(file_get_contents('/etc/passwd'));
+# See https://dustri.org/b/ini_set-based-open_basedir-bypass.html
# Since we have no way of matching on two parameters at the same time, we're
# blocking calls to open_basedir altogether: nobody is using it via ini_set anyway.
# Moreover, there are non-public bypasses that are also using this vector ;)
@@ -119,12 +120,17 @@ sp.disable_function.function("curl_setopt").param("value").value("2").allow();
sp.disable_function.function("curl_setopt").param("option").value("64").drop().alias("Please don't turn CURLOPT_SSL_VERIFYCLIENT off.");
sp.disable_function.function("curl_setopt").param("option").value("81").drop().alias("Please don't turn CURLOPT_SSL_VERIFYHOST off.");
+# Ensure that file:// protocol is not allowed in CURL
+sp.disable_function.function("curl_setopt").param("value").value_r("file://").drop().alias("file:// protocol is disabled");
+sp.disable_function.function("curl_init").param("url").value_r("file://").drop().alias("file:// protocol is disabled");
+
# File upload
sp.disable_function.function("move_uploaded_file").param("to").value_r("\\.ph").drop();
sp.disable_function.function("move_uploaded_file").param("to").value_r("\\.ht").drop();
# Logging lockdown
sp.disable_function.function("ini_set").param("option").value_r("error_log").drop()
+sp.disable_function.function("ini_set").param("option").value_r("display_errors").filename_r("/var/www/snappymail/snappymail/v/[0-9]+\.[0-9]+\.[0-9]+/app/libraries/snappymail/shutdown.php").allow();
sp.disable_function.function("ini_set").param("option").value_r("display_errors").drop()
sp.auto_cookie_secure.enable();