Postfix milter: ضبط Reply-To بشكل موثوق دون الإضرار بتسليم الرسائل

لماذا نستخدم milter

يستطيع cleanup وheader_checks في Postfix إعادة كتابة الترويسات، لكن الـ milter يمنحك تحكماً دقيقاً وقابلاً للبرمجة أثناء جلسة SMTP، ويمكنه تعديل الترويسات أو إضافتها بأمان قبل تشغيل مراحل التوقيع والتسليم. ويتيح لك milter مكتوب بلغة Python فحص الترويسات الواردة كما هي تماماً، واحتساب عنوان Reply-To موحّد، وضمان سلوك متسق بين الرسائل المرسلة عبر SMTP والرسائل المحلية.

باستخدام كود Python التالي، سنقرأ ترويسة From الأصلية ونضيفها إلى Reply-To

#!/usr/bin/env python3
import sys, os, re, email
import logging
from email.parser import BytesParser
from email.policy import default

#from Milter import Milter

import Milter

BaseClass = getattr(Milter, 'Base', getattr(Milter, 'Milter', None))

assert BaseClass is not None, "pymilter not installed (no Base/Milter class)"

BRAND = 'brandmail'
FROM_SENDER = 'mail@brandmail.io'  # must be approved by your relay
LOG_LEVEL = os.environ.get('IM_LOG_LEVEL', 'INFO').upper()

logging.basicConfig(level=getattr(logging, LOG_LEVEL, logging.INFO), format='%(asctime)s %(levelname)s %(message)s')
addr_spec_re = re.compile(r'([^<,]+@[^>,\s]+)')

class BrandMilter(BaseClass):
    def __init__(self):
        super().__init__()
        self.msg_headers = []
        self.raw_from = None
    def header(self, name, value):
        # Record headers; capture first From we see
        if name.lower() == 'from' and self.raw_from is None:
            self.raw_from = value
        # Keep all headers for later rewrite
        self.msg_headers.append((name, value))
        return Milter.CONTINUE
    def _id(self):
        try:
            return self.idstr()
        except Exception:
            return "unknown-id"
    def eom(self):
        try:            
            # Build minimal header block for parsing
            hdr_bytes = b''.join([f"{k}: {v}\r\n".encode('utf-8') for k, v in self.msg_headers])
            raw = hdr_bytes + b"\r\n"

            msg = BytesParser(policy=default).parsebytes(raw) or {}

            # Extract first mailbox from original From
            reply_to_addr = (self.raw_from or (msg.get('From') if hasattr(msg, 'get') else '') or '')

            # Force approved From
            final_from = f'"{BRAND}" <{FROM_SENDER}>'

            # Ensure single Reply-To if available
            if reply_to_addr:
                if hasattr(msg, 'get') and msg.get('Reply-To'):
                    combined = f"{msg.get('Reply-To')}, {reply_to_addr}"
                    msg.replace_header('Reply-To', combined)
                else:
                    msg['Reply-To'] = reply_to_addr

            # Force approved From
            if hasattr(msg, 'get') and msg.get('From'):
                # Don't replace From because we will do it in the header_checks
                # msg.replace_header('From', final_from)
                pass
            else:
                msg['From'] = final_from

            # Remove existing headers once per unique name
            seen = set()
            removed_count = 0
            for k, _ in self.msg_headers:
                kl = k.lower()
                if kl in seen:
                    continue
                seen.add(kl)
                try:
                    self.chgheader(k, 0, None)
                    removed_count += 1
                except Exception as ex:
                    logging.warning("%s failed to remove header %s: %r", self._id(), k, ex)


            # Re-add normalized headers
            added_count = 0
            for k, v in (msg.items() if hasattr(msg, 'items') else []):
                try:
                    self.addheader(k, str(v))
                    added_count += 1
                except Exception as ex:
                    pass

            return Milter.CONTINUE

        except Exception as e:
            # Never propagate exceptions to Postfix; log and continue
            logging.error("%s eom exception: %r", self._id(), e)

    # Utility to get body bytes accumulated by libmilter (may be empty)
    def getbody(self):
        # Milter.Base doesn’t buffer body by default; for header-only edits,
        # returning empty body is fine; many MTAs provide headers before body.
        return b''

def main():
    sock_dir = "/var/spool/postfix/var/run/pymilter"
    sock_path = f"{sock_dir}/brandmail.sock"
    os.makedirs(sock_dir, mode=0o750, exist_ok=True)
    try:
        os.unlink(sock_path)
    except FileNotFoundError:
        pass
    except OSError as e:
        logging.error("Could not unlink socket %s: %s", SOCK_PATH, e)
        raise
    # Register class and run milter
    Milter.factory = BrandMilter
    logging.info("Starting milter on unix:%s", sock_path)
    Milter.runmilter("brandmail-milter", f"unix:{sock_path}", 10)

if __name__ == "__main__":
    main()

يمكنك حفظ الملف في /opt/pymilter/milter.py أو في أي مكان آخر تفضّله (مع منحه صلاحية التنفيذ عبر chmod +x)

سيضمن ذلك أن ينشئ Python مقبس socket في المسار /var/spool/postfix/var/run/pymilter (تأكد من وجود المجلد)، والآن أنشئ الخدمة بحسب نظام التشغيل لديك، والخدمة أدناه مخصّصة لـ Ubuntu 20

[Unit]
Description=Postfix Python Milter
After=network.target

[Service]
Type=simple
User=postfix
Group=postfix
ExecStart=/opt/pymilter/venv/bin/python /opt/pymilter/milter.py
RuntimeDirectory=pymilter
RuntimeDirectoryMode=0750
UMask=0027
Restart=on-failure

[Install]
WantedBy=multi-user.target

الآن وجّه إعداد smtpd_milters في Postfix إلى مسار المقبس:

milter_default_action = accept
smtpd_milters = unix:/var/run/pymilter/brandmail.sock
non_smtpd_milters = unix:/var/run/pymilter/brandmail.sock

لم نضف "/var/spool/postfix" إلى المسار لأن Postfix يعمل داخل بيئة chroot هناك بحسب نظام التشغيل، فإذا ظهر تحذير من Postfix بشأن مسار المقبس، فأضف "/var/spool/postfix" في بدايته

أعد تحميل الخدمة وPostfix وستكون جاهزاً تماماً! بالتوفيق!