From: Michael Tremer Date: Tue, 20 Jan 2026 16:48:46 +0000 (+0000) Subject: accounts: Process any incoming bounces and store with the user X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=980f8e50e8414820bb73d7171b5706aedaa5ff3d;p=ipfire.org.git accounts: Process any incoming bounces and store with the user Signed-off-by: Michael Tremer --- diff --git a/src/backend/accounts.py b/src/backend/accounts.py index e168580b..ae248a1d 100644 --- a/src/backend/accounts.py +++ b/src/backend/accounts.py @@ -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 = ( diff --git a/src/backend/base.py b/src/backend/base.py index 0c2086a5..1d86906d 100644 --- a/src/backend/base.py +++ b/src/backend/base.py @@ -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, diff --git a/src/backend/messages.py b/src/backend/messages.py index 30c90c27..681457df 100644 --- a/src/backend/messages.py +++ b/src/backend/messages.py @@ -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