Sam Leiton

Desarrollador en AI, Servidores y Apps Web | Innovación y Tecnología a tu Alcance

Plesk/Postfix + SES + Forwards + Antispam

A while back, I created a service using PHP’s Laravel to handle emails via Lambda, S3, and SES. However, SES wasn’t as effective against anti-spam, and other configurations were a disaster. Perhaps most of it was my own fault, as I didn’t know much. So, I took it upon myself to learn properly and managed to implement Plesk + SES + Forward (Plesk and Devecot-Sieve).

  • I implemented local anti-spam using rspamd.
  • Using SES SMTP.
  • Successful forwards configured in Plesk.
  • Successful forwards configured in Roundcube WebMail Filters (Sieve).

Additional Information: RAM and CPU Recommendations

This depends greatly on email volume, but for a stable server:

Email Volume Minimum Recommended RAM Recommended CPU
Low (less than 500 emails/day) 2 GB (absolute minimum for Plesk and basic filters) 1 vCPU
Medium (500–5,000 emails/day) 4 GB 2 vCPU
High (more than 5,000 emails/day or with heavy filtering) 8 GB or more 4 vCPU

Rule of thumb: Every serious antispam service (SpamAssassin, Rspamd) consumes memory, and Plesk itself already consumes 800 MB–1 GB just when idle.
If you enable antivirus ClamAV, 4 GB is the minimum you’ll be comfortable with.

How add Rspamd Anti-Spam : Enhance Plesk with Postfix Anti-Spam Using Rspamd

Well then let’s get started:

We know that SEA doesn’t allow unverified emails to be sent, only those with a verified domain or specific verified emails. We have two mail forwarding cases.

  • Plesk
  • Dovecot Sieve

With Plesk use SRS
Why “SRS=”?
hen an email is forwarded using the Sender Rewriting Scheme (SRS), the sender’s address is rewritten to ensure proper delivery and prevent SPF failures. The rewritten address often starts with “SRS0=” followed by a hash value, timestamp (if applicable), and the original sender’s domain and local part. For example, an email originally from alice@example.com forwarded by a server using SRS might appear as SRS0=XYZ=example.com=alice@forwardingdomain.com

And Dovecot use Sieve Headers

  • X-Sieve: Pigeonhole Sieve 0.5.21.1 (49005e73)
  • X-Sieve-Redirected-From: mail@domain.com

The X-Sieve-Redirected-From header is added by Dovecot’s Pigeonhole Sieve when forwarding emails. It indicates the original sender of the message before it was forwarded. This header helps identify that a mail has been automatically forwarded and can be useful for debugging or identifying the origin of a message. 

In both cases, SES will deny them.

said: 554 Message rejected: Email address is not verified.

So let’s create a way to manipulate emails before they are sent.

#Let's create a file
nano /usr/local/bin/mailfilter.py

Make sure it is executable:

chmod +x /usr/local/bin/mailfilter.py

Code:

import smtpd
import asyncore
import smtplib
import traceback
import email
import re
from email.policy import default
import logging
import os
import time
import uuid

# --- Configuration ---
logging.basicConfig(level=logging.INFO, format='[%(asctime)s] %(message)s', datefmt='%Y-%m-%d %H:%M:%S')

LISTEN_HOST = '127.0.0.1'
LISTEN_PORT = 10025
REINJECT_HOST = '127.0.0.1'
REINJECT_PORT = 10026

FROM_ADDR_FORWARD = "forward@yourdomain.com"
SRS_DOMAIN = "yourdomain.com"

# Regex to detect SRS
srs_re = re.compile(r'^SRS0=[^=]+=[^=]+=([^=]+)=([^@]+)@' + re.escape(SRS_DOMAIN) + r'$')

# Option to save unmodified emails
SAVE_UNMODIFIED = False
SAVE_DIR = "/var/log/saved_emails"

if SAVE_UNMODIFIED:
    os.makedirs(SAVE_DIR, exist_ok=True)

