Pārlūkot izejas kodu

Return 421 when there's too much activity on an alias or mailbox

Son NK 5 gadi atpakaļ
vecāks
revīzija
8caebc0142
4 mainītis faili ar 204 papildinājumiem un 0 dzēšanām
  1. 8 0
      app/config.py
  2. 103 0
      app/greylisting.py
  3. 8 0
      email_handler.py
  4. 85 0
      tests/test_greylisting.py

+ 8 - 0
app/config.py

@@ -208,6 +208,14 @@ PAGE_LIMIT = 20
 LOCAL_FILE_UPLOAD = "LOCAL_FILE_UPLOAD" in os.environ
 UPLOAD_DIR = None
 
+# Greylisting features
+# minimal time in seconds an alias can receive/send emails
+MIN_TIME_BETWEEN_ACTIVITY_PER_ALIAS = 8
+
+# minimal time in seconds a mailbox can receive/send emails
+MIN_TIME_BETWEEN_ACTIVITY_PER_MAILBOX = 3
+
+
 if LOCAL_FILE_UPLOAD:
     print("Upload files to local dir")
     UPLOAD_DIR = os.path.join(ROOT_DIR, "static/upload")

+ 103 - 0
app/greylisting.py

@@ -0,0 +1,103 @@
+import arrow
+
+from app.alias_utils import try_auto_create
+from app.config import (
+    MIN_TIME_BETWEEN_ACTIVITY_PER_ALIAS,
+    MIN_TIME_BETWEEN_ACTIVITY_PER_MAILBOX,
+)
+from app.extensions import db
+from app.log import LOG
+from app.models import Alias, EmailLog, Contact
+
+
+def greylisting_needed_for_alias(alias: Alias) -> bool:
+    # get the latest email activity on this alias
+    r = (
+        db.session.query(EmailLog, Contact)
+        .filter(EmailLog.contact_id == Contact.id, Contact.alias_id == alias.id)
+        .order_by(EmailLog.id.desc())
+        .first()
+    )
+
+    if r:
+        email_log, _ = r
+        now = arrow.now()
+        if (now - email_log.created_at).seconds < MIN_TIME_BETWEEN_ACTIVITY_PER_ALIAS:
+            LOG.d(
+                "Too much forward on alias %s. Latest email log %s", alias, email_log,
+            )
+            return True
+
+    return False
+
+
+def greylisting_needed_for_mailbox(alias: Alias) -> bool:
+    # get the latest email activity on this mailbox
+    r = (
+        db.session.query(EmailLog, Contact, Alias)
+        .filter(
+            EmailLog.contact_id == Contact.id,
+            Contact.alias_id == Alias.id,
+            Alias.mailbox_id == alias.mailbox_id,
+        )
+        .order_by(EmailLog.id.desc())
+        .first()
+    )
+
+    if r:
+        email_log, _, _ = r
+        now = arrow.now()
+        if (now - email_log.created_at).seconds < MIN_TIME_BETWEEN_ACTIVITY_PER_MAILBOX:
+            LOG.d(
+                "Too much forward on mailbox %s. Latest email log %s. Alias %s",
+                alias.mailbox,
+                email_log,
+                alias,
+            )
+            return True
+
+    return False
+
+
+def greylisting_needed_forward_phase(alias_address: str) -> bool:
+    alias = Alias.get_by(email=alias_address)
+
+    if alias:
+        return greylisting_needed_for_alias(alias) or greylisting_needed_for_mailbox(
+            alias
+        )
+
+    else:
+        LOG.d(
+            "alias %s not exist. Try to see if it can be created on the fly",
+            alias_address,
+        )
+        alias = try_auto_create(alias_address)
+        if alias:
+            return greylisting_needed_for_mailbox(alias)
+
+    return False
+
+
+def greylisting_needed_reply_phase(reply_email: str) -> bool:
+    contact = Contact.get_by(reply_email=reply_email)
+    if not contact:
+        return False
+
+    alias = contact.alias
+    return greylisting_needed_for_alias(alias) or greylisting_needed_for_mailbox(alias)
+
+
+def greylisting_needed(mail_from: str, rcpt_tos: [str]) -> bool:
+    for rcpt_to in rcpt_tos:
+        if rcpt_to.startswith("reply+") or rcpt_to.startswith("ra+"):
+            reply_email = rcpt_to.lower()
+            if greylisting_needed_reply_phase(reply_email):
+                return True
+        else:
+            # Forward phase
+            address = rcpt_to.lower()  # alias@SL
+            if greylisting_needed_forward_phase(address):
+                return True
+
+    return False

