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)