class CustomSMTPServer(smtpd.SMTPServer):

    def process_message(self, peer, mailfrom, rcpttos, data, **kwargs):
        logging.info(f"New message received from {peer} from {mailfrom} for {rcpttos}")

        # By default, the email is reinjected as is
        data_to_reinject = data
        envelope_from_for_reinject = mailfrom
        
        try:
            # Parse the message once for analysis
            msg = email.message_from_bytes(data, policy=default)
            srs_match = srs_re.match(mailfrom)

            # --- CASE 1: Plesk Forward (detected by SRS) ---
            if srs_match:
                logging.info("Plesk Forward (SRS) detected. Applying modifications...")

                # Original sender from SRS
                original_sender = f"{srs_match.group(2)}@{srs_match.group(1)}"
                logging.info(f"Original sender extracted from SRS: {original_sender}")

                # Modify From and Reply-To
                del msg['From']
                msg['From'] = FROM_ADDR_FORWARD
                if 'Reply-To' in msg:
                    del msg['Reply-To']
                msg['Reply-To'] = original_sender

                # Remove DKIM signatures to prevent validation failures
                while 'DKIM-Signature' in msg:
                    del msg['DKIM-Signature']

                data_to_reinject = msg.as_bytes()
                envelope_from_for_reinject = FROM_ADDR_FORWARD

            ### NEW ###
            # --- CASE 2: Dovecot Copy (detected by Sieve header) ---
            elif 'X-Sieve-Redirected-From' in msg:
                logging.info("Sieve (Dovecot) copy detected. Applying modifications...")
                
                # The account with the Sieve filter is our "Reply-To"
                sieve_recipient = msg['X-Sieve-Redirected-From']
                logging.info(f"Original account with Sieve filter: {sieve_recipient}")

                # Apply the same logic: change From and add Reply-To
                del msg['From']
                msg['From'] = FROM_ADDR_FORWARD
                if 'Reply-To' in msg:
                    del msg['Reply-To']
                msg['Reply-To'] = sieve_recipient

                # Remove DKIM signatures to prevent validation failures
                while 'DKIM-Signature' in msg:
                    del msg['DKIM-Signature']

                data_to_reinject = msg.as_bytes()
                envelope_from_for_reinject = FROM_ADDR_FORWARD

            # --- CASE 3: Standard Email (no modifications) ---
            else:
                logging.info("Standard email detected. Passing through without modifications.")
                
                if SAVE_UNMODIFIED:
                    filename = f"{int(time.time())}_{uuid.uuid4().hex}.eml"
                    filepath = os.path.join(SAVE_DIR, filename)
                    with open(filepath, "wb") as f:
                        f.write(data)
                    logging.info(f"📁 Unmodified email saved to: {filepath}")

            # --- Re-injection of the email (modified or not) ---
            logging.info(f"Connecting to {REINJECT_HOST}:{REINJECT_PORT} for re-injection...")
            with smtplib.SMTP(REINJECT_HOST, REINJECT_PORT, local_hostname=LISTEN_HOST) as server:
                server.sendmail(envelope_from_for_reinject, rcpttos, data_to_reinject)
                logging.info(f"✅ Email successfully re-injected with envelope from: {envelope_from_for_reinject}")

        except smtplib.SMTPException as e:
            logging.error(f"❌ SMTP error during re-injection: {e}")
            logging.error(traceback.format_exc())
        except Exception as e:
            logging.error(f"❌ Error processing message: {e}")
            logging.error(traceback.format_exc())

        logging.info("Process finished, returning 250 OK to the original client.")
        return


if __name__ == '__main__':
    logging.info(f"Starting SMTP server on {LISTEN_HOST}:{LISTEN_PORT}")
    server = CustomSMTPServer((LISTEN_HOST, LISTEN_PORT), None)
    try:
        asyncore.loop()
    except KeyboardInterrupt:
        logging.info("Server stopped by user.")
        server.close()

What It Does ?

Think of this script as a smart traffic cop for emails on your server. It listens on one network port (10025) for incoming mail. When an email arrives, it inspects it to see if it’s one of two special types. After inspection, it sends the email to another port (10026) to continue its journey.


How It Works