+ 8 - 0
email_handler.py

@@ -69,6 +69,7 @@ from app.email_utils import (
     get_orig_message_from_spamassassin_report,
 )
 from app.extensions import db
+from app.greylisting import greylisting_needed
 from app.log import LOG
 from app.models import (
     Alias,
@@ -792,6 +793,13 @@ def handle(envelope: Envelope, smtp: SMTP) -> str:
         LOG.d("Handle unsubscribe request from %s", envelope.mail_from)
         return handle_unsubscribe(envelope)
 
+    # Whether it's necessary to apply greylisting
+    if greylisting_needed(envelope.mail_from, envelope.rcpt_tos):
+        LOG.warning(
+            "Grey listing applied for %s %s", envelope.mail_from, envelope.rcpt_tos
+        )
+        return "421 SL Retry later"
+
     # result of all deliveries
     # each element is a couple of whether the delivery is successful and the smtp status
     res: [(bool, str)] = []

+ 85 - 0
tests/test_greylisting.py

@@ -0,0 +1,85 @@
+from app.extensions import db
+from app.greylisting import (
+    greylisting_needed_forward_phase,
+    greylisting_needed_for_alias,
+    greylisting_needed_for_mailbox,
+    greylisting_needed_reply_phase,
+)
+from app.models import User, Alias, EmailLog, Contact
+
+
+def test_greylisting_needed_forward_phase_for_alias(flask_client):
+    user = User.create(
+        email="a@b.c", password="password", name="Test User", activated=True
+    )
+    db.session.commit()
+
+    # no greylisting for a new alias
+    alias = Alias.create_new_random(user)
+    db.session.commit()
+    assert not greylisting_needed_for_alias(alias)
+
+    # greylisting when there's a previous activity on alias
+    contact = Contact.create(
+        user_id=user.id,
+        alias_id=alias.id,
+        website_email="contact@example.com",
+        reply_email="rep@sl.local",
+    )
+    db.session.commit()
+    EmailLog.create(user_id=user.id, contact_id=contact.id)
+    assert greylisting_needed_for_alias(alias)
+
+
+def test_greylisting_needed_forward_phase_for_mailbox(flask_client):
+    user = User.create(
+        email="a@b.c", password="password", name="Test User", activated=True
+    )
+    db.session.commit()
+
+    alias = Alias.create_new_random(user)
+    db.session.commit()
+
+    contact = Contact.create(
+        user_id=user.id,
+        alias_id=alias.id,
+        website_email="contact@example.com",
+        reply_email="rep@sl.local",
+    )
+    db.session.commit()
+    EmailLog.create(user_id=user.id, contact_id=contact.id)
+
+    # Create another alias with the same mailbox
+    # will be greylisted as there's a previous activity on mailbox
+    alias = Alias.create_new_random(user)
+    db.session.commit()
+    assert greylisting_needed_for_mailbox(alias)
+
+
+def test_greylisting_needed_forward_phase(flask_client):
+    # no greylisting when alias not exist
+    assert not greylisting_needed_forward_phase("not-exist@alias.com")
+
+
+def test_greylisting_needed_reply_phase(flask_client):
+    # no greylisting when reply_email not exist
+    assert not greylisting_needed_reply_phase("not-exist-reply@alias.com")
+
+    user = User.create(
+        email="a@b.c", password="password", name="Test User", activated=True
+    )
+    db.session.commit()
+
+    alias = Alias.create_new_random(user)
+    db.session.commit()
+
+    contact = Contact.create(
+        user_id=user.id,
+        alias_id=alias.id,
+        website_email="contact@example.com",
+        reply_email="rep@sl.local",
+    )
+    db.session.commit()
+    EmailLog.create(user_id=user.id, contact_id=contact.id)
+
+    assert greylisting_needed_reply_phase("rep@sl.local")