On Sat, Feb 26, 2022 at 02:14:42PM -0700, Austin Witmer wrote:

> It is my understanding that header checks are processed line by line,

That understanding is correct.

> but I am seeing some behavior that makes me question that.

You've confused yourself by misinterpreting what you're seeing.

> For example, line #1 in my header_checks file uses
>
>   /^To: someaddr...@mydomain.com <mailto:someaddr...@mydomain.com>/ PASS

> This email can skip further header checks since it is destined for the
> correct address.

Efficiency considerations aside, the order of patterns in header_checks
is not relevant when they match distinct headers.  Only patterns that
can match multiple headers, or multiple patterns for the same header are
order-dependent.

> And line #2 in header_check is
>
> /^Content-Type: text\/plain/ REJECT

The order of these lines is irrelevant.  The only thing that matters is
the order in which the headers appear in a message.

> The above lines seem to work fine if the “To:” header comes before the
> “Content-Type” header in the email being sent,
                        -----------------------

Precisely.  Header checks happen one header at a time, in the order in
which they appear in the message.  Tha is, the headers form a "stream".
As each header is encountered, it is checked independently of the
others.  Postfix is not accumulating all the headers and then running
through the rules in header_checks file in rule order looking for
headers to match.

Again:

    for each header in header order:
        for each pattern in header_checks:
            if pattern match:
                perform the specified action.

NOT:

    for each pattern in header_checks:
        for each header in header order:
            if pattern match:
                perform the specified action.

> but if the “Content-Type” header comes before the “To” header in the
> email it gets rejected.

See above.

> I would have thought that the order of the email headers themselves
> doesn’t matter.

It does. What doesn't matter as much is the order of the patterns
in the header_checks file.

> It seems like Mac Mail is putting the “Content-Type” header before the
> “To” header in the email.
> 
> Any ideas or suggestions for me?

For global header checks that can act on any header independently of its
placement, you need a stateful check that acts after seeing all the
headers.  That's available via milters, but not header_checks.

Below, you'll find a stripped down milter skeleton in Python...  Doesn't
do anything, add your logic and test carefully.  Tempfail is best until
you're sure it is working.

-- 
    Viktor.

#!/usr/local/bin/python3
"""
pick_a_name - a milter service for postfix
"""

import sys
import os
import time
import re
import pwd
import grp
import signal
import Milter
import email.utils
import threading
import traceback
import argparse
import syslog as _syslog

try:
    # noinspection PyUnresolvedReferences
    import setproctitle
    setproctitle.setproctitle("yourmilter")
except ImportError:
    pass

from syslog import *
from syslog import syslog as syslog
from getopt import getopt
from email.header import decode_header
from threading import Thread
from queue import Queue

NAME = "pick_a_name"

# Defaults
BINDADDR = "[::1]"
PORT = 30072
MILTERUSER = "nobody"
MILTERGROUP = "nobody"
VERSION = "0.1.0"

__version__ = VERSION
__author__ = "Your Name Here <y...@example.org>"

# noinspection PyUnresolvedReferences
class Cfg(object):
    """Helper class for some configuration parameters"""

    action = Milter.REJECT
    hold = False
    workerQueue = Queue()


# noinspection PyIncorrectDocstring,PyUnresolvedReferences
class YourMilter(Milter.Base):
    """
    Milter that does something.
    """
    
    def __init__(self):
        self.__id = Milter.uniqueID()
        self.__ipname = None
        self.__ip = None
        self.__port = None
        self.__from = None
        self.__now = time.time()
        ...

    # noinspection PyUnusedLocal
    @Milter.noreply
    def connect(self, ipname, family, hostaddr):
        """connect callback """

        self.__ip = hostaddr[0]
        self.__ipname = ipname
        self.__port = hostaddr[1]

        if config.debug:
            print("id=%i connect from %s[%s]:%s" % (self.__id,
                                                    self.__ipname,
                                                    self.__ip,
                                                    self.__port))

        return Milter.CONTINUE

    # noinspection PyUnusedLocal
    @Milter.noreply
    def envfrom(self, mailfrom, *dummy):
        """Callback that is called when MAIL FROM: is recognized. This also
        is the most earliest time, where we can collect nearly all connection
        specific information.
        """
        self.__from = mailfrom
        return Milter.CONTINUE

    def header(self, name, hval):
        """header callback gets called for each header
        """
        if config.debug:
            print("%s: %s" % (name, hval))

        # Your Python code here.
        # ...
        #   self.setreply("454", xcode="4.7.0", msg="Too bad")
        #   return Milter.TEMPFAIL
        # ...
        #   self.setreply("554", xcode="5.7.0", msg="Too bad")
        #   return Milter.REJECT
        # ...

        return Milter.CONTINUE

    @Milter.noreply
    def eoh(self):
        """eoh - end of header. Gets called after all headers have been
        proccessed"""

        # Your Python code here.
        # ...
        #   self.setreply("454", xcode="4.7.0", msg="Too bad")
        #   return Milter.TEMPFAIL
        # ...
        #   self.setreply("554", xcode="5.7.0", msg="Too bad")
        #   return Milter.REJECT
        # ...

        return Milter.CONTINUE