The script checks each email for specific clues to decide what to do:

  1. Plesk Forward Check: It first looks at the “envelope from” address. If this address starts with SRS0=, the script knows it’s a forward from Plesk’s system. It then rewrites the email’s From: header to a generic address (forward@domain.com) and sets the Reply-To: header to the original sender’s address.
  2. Sieve Copy Check: If the first check fails, it then looks inside the email’s headers for X-Sieve-Redirected-From. The presence of this header means the email is a copy created by a user’s Sieve filter in Dovecot. It performs the same action: it changes the From: header and sets the Reply-To: header to the address of the user who owns the filter.
  3. Standard Email: If neither of the above conditions is met, the script considers it a normal email and does nothing to it. It simply passes it along unchanged.

In cases 1 and 2, the script also removes the DKIM-Signature header. This is important because modifying the From: header would break the signature and could cause the email to be marked as spam and if the email has a DKIM-Signature the SES responses with:

said: 554 Transaction failed: Duplicate header 'DKIM-Signature'.

If you Have multi Sites and Emails Domains:

import smtpd
import asyncore
import smtplib
import traceback
import email
import re
from email.policy import default
import logging
import os
import time
import uuid

# --- Configuration ---
logging.basicConfig(level=logging.INFO, format='[%(asctime)s] %(message)s', datefmt='%Y-%m-%d %H:%M:%S')

LISTEN_HOST = '127.0.0.1'
LISTEN_PORT = 10025
REINJECT_HOST = '127.0.0.1'
REINJECT_PORT = 10026

# Option to save unmodified emails for auditing
SAVE_UNMODIFIED = False
SAVE_DIR = "/var/log/saved_emails"

if SAVE_UNMODIFIED:
    os.makedirs(SAVE_DIR, exist_ok=True)

# Generic regex to detect SRS format (no fixed domain)
srs_re = re.compile(r'^SRS0=[^=]+=[^=]+=([^=]+)=([^@]+)@([^>]+)$')

class CustomSMTPServer(smtpd.SMTPServer):

    def process_message(self, peer, mailfrom, rcpttos, data, **kwargs):
        logging.info(f"📨 New message from {peer}, envelope from {mailfrom}, to {rcpttos}")

        # By default, re-inject without changes
        data_to_reinject = data
        envelope_from_for_reinject = mailfrom

        try:
            msg = email.message_from_bytes(data, policy=default)
            srs_match = srs_re.match(mailfrom)

            # --- CASE 1: Plesk Forward (detected by SRS format) ---
            if srs_match:
                logging.info("🔄 Plesk forward (SRS) detected")

                original_sender = f"{srs_match.group(2)}@{srs_match.group(1)}"
                srs_domain = srs_match.group(3)
                from_addr_forward = f"forward@{srs_domain}"

                logging.info(f"📝 Detected domain: {srs_domain}")
                logging.info(f"📧 Original sender extracted from SRS: {original_sender}")

                # Modify From and add Reply-To
                del msg['From']
                msg['From'] = from_addr_forward
                msg['Reply-To'] = original_sender

                # Remove DKIM signatures to avoid verification issues
                while 'DKIM-Signature' in msg:
                    del msg['DKIM-Signature']

                data_to_reinject = msg.as_bytes()
                envelope_from_for_reinject = from_addr_forward

            # --- CASE 2: Sieve Forward (detected by header) ---
            elif 'X-Sieve-Redirected-From' in msg:
                sieve_sender = msg['X-Sieve-Redirected-From']
                sieve_domain = sieve_sender.split('@')[-1]
                from_addr_forward = f"forward@{sieve_domain}"

                logging.info("📄 Sieve forward detected")
                logging.info(f"📝 Detected domain: {sieve_domain}")
                logging.info(f"📧 Sieve account: {sieve_sender}")

                # Modify From and add Reply-To
                del msg['From']
                msg['From'] = from_addr_forward
                msg['Reply-To'] = sieve_sender

                # Remove DKIM signatures to avoid verification issues
                while 'DKIM-Signature' in msg:
                    del msg['DKIM-Signature']

                data_to_reinject = msg.as_bytes()
                envelope_from_for_reinject = from_addr_forward

            # --- CASE 3: Normal email (no modifications) ---
            else:
                logging.info("✉️ Standard email, passing without modifications")
                if SAVE_UNMODIFIED:
                    filename = f"{int(time.time())}_{uuid.uuid4().hex}.eml"
                    filepath = os.path.join(SAVE_DIR, filename)
                    with open(filepath, "wb") as f:
                        f.write(data)
                    logging.info(f"📁 Saved unmodified email to: {filepath}")

            # --- Re-inject message ---
            logging.info(f"🚀 Re-injecting to {REINJECT_HOST}:{REINJECT_PORT}")
            with smtplib.SMTP(REINJECT_HOST, REINJECT_PORT, local_hostname=LISTEN_HOST) as server:
                server.sendmail(envelope_from_for_reinject, rcpttos, data_to_reinject)
            logging.info(f"✅ Email re-injected with envelope-from: {envelope_from_for_reinject}")

        except Exception as e:
            logging.error(f"❌ Error processing message: {e}")
            logging.error(traceback.format_exc())

        return

