]> git.ipfire.org Git - ipfire.org.git/commitdiff
accounts: Process any incoming bounces and store with the user
authorMichael Tremer <michael.tremer@ipfire.org>
Tue, 20 Jan 2026 16:48:46 +0000 (16:48 +0000)
committerMichael Tremer <michael.tremer@ipfire.org>
Tue, 20 Jan 2026 16:48:46 +0000 (16:48 +0000)
Signed-off-by: Michael Tremer <michael.tremer@ipfire.org>
src/backend/accounts.py
src/backend/base.py
src/backend/messages.py

index e168580b974bf4e796e1a17b18f78b99b2f03164..ae248a1d9609ccf791b9e9a227ca9101df5a8600 100644 (file)
@@ -29,6 +29,9 @@ from . import util
 from .decorators import *
 from .misc import Object
 
+# Setup logging
+log = logging.getLogger(__name__)
+
 # Set the client keytab name
 os.environ["KRB5_CLIENT_KTNAME"] = "/etc/ipfire.org/ldap.keytab"
 
@@ -737,7 +740,6 @@ class Accounts(Object):
                return self.backend.groups.get_by_gid("promotional-consent")
 
 
-
 class Account(LDAPObject):
        def __str__(self):
                if self.nickname:
@@ -1609,6 +1611,108 @@ class Account(LDAPObject):
                # Disable the user
                await user.disable(text)
 
+       # Bounces
+
+       def process_bounce(self, msg):
+               """
+                       Called when a bounce has been received for this account
+               """
+               # Determine the bounce score
+               score = self._determine_bounce_score(msg)
+
+               # Log action
+               log.info("Received bounce message for %s (%s) with score %s" % (self, self.uid, score))
+
+               # Store the bounce message
+               self.db.execute("""
+                       INSERT INTO
+                               account_bounces
+                       (
+                               uid, score, message
+                       )
+                       VALUES
+                       (
+                               %s, %s, %s
+                       )
+                       """, self.uid, score, msg.as_string(),
+               )
+
+               # Fetch the total bounce score
+               total_score = self.get_bounce_score()
+
+               # Log the total score
+               log.info("%s (%s) has a total bounce score of %s" % (self, self.uid, total_score))
+
+       def _determine_bounce_score(self, msg):
+               """
+                       Returns the bounce score from the message
+               """
+               # We can only process multi-part messages
+               if not msg.is_multipart():
+                       return 3
+
+               # Walk through all parts
+               for part in msg.walk():
+                       # Fetch the content type
+                       content_type = part.get_content_type()
+
+                       # We only care about delivery status notifications
+                       if not content_type == "message/delivery-status":
+                               continue
+
+                       # Fetch the payload
+                       payload = part.get_payload()
+
+                       # We would expect a couple of lists here
+                       if not isinstance(payload, list):
+                               break
+
+                       # Walk through all blocks in the payload
+                       for i, block in enumerate(payload):
+                               # The first block contains message information we don't care about
+                               if i == 0:
+                                       continue
+
+                               # Fetch the status
+                               status = block.get("Status")
+
+                               # Fail if we don't have a status
+                               if not status:
+                                       break
+
+                               # Hard bounce
+                               elif status.startswith("5."):
+                                       return 3
+
+                               # Soft bounce
+                               elif status.startswith("4."):
+                                       return 1
+
+               # If we cannot be sure, we return a hard bounce
+               return 3
+
+       def get_bounce_score(self, days=60):
+               """
+                       Returns the current bounce score of this account
+               """
+               cutoff = datetime.timedelta(days=days)
+
+               # Fetch the score
+               res = self.db.get("""
+                       SELECT
+                               SUM(score) AS score
+                       FROM
+                               account_bounces
+                       WHERE
+                               uid = %s
+                       AND
+                               received_at >= CURRENT_TIMESTAMP - %s
+                       """, self.uid, cutoff,
+               )
+
+               # Return the score or zero
+               return res.score if res else 0
+
 
 class Groups(Object):
        hidden_groups = (
index 0c2086a5a19b86d6b62e937c983d1dc04e4a7d32..1d86906d912e558aab90af03a6dc487e13d4b771 100644 (file)
@@ -169,6 +169,7 @@ class Backend(object):
                        "scan-files"          : self.releases.scan_files,
                        "send-message"        : self.messages.send_cli,
                        "send-all-messages"   : self.messages.queue.send_all,
+                       "recv-message"        : self.messages.recv,
                        "test-ldap"           : self.accounts.test_ldap,
                        "toot"                : self.toots.toot,
                        "update-blog-feeds"   : self.blog.update_feeds,
index 30c90c27c4a97ff2377ded4b3a6b58c5c466fa54..681457dfbc41520e247bb5e84a5ffd0bdd8b634a 100644 (file)
@@ -5,6 +5,7 @@ import datetime
 import email
 import email.mime.multipart
 import email.mime.text
+import email.policy
 import email.utils
 import logging
 import mimetypes
@@ -14,6 +15,7 @@ import random
 import smtplib
 import socket
 import subprocess
+import sys
 import tornado.locale
 import tornado.template
 
@@ -22,6 +24,9 @@ from . import misc
 from . import util
 from .decorators import *
 
+# Setup logging
+log = logging.getLogger(__name__)
+
 # Encode emails in UTF-8 by default
 email.charset.add_charset("utf-8", email.charset.SHORTEST, email.charset.QP, "utf-8")
 
@@ -248,6 +253,67 @@ class Messages(misc.Object):
 
                return self.send_template(template, **kwargs)
 
+       # Receive a message
+
+       async def recv(self):
+               """
+                       Receives an email and then tries to do something useful with it
+               """
+               # Parse the message
+               msg = email.message_from_file(sys.stdin, policy=email.policy.default)
+
+               # Handle bounces
+               if self._is_bounce(msg):
+                       self._recv_bounce(msg)
+
+       def _is_bounce(self, msg):
+               """
+                       Checks and returns True if the given message is a bounce message
+               """
+               recipient = msg.get("To")
+
+               # If this being sent to our bounces address,
+               # this is likely going to be a bounce message.
+               if recipient.startswith("bounces+"):
+                       return True
+
+               # This does not seem to be a bounce message
+               return False
+
+       def _recv_bounce(self, msg):
+               """
+                       We have received a bounce message...
+               """
+               log.debug("Received bounce message:\n%s" % msg)
+
+               # Fetch the recipient
+               recipient = msg.get("To")
+
+               # Remove the prefix
+               recipient = recipient.removeprefix("bounces+")
+
+               # Split off the domain
+               recipient, _, domain = recipient.rpartition("@")
+
+               # Decode the email address
+               recipient = recipient.replace("=", "@")
+
+               # Fail if there is no recipient
+               if not recipient:
+                       log.error("Failed to parse the recipient '%s'" % msg.get("To"))
+                       return
+
+               # Find the account
+               account = self.backend.accounts.get_by_mail(recipient)
+
+               # Silently fail if we could not find an account (might have been deleted already)
+               if not account:
+                       log.debug("Could not find an account matching '%s'" % recipient)
+                       return
+
+               # Pass the message to the account
+               account.process_bounce(msg)
+
 
 class Queue(misc.Object):
        @property