diff --git a/README.md b/README.md index ebcc178..d90e816 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,8 @@ The following configuration options are optional for each rule: As described above in the [Global](#Global) section. * **log** As described above in the [Global](#Global) section. +* **pretend** + Just pretend to make the modifications, for test purposes. ### Modifications The following configuration options are mandatory for each modification: @@ -52,6 +54,7 @@ The following configuration options are mandatory for each modification: * **add_header** * **del_header** * **mod_header** + * **add_disclaimer** The following configuration options are mandatory based on the modification type in use. * **add_header** @@ -72,6 +75,24 @@ The following configuration options are mandatory based on the modification type * **value** New value of the header. +* **add_disclaimer** + * **action** + Action to perform with the disclaimer. Possible values are: + * append + * prepend + * **html_template** + Path to a file that contains the html representation of the disclaimer. + * **text_template** + Path to a file that contains the text representation of the disclaimer. + * **error_policy** + Set what should be done if the disclaimer could not be added (e.g. no body text found). Possible values are: + * wrap + The original e-mail will be attached to a new one containing the disclaimer. + * ignore + Ignore the error and do nothing. + * reject + Reject the e-mail. + The following configuration options are optional for each modification: * **name** Name of the modification. @@ -81,11 +102,13 @@ The following configuration options are optional for each modification: ### Conditions The following condition options are optional: * **local** - If set to true, the rule is only executed for emails originating from addresses defined in local_addrs and vice versa. + If set to true, the rule is only executed for e-mails originating from addresses defined in local_addrs and vice versa. * **hosts** A list of hosts and network addresses for which the rule should be executed. * **envfrom** A regular expression to match against the evenlope-from addresses for which the rule should be executed. +* **envto** + A regular expression to match against all evenlope-to addresses. All addresses must match to fulfill the condition. ## Developer information Everyone who wants to improve or extend this project is very welcome. diff --git a/docs/pymodmilter.conf.example b/docs/pymodmilter.conf.example index 8d329eb..51874ee 100644 --- a/docs/pymodmilter.conf.example +++ b/docs/pymodmilter.conf.example @@ -75,7 +75,14 @@ # Notes: Condition wheter the envelop-from address matches this regular expression. # Value: [ REGEX ] # - "envfrom": "^(?!.+@mycompany\\.com).+$" + "envfrom": "^.+@mypartner\\.com$", + + # Option: envto + # Type: String + # Notes: Condition wheter the envelop-to address matches this regular expression. + # Value: [ REGEX ] + # + "envto": "^postmaster@.+$" }, # Section: modifications @@ -109,7 +116,7 @@ # Notes: Value of the header. # Value: [ VALUE ] # - "value": "true" + "value": "true", }, { "name": "modify_subject", @@ -145,8 +152,47 @@ # Value: [ REGEX ] # "header": "^Received:" + }, { + "name": "add_disclaimer", + + "type": "add_disclaimer", + + # Option: action + # Type: String + # Notes: Action to perform with the disclaimer. + # Value: [ append | prepend ] + # + "action": "prepend", + + # Option: html_template + # Type: String + # Notes: Path to a file that contains the html representation of the disclaimer. + # Value: [ FILE_PATH ] + # + "html_template": "/etc/pymodmilter/templates/disclaimer_html.template", + + # Option: text_template + # Type: String + # Notes: Path to a file that contains the text representation of the disclaimer. + # Value: [ FILE_PATH ] + # + "text_template": "/etc/pymodmilter/templates/disclaimer_text.template", + + # Option: error_policy + # Type: String + # Notes: Set what should be done if the modification fails (e.g. no message body present). + # Value: [ wrap | ignore | reject ] + # + "error_policy": "wrap" } - ] + ], + + # Option: pretend + # Type: Bool + # Notes: Just pretend to do the modifications, for test purposes. + # Value: [ true | false ] + # + "pretend": true } ] } diff --git a/docs/templates/disclaimer_html.template b/docs/templates/disclaimer_html.template new file mode 100644 index 0000000..eec02a0 --- /dev/null +++ b/docs/templates/disclaimer_html.template @@ -0,0 +1,9 @@ + + + + +
+ CAUTION: This email originated from outside the organization. + Do not follow guidance, click links or open attachments unless you recognize the sender and know the content is safe. +
+

diff --git a/docs/templates/disclaimer_text.template b/docs/templates/disclaimer_text.template new file mode 100644 index 0000000..6d9532d --- /dev/null +++ b/docs/templates/disclaimer_text.template @@ -0,0 +1,4 @@ + +CAUTION: This email originated from outside the organization. Do not follow guidance, click links or open attachments unless you recognize the sender and know the content is safe. + + diff --git a/pymodmilter/__init__.py b/pymodmilter/__init__.py index e4d7774..278f985 100644 --- a/pymodmilter/__init__.py +++ b/pymodmilter/__init__.py @@ -27,8 +27,13 @@ import logging.handlers import re from Milter.utils import parse_addr +from bs4 import BeautifulSoup from email.charset import Charset from email.header import Header, decode_header +from email import message_from_binary_file +from email.message import MIMEPart +from email.policy import default as default_policy, SMTP +from io import BytesIO from netaddr import IPAddress, IPNetwork, AddrFormatError @@ -55,6 +60,7 @@ def make_header(decoded_seq, maxlinelen=None, header_name=None, def replace_illegal_chars(string): + """Replace illegal characters in header values.""" return string.replace( "\x00", "").replace( "\r", "").replace( @@ -62,136 +68,367 @@ def replace_illegal_chars(string): class Modification: - """Modification to implement a modification to apply on e-mails.""" + """ + Modification to implement certain modifications on e-mails. - # mandatory parameters for each modification type - types = { - "add_header": ["header", "value"], - "del_header": ["header"], - "mod_header": ["header", "search", "value"] - } + Each modification function returns the necessary changes for ModifyMilter + so they can be applied to the email passing the MTA. + """ def __init__(self, name, mod_type, log, **params): self.logger = logging.getLogger(__name__) self.logger.debug(f"initializing modification '{name}'") self.name = name self.log = log - # check mod_type - if mod_type not in Modification.types: + # needs for each modification type + self.types = { + "add_header": { + "needs": ["headers"]}, + "del_header": { + "needs": ["headers"]}, + "mod_header": { + "needs": ["headers"]}, + "add_disclaimer": { + "needs": ["headers", "data"]}} + + if mod_type not in self.types: raise RuntimeError( f"{self.name}: invalid modification type '{mod_type}'") + self.mod_type = mod_type - # check if mandatory modification options are present in config - for option in Modification.types[self.mod_type]: - if option not in params: - raise RuntimeError( - f"{self.name}: mandatory config " - f"option '{option}' not found") - if option == "value" and not params["value"]: - raise RuntimeError( - f"{self.name}: empty value specified") - if mod_type == "add_header": - self.header = params["header"] - self.value = params["value"] - elif mod_type in ["del_header", "mod_header"]: - # compile header regex - try: - self.header = re.compile( - params["header"], re.MULTILINE + re.DOTALL + re.IGNORECASE) - except re.error as e: - raise RuntimeError( - f"{self.name}: unable to parse regular expression of " - f"option 'header': {e}") - - if mod_type == "mod_header": - # compile search regex + try: + if mod_type == "add_header": + self.header = params["header"] + self.value = params["value"] + elif mod_type in ["del_header", "mod_header"]: try: - self.search = re.compile( - params["search"], + self.header = re.compile( + params["header"], re.MULTILINE + re.DOTALL + re.IGNORECASE) except re.error as e: raise RuntimeError( - f"{self.name}: unable to parse regular expression of " - f"option 'search': {e}") - self.value = params["value"] + f"{self.name}: unable to parse regex of " + f"option 'header': {e}") - def execute(self, qid, headers): - """ - Execute modification and return list - with modified headers. - """ - if self.mod_type == "add_header": - header = f"{self.header}: {self.value}" - if self.log: - self.logger.info( - f"{qid}: {self.name}: add_header: {header[0:70]}") - else: - self.logger.debug( - f"{qid}: {self.name}: add_header: {header}") - return [(self.mod_type, self.header, self.value, 0, 1)] + if mod_type == "mod_header": + try: + self.search = re.compile( + params["search"], + re.MULTILINE + re.DOTALL + re.IGNORECASE) + except re.error as e: + raise RuntimeError( + f"{self.name}: unable to parse regex of " + f"option 'search': {e}") - modified = [] + self.value = params["value"] + elif mod_type == "add_disclaimer": + if params["action"] not in ["append", "prepend"]: + raise RuntimeError( + f"{self.name}: unknown action specified") + + self.action = params["action"] + + if params["error_policy"] not in ["wrap", "ignore", "reject"]: + raise RuntimeError( + f"{self.name}: unknown error_policy specified") + + self.error_policy = params["error_policy"] + + try: + with open(params["html_template"], "r") as f: + self.html = BeautifulSoup(f.read(), "html.parser") + body = self.html.find('body') + if body: + # just use content within the body tag if present + self.html = body + with open(params["text_template"], "r") as f: + self.text = f.read() + except IOError as e: + raise RuntimeError(f"unable to read template: {e}") + except KeyError as e: + raise RuntimeError( + f"{self.name}: mandatory configuration option not found: {e}") + + def needs(self): + """Return the needs of this modification to work.""" + return self.types[self.mod_type]["needs"] + + def add_header(self, qid, headers, header, value, pos=-1): + """Add header to email.""" + hdr = f"{header}: {value}" + if self.log: + self.logger.info( + f"{qid}: {self.name}: add_header: {hdr[0:70]}") + else: + self.logger.debug( + f"{qid}: {self.name}: add_header: {hdr}") + + headers.append((header, value)) + params = [header, value, pos] + return [("add_header", *params)] + + def mod_header(self, qid, headers, header, search, replace): + """Modify an email header.""" + if isinstance(header, str): + header = re.compile( + header, re.MULTILINE + re.DOTALL + re.IGNORECASE) + + if isinstance(search, str): + search = re.compile( + search, re.MULTILINE + re.DOTALL + re.IGNORECASE) + + changes = [] index = 0 occurrences = {} - - # iterate headers - for name, value in headers: - # keep track of the occurrence of each header, needed by - # Milter.Base.chgheader + # iterate a copy of headers because headers may be modified + for name, value in headers.copy(): + # keep track of the occurrence of each header + # needed by Milter.Base.chgheader if name not in occurrences.keys(): occurrences[name] = 1 else: occurrences[name] += 1 - # check if header line matches regex - header = f"{name}: {value}" - if self.header.search(header): - if self.mod_type == "del_header": - # set an empty value to delete the header - new_value = "" - if self.log: - self.logger.info( - f"{qid}: {self.name}: del_header: " - f"{header[0:70]}") - else: - self.logger.debug( - f"{qid}: {self.name}: del_header: " - f"(occ. {occurrences[name]}): {header}") - else: - old_header = header - new_value = self.search.sub(self.value, value) - if value == new_value: - continue - header = f"{name}: {new_value}" + hdr = f"{name}: {value}" + if header.search(hdr): + new_value = search.sub(replace, value).strip() + if new_value == "": + self.logger.warning( + f"{qid}: {self.name}: mod_header: resulting value is " + f"empty: del_header: {hdr[0:70]}") + del headers[index] + params = [name, new_value, index, occurrences[name]] + changes.append(("mod_header", *params)) + index -= 1 + occurrences[name] -= 1 + elif value != new_value: + old_hdr = hdr + hdr = f"{name}: {new_value}" if self.log: self.logger.info( f"{qid}: {self.name}: mod_header: " - f"{old_header[0:70]}: {header[0:70]}") + f"{old_hdr[0:70]}: {hdr[0:70]}") else: self.logger.debug( f"{qid}: {self.name}: mod_header: " - f"(occ. {occurrences[name]}): {old_header}: " - f"{header}") - modified.append( - (self.mod_type, name, new_value, index, occurrences[name])) + f"(occ. {occurrences[name]}): {old_hdr}: " + f"{hdr}") + + headers[index] = (name, new_value) + params = [name, new_value, occurrences[name]] + changes.append(("mod_header", *params)) + index += 1 - return modified + + return changes + + def del_header(self, qid, headers, header): + """Delete an email header.""" + if isinstance(header, str): + header = re.compile( + header, re.MULTILINE + re.DOTALL + re.IGNORECASE) + + changes = [] + index = 0 + occurrences = {} + # iterate a copy of headers because headers may be modified + for name, value in headers.copy(): + # keep track of the occurrence of each header, + # needed by Milter.Base.chgheader + if name not in occurrences.keys(): + occurrences[name] = 1 + else: + occurrences[name] += 1 + + hdr = f"{name}: {value}" + if header.search(hdr): + if self.log: + self.logger.info( + f"{qid}: {self.name}: del_header: " + f"{hdr[0:70]}") + else: + self.logger.debug( + f"{qid}: {self.name}: del_header: " + f"(occ. {occurrences[name]}): {hdr}") + + del headers[index] + params = [name, "", occurrences[name]] + changes.append(("mod_header", *params)) + index -= 1 + occurrences[name] -= 1 + + index += 1 + + return changes + + def add_disclaimer(self, qid, headers, fp, text_template, html_template, + error_policy): + """Append or prepend a disclaimer to the email body.""" + changes = [] + + fp.seek(0) + msg = message_from_binary_file(fp, policy=default_policy) + + html_body = None + text_body = None + update_headers = False + + try: + html_body = msg.get_body(preferencelist=("html")) + text_body = msg.get_body(preferencelist=("plain")) + except Exception as e: + self.logger.error( + f"{qid}: {self.name}: an error occured in " + f"email.message.EmailMessage.get_body: {e}") + + if html_body is None and text_body is None: + if self.error_policy == "ignore": + self.logger.info( + f"{qid}: {self.name}: unable to find email body, " + f"ignore according to policy") + return changes + elif self.error_policy == "reject": + self.logger.info( + f"{qid}: {self.name}: unable to find email body, " + f"reject message according to policy") + return [ + ("reject", "Message rejected due to missing email body")] + + self.logger.info( + f"{qid}: {self.name}: unable to find email body, " + f"wrapping original email in a new message envelope") + msg = MIMEPart() + msg.add_header("MIME-Version", "1.0") + msg.set_content( + "Please see the original email attached to this email.") + msg.add_alternative( + "Please see the original email attached to this email.", + subtype="html") + fp.seek(0) + msg.add_attachment( + fp.read(), maintype="plain", subtype="text", + filename="original_email.eml") + html_body = msg.get_body(preferencelist=("html")) + text_body = msg.get_body(preferencelist=("plain")) + # content and mime headers may have to be updated because + # a new message has been created + update_headers = True + elif not msg.is_multipart(): + # content and mime headers may have to be updated because + # we operate on a non-multipart email + update_headers = True + + if text_body is not None: + if self.log: + self.logger.info( + f"{qid}: {self.name}: {self.action} text disclaimer") + else: + self.logger.debug( + f"{qid}: {self.name}: {self.action} text disclaimer") + + text = text_body.get_content() + if self.action == "prepend": + text = f"{text_template}{text}" + else: + text = f"{text}{text_template}" + + text_body.set_content( + text.encode(), maintype="text", subtype="plain") + text_body.set_param("charset", "UTF-8", header="Content-Type") + + if html_body is not None: + if self.log: + self.logger.info( + f"{qid}: {self.name}: {self.action} html disclaimer") + else: + self.logger.debug( + f"{qid}: {self.name}: {self.action} html disclaimer") + + soup = BeautifulSoup(html_body.get_content(), "html.parser") + body = soup.find('body') + if body: + # work within the body tag if it is present + soup = body + + if self.action == "prepend": + soup.insert(0, html_template) + else: + soup.append(html_template) + + html_body.set_content( + str(soup).encode(), maintype="text", subtype="html") + html_body.set_param("charset", "UTF-8", header="Content-Type") + + if update_headers: + for name, value in msg.items(): + name_lower = name.lower() + if not name_lower.startswith("content-") and \ + name_lower != "mime-version": + continue + + defined = False + for n, v in headers: + if n.lower() == name_lower: + changes += self.mod_header( + qid, headers, f"^{n}:", ".*", value) + defined = True + break + + if not defined: + changes += self.add_header( + qid, headers, name, value) + + msg = msg.as_string(policy=SMTP).encode("ascii", errors="replace") + fp.seek(0) + fp.write(msg) + fp.truncate() + body_pos = msg.find(b"\r\n\r\n") + 2 + changes.append(("mod_body", body_pos)) + return changes + + def execute(self, qid, headers, fp): + """ + Execute configured modification. + """ + changes = [] + + if self.mod_type == "add_header": + changes = self.add_header( + qid, headers, self.header, self.value) + elif self.mod_type == "mod_header": + changes = self.mod_header( + qid, headers, self.header, self.search, self.value) + elif self.mod_type == "del_header": + changes = self.del_header( + qid, headers, self.header) + elif self.mod_type == "add_disclaimer": + changes = self.add_disclaimer( + qid, headers, fp, self.text, + self.html, self.error_policy) + + return changes class Rule: """ Rule to implement multiple modifications on emails based on conditions. """ - def __init__(self, name, modifications, local_addrs, log, conditions={}): + + def __init__(self, name, modifications, local_addrs, log, conditions={}, + pretend=False): self.logger = logging.getLogger(__name__) - self.name = name - self.log = log + if pretend: + self.name = f"{name} (pretend)" + else: + self.name = name self.logger.debug(f"initializing rule '{self.name}'") - + self.log = log + self.pretend = pretend + self._needs = [] self._local_addrs = [] - # replace strings in local_addrs list with IPNetwork instances + try: for addr in local_addrs: self._local_addrs.append(IPNetwork(addr)) @@ -215,22 +452,23 @@ class Rule: raise RuntimeError( f"{self.name}: unable to parse entry of " f"condition '{option}': {e}") + self.logger.debug( f"{self.name}: added condition: {option} = {value}") - elif option == "envfrom": + elif option in ["envfrom", "envto"]: try: self.conditions[option] = re.compile(value, re.IGNORECASE) except re.error as e: raise RuntimeError( - f"{self.name}: unable to parse regular expression of " + f"{self.name}: unable to parse regex of " f"condition '{option}': {e}") + self.logger.debug( f"{self.name}: added condition: {option} = {value}") self.modifications = [] for mod_idx, mod in enumerate(modifications): params = {} - # set default values if not specified in config if "name" not in mod: mod["name"] = f"Modification #{mod_idx}" @@ -251,19 +489,28 @@ class Rule: f"{params['name']}: mandatory config " f"option 'type' not found") - if "header" in mod: - params["header"] = mod["header"] + for param in [ + "header", "search", "value", "action", "html_template", + "text_template", "error_policy"]: + if param in mod: + params[param] = mod[param] - if "search" in mod: - params["search"] = mod["search"] + modification = Modification(**params) + for need in modification.needs(): + if need not in self._needs: + self._needs.append(need) - if "value" in mod: - params["value"] = mod["value"] - - self.modifications.append(Modification(**params)) + self.modifications.append(modification) self.logger.debug( f"{self.name}: added modification: {mod['name']}") + self.logger.debug( + f"{self.name}: rule needs: {self._needs}") + + def needs(self): + """Return the needs of this rule.""" + return self._needs + def ignore_host(self, host): """Check if host is ignored by this rule.""" ip = IPAddress(host) @@ -274,6 +521,7 @@ class Rule: if ip in addr: is_local = True break + if is_local != self.conditions["local"]: return True @@ -282,6 +530,7 @@ class Rule: for accepted in self.conditions["hosts"]: if ip in accepted: return False + return True return False @@ -291,9 +540,21 @@ class Rule: if "envfrom" in self.conditions: if not self.conditions["envfrom"].search(envfrom): return True + return False - def execute(self, qid, headers): + def ignore_envto(self, envto): + """Check if envelope-to address is ignored by this rule.""" + if "envto" in self.conditions: + if not isinstance(envto, set): + envto = set(envto) + for to in envto: + if not self.conditions["envto"].search(to): + return True + + return False + + def execute(self, qid, headers, data): """Execute all modifications of this rule.""" changes = [] if self.log: @@ -303,16 +564,10 @@ class Rule: for mod in self.modifications: self.logger.debug(f"{qid}: executing modification '{mod.name}'") - result = mod.execute(qid, headers) - changes += result - for mod_type, name, value, index, occurrence in result: - if mod_type == "add_header": - headers.append((name, value)) - else: - if mod_type == "mod_header": - headers[index] = (name, value) - elif mod_type == "del_header": - del headers[index] + changes += mod.execute(qid, headers, data) + + if self.pretend: + changes = [] return changes @@ -348,6 +603,7 @@ class ModifyMilter(Milter.Base): f"host {hostaddr[0]} is ignored by all rules, " f"skip further processing") return Milter.ACCEPT + return Milter.CONTINUE def envfrom(self, mailfrom, *str): @@ -361,16 +617,52 @@ class ModifyMilter(Milter.Base): if not self.rules: self.logger.debug( - f"mail from {mailfrom} is ignored by all rules, " + f"envelope-from address {mailfrom} is ignored by all rules, " f"skip further processing") return Milter.ACCEPT + + self.recipients = set() return Milter.CONTINUE @Milter.noreply + def envrcpt(self, to, *str): + self.recipients.add("@".join(parse_addr(to)).lower()) + return Milter.CONTINUE + def data(self): - self.qid = self.getsymval('i') - self.logger.debug(f"{self.qid}: received queue-id from MTA") - self.headers = [] + try: + for rule in self.rules.copy(): + if rule.ignore_envto(self.recipients): + self.logger.debug( + f"envelope-to addresses are ignored by " + f"rule '{rule.name}'") + self.rules.remove(rule) + + if not self.rules: + self.logger.debug( + f"envelope-to addresses are ignored by all rules, " + f"skip further processing") + return Milter.ACCEPT + + self.qid = self.getsymval('i') + self.logger.debug(f"{self.qid}: received queue-id from MTA") + self.headers = None + self.fp = None + for rule in self.rules: + if "headers" in rule.needs() and self.headers is None: + self.headers = [] + + if "data" in rule.needs() and self.fp is None: + self.fp = BytesIO() + + if None not in [self.headers, self.fp]: + break + + except Exception as e: + self.logger.exception( + f"an exception occured in data function: {e}") + return Milter.TEMPFAIL + return Milter.CONTINUE def header(self, name, value): @@ -378,36 +670,89 @@ class ModifyMilter(Milter.Base): # remove surrogates from value value = value.encode( errors="surrogateescape").decode(errors="replace") - self.logger.debug(f"{self.qid}: received header: {name}: {value}") - header = make_header(decode_header(value), errors="replace") - value = str(header).replace("\x00", "") - self.logger.debug( - f"{self.qid}: decoded header: {name}: {value}") - self.headers.append((name, value)) - return Milter.CONTINUE + if self.fp is not None: + self.fp.write(f"{name}: {value}\r\n".encode( + encoding="ascii", errors="replace")) + + if self.headers is not None: + self.logger.debug(f"{self.qid}: received header: " + f"{name}: {value}") + header = make_header(decode_header(value), errors="replace") + value = str(header).replace("\x00", "") + self.logger.debug( + f"{self.qid}: decoded header: {name}: {value}") + self.headers.append((name, value)) except Exception as e: self.logger.exception( f"an exception occured in header function: {e}") return Milter.TEMPFAIL + return Milter.CONTINUE + + def eoh(self): + try: + if self.fp is not None: + self.fp.write(b"\r\n") + except Exception as e: + self.logger.exception( + f"an exception occured in eoh function: {e}") + return Milter.TEMPFAIL + + return Milter.CONTINUE + + def body(self, chunk): + try: + if self.fp is not None: + self.fp.write(chunk) + except Exception as e: + self.logger.exception( + f"an exception occured in body function: {e}") + return Milter.TEMPFAIL + + return Milter.CONTINUE + def eom(self): try: + changes = [] for rule in self.rules: - changes = rule.execute(self.qid, self.headers) - for mod_type, name, value, index, occurrence in changes: + changes += rule.execute(self.qid, self.headers, self.fp) + + mod_body_pos = None + for mod_type, *params in changes: + if mod_type in ["add_header", "mod_header", "del_header"]: + header, value, occurrence = params enc_value = replace_illegal_chars( Header(s=value).encode()) if mod_type == "add_header": - self.logger.debug(f"{self.qid}: milter: adding " - f"header: {name}: {enc_value}") - self.addheader(name, enc_value, -1) + self.logger.debug(f"{self.qid}: milter: add " + f"header: {header}: {enc_value}") + self.addheader(header, enc_value, occurrence) else: - self.logger.debug(f"{self.qid}: milter: modify " - f"header (occ. {occurrence}): " - f"{name}: {enc_value}") - self.chgheader(name, occurrence, enc_value) - return Milter.ACCEPT + if enc_value == "": + self.logger.debug( + f"{self.qid}: milter: delete " + f"header (occ. {occurrence}): " + f"{header}") + else: + self.logger.debug( + f"{self.qid}: milter: modify " + f"header (occ. {occurrence}): " + f"{header}: {enc_value}") + + self.chgheader(header, occurrence, enc_value) + elif mod_type == "mod_body": + mod_body_pos = params[0] + elif mod_type == "reject": + self.setreply("554", "5.7.0", params[0]) + return Milter.REJECT + + if mod_body_pos is not None: + self.fp.seek(mod_body_pos) + self.logger.debug(f"{self.qid}: milter: replace body") + self.replacebody(self.fp.read()) except Exception as e: self.logger.exception( f"an exception occured in eom function: {e}") return Milter.TEMPFAIL + + return Milter.ACCEPT diff --git a/pymodmilter/run.py b/pymodmilter/run.py index fe2faee..924ab2a 100644 --- a/pymodmilter/run.py +++ b/pymodmilter/run.py @@ -114,6 +114,9 @@ def main(): if "log" not in config["global"]: config["global"]["log"] = True + if "pretend" not in config["global"]: + config["global"]["pretend"] = False + # check if mandatory sections are present in config for section in ["rules"]: if section not in config: @@ -138,6 +141,11 @@ def main(): else: params["log"] = config["global"]["log"] + if "pretend" in rule: + params["pretend"] = rule["pretend"] + else: + params["pretend"] = config["global"]["pretend"] + if "local_addrs" in rule: params["local_addrs"] = rule["local_addrs"] else: diff --git a/pymodmilter/version.py b/pymodmilter/version.py index 7863915..976498a 100644 --- a/pymodmilter/version.py +++ b/pymodmilter/version.py @@ -1 +1 @@ -__version__ = "1.0.2" +__version__ = "1.0.3" diff --git a/setup.py b/setup.py index 6e482f0..3417ec4 100644 --- a/setup.py +++ b/setup.py @@ -37,9 +37,15 @@ setup(name = "pymodmilter", }, data_files = [ ( - '/etc/pymodmilter', + "/etc/pymodmilter", [ - 'docs/pymodmilter.conf.example' + "docs/pymodmilter.conf.example" + ] + ), ( + "/etc/pymodmilter/templates", + [ + "docs/templates/disclaimer_html.template", + "docs/templates/disclaimer_txt.template" ] ) ],