if __name__ == '__main__':
    logging.info(f"Starting SMTP server on {LISTEN_HOST}:{LISTEN_PORT}")
    server = CustomSMTPServer((LISTEN_HOST, LISTEN_PORT), None)
    try:
        asyncore.loop()
    except KeyboardInterrupt:
        logging.info("Server stopped by user")
        server.close()

Now, Lets create a daemon service

Create a file:

nano /etc/systemd/system/smtplocal.service

add:

# /etc/systemd/system/smtplocal.service
[Unit]
Description=Python SMTP Local Forwarder After=network.target
[Service]
Type=simple
#User=root  
ExecStart=/usr/bin/python3 /usr/local/bin/mailfilter.py #single domain
#ExecStart=/usr/bin/python3 /usr/local/bin/mailfilter_multidomains.py #multi domain - auto detect domain forward
Restart=always
RestartSec=1
StandardOutput=syslog
StandardError=syslog
SyslogIdentifier=smtplocal

[Install]
WantedBy=multi-user.target

Save, and now reload daemon:

sudo systemctl daemon-reload

Now, Start the service

sudo systemctl start smtplocal.service

Check the status

sudo systemctl status smtplocal.service

We’re not done yet, we still need to get it working with Postfix.

So far, our service is working. The service port is 10025 (you can change it, but remember to adapt it later).
Now we’ll need to edit Postfix’s master.cf file. Here, we’ll add two new services. One will send incoming mail to our local smtp service, which works on port 10025. Then, we’ll create another service that will redirect mail to Postfix so it can continue its journey.

Lets edit master.cf

nano /etc/postfix/master.cf

Now add, this will help us send the email to our smtplocal service that we created.

scan      unix  -       -       n       -      10       smtp
  -o smtp_send_xforward_command=yes
  -o disable_mime_output_conversion=yes
  -o smtp_tls_security_level=none
  -o smtpd_tls_security_level=none
  -o smtpd_client_restrictions=permit_mynetworks,reject

And now the service that receives the mail modified by our service.

127.0.0.1:10026      inet      n      -      n      -      -      smtpd
  -o content_filter=
  -o receive_override_options=no_unknown_recipient_checks,no_header_body_checks
  -o smtpd_helo_required=no
  -o smtpd_recipient_restrictions=permit_mynetworks,reject
  -o mynetworks=127.0.0.0/8
  -o smtpd_sender_restrictions=permit_mynetworks,reject
  -o smtpd_authorized_xforward_hosts=127.0.0.0/8

Okay, now we can save changes and close.

Now we’ll modify main.cf
Here we’ll tell Postfix to send emails to our local smtp service using content_filter

nano /etc/postfix/main.cf

Add it at the end ( Remember that the smtplocal.service service we created uses port 10025, so if you changed it you should also use the one you chose. )

content_filter = scan:localhost:10025

Yea, now save and close, restart postfix

systemctl restart postfix

Now, in your webmail like Roundcube can try with a filter

Plesk/Dovecot Sieve Forward Test:

Filter:

Plesk Forward:

Result:

Remember that due to the “Reply-To” header, replies you make to the forward will be directed to the original From address before smtplocal.service modified it. The forward@youdomain.com email address will not receive those replies.

Leave a Reply

Your email address will not be published. Required fields are marked *.

*
*