From 1438253a069da3b10831ef89dc119177f16f5216 Mon Sep 17 00:00:00 2001
From: Florent Daigniere
Date: Sun, 8 Aug 2021 09:21:14 +0200
Subject: [PATCH 1/8] Ratelimit outgoing emails per user
---
core/admin/mailu/configuration.py | 1 +
core/admin/mailu/internal/views/postfix.py | 10 ++++++++--
core/admin/mailu/models.py | 8 +++++++-
core/admin/mailu/ui/templates/user/list.html | 5 ++++-
core/postfix/conf/main.cf | 1 +
core/postfix/start.py | 3 ++-
setup/flavors/compose/mailu.env | 5 +++++
setup/templates/steps/config.html | 7 +++++++
towncrier/newsfragments/1031.feature | 1 +
9 files changed, 36 insertions(+), 5 deletions(-)
create mode 100644 towncrier/newsfragments/1031.feature
diff --git a/core/admin/mailu/configuration.py b/core/admin/mailu/configuration.py
index d2d34d88..50733d52 100644
--- a/core/admin/mailu/configuration.py
+++ b/core/admin/mailu/configuration.py
@@ -46,6 +46,7 @@ DEFAULT_CONFIG = {
'DKIM_SELECTOR': 'dkim',
'DKIM_PATH': '/dkim/{domain}.{selector}.key',
'DEFAULT_QUOTA': 1000000000,
+ 'MESSAGE_RATELIMIT': '100/hour',
# Web settings
'SITENAME': 'Mailu',
'WEBSITE': 'https://mailu.io',
diff --git a/core/admin/mailu/internal/views/postfix.py b/core/admin/mailu/internal/views/postfix.py
index c358c37f..06918c61 100644
--- a/core/admin/mailu/internal/views/postfix.py
+++ b/core/admin/mailu/internal/views/postfix.py
@@ -1,5 +1,6 @@
-from mailu import models
+from mailu import models, utils
from mailu.internal import internal
+from flask import current_app as app
import flask
import idna
@@ -31,7 +32,6 @@ def postfix_alias_map(alias):
destination = models.Email.resolve_destination(localpart, domain_name)
return flask.jsonify(",".join(destination)) if destination else flask.abort(404)
-
@internal.route("/postfix/transport/")
def postfix_transport(email):
if email == '*' or re.match("(^|.*@)\[.*\]$", email):
@@ -139,6 +139,12 @@ def postfix_sender_login(sender):
destination = models.Email.resolve_destination(localpart, domain_name, True)
return flask.jsonify(",".join(destination)) if destination else flask.abort(404)
+@internal.route("/postfix/sender/rate/")
+def postfix_sender_rate(sender):
+ """ Rate limit outbound emails per sender login
+ """
+ user = models.User.get(sender) or flask.abort(404)
+ return flask.abort(404) if user.sender_limiter.hit() else flask.jsonify("REJECT")
@internal.route("/postfix/sender/access/")
def postfix_sender_access(sender):
diff --git a/core/admin/mailu/models.py b/core/admin/mailu/models.py
index 3a299786..5760c27f 100644
--- a/core/admin/mailu/models.py
+++ b/core/admin/mailu/models.py
@@ -27,7 +27,7 @@ from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.inspection import inspect
from werkzeug.utils import cached_property
-from mailu import dkim
+from mailu import dkim, utils
db = flask_sqlalchemy.SQLAlchemy()
@@ -501,6 +501,12 @@ class User(Base, Email):
self.reply_enddate > now
)
+ @property
+ def sender_limiter(self):
+ return utils.limiter.get_limiter(
+ app.config["MESSAGE_RATELIMIT"], "sender", self.email
+ )
+
@classmethod
def get_password_context(cls):
""" create password context for hashing and verification
diff --git a/core/admin/mailu/ui/templates/user/list.html b/core/admin/mailu/ui/templates/user/list.html
index 2aff662f..746afd45 100644
--- a/core/admin/mailu/ui/templates/user/list.html
+++ b/core/admin/mailu/ui/templates/user/list.html
@@ -19,7 +19,8 @@
{% trans %}User settings{% endtrans %} |
{% trans %}Email{% endtrans %} |
{% trans %}Features{% endtrans %} |
- {% trans %}Quota{% endtrans %} |
+ {% trans %}Storage Quota{% endtrans %} |
+ {% trans %}Sending Quota{% endtrans %} |
{% trans %}Comment{% endtrans %} |
{% trans %}Created{% endtrans %} |
{% trans %}Last edit{% endtrans %} |
@@ -41,6 +42,8 @@
{% if user.enable_pop %}pop3{% endif %}
{{ user.quota_bytes_used | filesizeformat }} / {{ (user.quota_bytes | filesizeformat) if user.quota_bytes else '∞' }} |
+ {% set limiter = user.sender_limiter %}
+ {{ limiter.get_window_stats()[1] }} / {{ limiter.limit }} |
{{ user.comment or '-' }} |
{{ user.created_at }} |
{{ user.updated_at or '' }} |
diff --git a/core/postfix/conf/main.cf b/core/postfix/conf/main.cf
index 8f35f609..6f5a20b8 100644
--- a/core/postfix/conf/main.cf
+++ b/core/postfix/conf/main.cf
@@ -100,6 +100,7 @@ smtpd_sender_login_maps = ${podop}senderlogin
smtpd_helo_required = yes
smtpd_client_restrictions =
+ check_sasl_access ${podop}senderrate,
permit_mynetworks,
check_sender_access ${podop}senderaccess,
reject_non_fqdn_sender,
diff --git a/core/postfix/start.py b/core/postfix/start.py
index e0c781b7..139616b2 100755
--- a/core/postfix/start.py
+++ b/core/postfix/start.py
@@ -25,7 +25,8 @@ def start_podop():
("recipientmap", "url", url + "recipient/map/§"),
("sendermap", "url", url + "sender/map/§"),
("senderaccess", "url", url + "sender/access/§"),
- ("senderlogin", "url", url + "sender/login/§")
+ ("senderlogin", "url", url + "sender/login/§"),
+ ("senderrate", "url", url + "sender/rate/§")
])
def is_valid_postconf_line(line):
diff --git a/setup/flavors/compose/mailu.env b/setup/flavors/compose/mailu.env
index d45f5517..52f4ee04 100644
--- a/setup/flavors/compose/mailu.env
+++ b/setup/flavors/compose/mailu.env
@@ -62,6 +62,11 @@ ANTIVIRUS={{ antivirus_enabled or 'none' }}
# Max attachment size will be 33% smaller
MESSAGE_SIZE_LIMIT={{ message_size_limit or '50000000' }}
+# Message rate limit (per user)
+{% if message_ratelimit_pd > '0' %}
+MESSAGE_RATELIMIT={{ message_ratelimit_pd }}/day
+{% endif %}
+
# Networks granted relay permissions
# Use this with care, all hosts in this networks will be able to send mail without authentication!
RELAYNETS=
diff --git a/setup/templates/steps/config.html b/setup/templates/steps/config.html
index 72b83915..87410fca 100644
--- a/setup/templates/steps/config.html
+++ b/setup/templates/steps/config.html
@@ -55,6 +55,13 @@ Or in plain english: if receivers start to classify your mail as spam, this post
+
+