# noinspection PyUnresolvedReferences
def runner():
    """Starts the milter loop"""

    Milter.factory = YourMilter

    # flags = Milter.CHGHDR
    Milter.set_flags(0)

    Milter.runmilter(NAME, config.socket, timeout=300)

# noinspection PyProtectedMember,PyUnresolvedReferences
if __name__ == "__main__":
    parser = argparse.ArgumentParser(epilog="pick_a_name - does something")

    parser.add_argument("--socket", "-s",
                        type=str,
                        default="inet6:{0}@{1}".format(PORT, BINDADDR),
                        help="IPv4, IPv6 or unix socket (default: %(default)s)")
    parser.add_argument("--syslog_name", "-n",
                        type=str,
                        default=NAME,
                        help="Syslog name (default: %(default)s)")
    parser.add_argument("--syslog_facility", "-N",
                        type=str,
                        default="mail",
                        help="Syslog facility (default: %(default)s)")
    parser.add_argument("--user", "-u",
                        type=str,
                        default=MILTERUSER,
                        help="Run milter as this user (default: %(default)s)")
    parser.add_argument("--group", "-g",
                        type=str,
                        default=MILTERGROUP,
                        help="Run milter with this group "
                             "(default: %(default)s)")
    parser.add_argument("--pid", "-p",
                        type=str,
                        default=None,
                        help="Path for an optional PID file")
    parser.add_argument("--debug", "-d",
                        default=False,
                        action="store_true",
                        help="Run in foreground with debugging turned on")

    config = parser.parse_args()

    facility_name = "LOG_" + config.syslog_facility.upper()
    if config.debug:
        print("Log facility_name: {}".format(facility_name))
    facility = getattr(_syslog, facility_name, LOG_MAIL)
    if config.debug:
        print("Log facility: {}".format(facility))

    openlog(config.syslog_name, LOG_PID, facility)

    try:
        uid = pwd.getpwnam(config.user)[2]
        gid = grp.getgrnam(config.group)[2]
    except KeyError as e:
        print("User or group not known: {0}".format(e.message), file=sys.stderr)
        sys.exit(1)

    if config.debug:
        print("Staying in foreground...")
    else:
        try:
            pid = os.fork()
        except OSError as e:
            print("First fork failed: (%d) %s" % (e.errno, e.strerror),
                  file=sys.stderr)
            sys.exit(1)
        if pid == 0:
            os.setsid()
            try:
                pid = os.fork()
            except OSError as e:
                print("Second fork failed: (%d) %s" % (e.errno, e.strerror),
                      file=sys.stderr)
                sys.exit(1)
            if pid == 0:
                os.chdir("/")
                os.umask(0)
            else:
                # noinspection PyProtectedMember
                os._exit(0)
        else:
            # noinspection PyProtectedMember
            os._exit(0)
    
        # In daemon mode, we redirect stdin, stdout and stderr to /dev/null   
        sys.stdin = open(os.devnull, "r").fileno()
        sys.stdout = open(os.devnull, "w").fileno()
        sys.stderr = open(os.devnull, "w").fileno()
    
    try:
        if config.pid:
            with open(config.pid, "w") as fd:
                fd.write(str(os.getpid()))
    except IOError as e:
        if config.debug:
            print("Cannot create PID file: (%d) %s" % (e.errno, e.strerror),
                  file=sys.stderr)

    try:
        # Needs Python >=2.7
        os.initgroups(config.user, gid)
    except Exception as _:
        pass

    try:
        os.setgid(gid)
    except OSError as e:
        print('Could not set effective group id: %s' % e, file=sys.stderr)
        sys.exit(1)
    try:
        os.setuid(uid)
    except OSError as e:
        print('Could not set effective user id: %s' % e, file=sys.stderr)
        sys.exit(1)

    def finish(signum, frame):
        _ = frame
        syslog(LOG_NOTICE,
               "%s-%s milter shutdown. Caught signal %d"
               % (NAME, VERSION, signum))

    signal.signal(signal.SIGINT, finish)
    signal.signal(signal.SIGQUIT, finish)
    signal.signal(signal.SIGTERM, finish)

    signal.siginterrupt(signal.SIGHUP, False)
    signal.siginterrupt(signal.SIGUSR1, False)
    
    syslog(LOG_NOTICE, "%s-%s milter startup" % (NAME, VERSION))

    milter_t = Thread(target=runner)
    milter_t.daemon = True
    milter_t.start()

    # Waiting for SIGNAL to terminate process
    signal.pause()

    try:
        if config.pid and os.path.exists(config.pid):
            os.unlink(config.pid)
    except IOError as e:
        if config.debug:
            print("Cannot remove PID file: (%d) %s" % (e.errno, e.strerror),
                  file=sys.stderr)
        sys.exit(1)

    sys.exit(0)

Reply via email to