diff --git a/pymodmilter/__init__.py b/pymodmilter/__init__.py index 03e7e7e..255b889 100644 --- a/pymodmilter/__init__.py +++ b/pymodmilter/__init__.py @@ -14,10 +14,11 @@ __all__ = [ "actions", + "base", "conditions", + "rules", "run", - "CustomLogger", - "Rule", + "ModifyMilterConfig", "ModifyMilter"] __version__ = "1.1.4" @@ -26,130 +27,93 @@ from pymodmilter import _runtime_patches import Milter import logging +import re +import json from Milter.utils import parse_addr - from collections import defaultdict - from email.header import Header -from email.message import MIMEPart from email.parser import BytesFeedParser from email.policy import default as default_policy, SMTP -from pymodmilter.actions import Action -from pymodmilter.conditions import Conditions +from pymodmilter.base import CustomLogger, BaseConfig, MilterMessage +from pymodmilter.base import replace_illegal_chars +from pymodmilter.rules import RuleConfig, Rule -class CustomLogger(logging.LoggerAdapter): - def process(self, msg, kwargs): - if "name" in self.extra: - msg = "{}: {}".format(self.extra["name"], msg) +class ModifyMilterConfig(BaseConfig): + def __init__(self, cfgfile, debug=False): + try: + with open(cfgfile, "r") as fh: + # remove lines with leading # (comments), they + # are not allowed in json + cfg = re.sub(r"(?m)^\s*#.*\n?", "", fh.read()) + except IOError as e: + raise RuntimeError(f"unable to open/read config file: {e}") - if "qid" in self.extra: - msg = "{}: {}".format(self.extra["qid"], msg) + try: + cfg = json.loads(cfg) + except json.JSONDecodeError as e: + cfg_text = [f"{n+1}: {l}" for n, l in enumerate(cfg.splitlines())] + msg = "\n".join(cfg_text) + e.msg = f"{msg}\n{e.msg}" + raise e - if self.logger.getEffectiveLevel() != logging.DEBUG: - msg = msg.replace("\n", "").replace("\r", "") + if "global" in cfg: + assert isinstance(cfg["global"], dict), \ + "global: invalid type, should be dict" - return msg, kwargs + cfg["global"]["name"] = "global" + super().__init__(cfg["global"], debug) + self.logger.debug("initialize config") -class Rule: - """ - Rule to implement multiple actions on emails. - """ - - def __init__(self, milter_cfg, cfg): - logger = logging.getLogger(cfg["name"]) - self.logger = CustomLogger(logger, {"name": cfg["name"]}) - self.logger.setLevel(cfg["loglevel"]) - - if cfg["conditions"] is None: - self.conditions = None - else: - self.conditions = Conditions(milter_cfg, cfg["conditions"]) - - self._need_body = False - - self.actions = [] - for action_cfg in cfg["actions"]: - action = Action(milter_cfg, action_cfg) - self.actions.append(action) - if action.need_body(): - self._need_body = True - - self.pretend = cfg["pretend"] - - def need_body(self): - """Return True if this rule needs the message body.""" - return self._need_body - - def ignores(self, host=None, envfrom=None, envto=None): - args = {} - - if host is not None: - args["host"] = host - - if envfrom is not None: - args["envfrom"] = envfrom - - if envto is not None: - args["envto"] = envto - - if self.conditions is None or self.conditions.match(args): - for action in self.actions: - if action.conditions is None or action.conditions.match(args): - return False - - return True - - def execute(self, milter, pretend=None): - """Execute all actions of this rule.""" - if pretend is None: - pretend = self.pretend - - for action in self.actions: - milter_action = action.execute(milter, pretend=pretend) - if milter_action is not None: - return milter_action - - -class MilterMessage(MIMEPart): - def replace_header(self, _name, _value, idx=None): - _name = _name.lower() - counter = 0 - for i, (k, v) in zip(range(len(self._headers)), self._headers): - if k.lower() == _name: - counter += 1 - if not idx or counter == idx: - self._headers[i] = self.policy.header_store_parse( - k, _value) - break - - else: - raise KeyError(_name) - - def remove_header(self, name, idx=None): - name = name.lower() - newheaders = [] - counter = 0 - for k, v in self._headers: - if k.lower() == name: - counter += 1 - if counter != idx: - newheaders.append((k, v)) + if "pretend" in cfg["global"]: + pretend = cfg["global"]["pretend"] + assert isinstance(pretend, bool), \ + "global: pretend: invalid value, should be bool" + self["pretend"] = pretend else: - newheaders.append((k, v)) + self["pretend"] = False - self._headers = newheaders + if "socket" in cfg["global"]: + socket = cfg["global"]["socket"] + assert isinstance(socket, str), \ + "global: socket: invalid value, should be string" + self["socket"] = socket + else: + self["socket"] = None + if "local_addrs" in cfg["global"]: + local_addrs = cfg["global"]["local_addrs"] + assert isinstance(local_addrs, list) and all( + [isinstance(addr, str) for addr in local_addrs]), \ + "global: local_addrs: invalid value, " \ + "should be list of strings" + self["local_addrs"] = local_addrs + else: + self["local_addrs"] = [ + "::1/128", + "127.0.0.0/8", + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16"] -def replace_illegal_chars(string): - """Replace illegal characters in header values.""" - return string.replace( - "\x00", "").replace( - "\r", "").replace( - "\n", "") + self.logger.debug(f"socket={self['socket']}, " + f"local_addrs={self['local_addrs']}, " + f"pretend={self['pretend']}, " + f"loglevel={self['loglevel']}") + + assert "rules" in cfg, \ + "mandatory parameter 'rules' not found" + assert isinstance(cfg["rules"], list), \ + "rules: invalid value, should be list" + + self.logger.debug("initialize rules config") + self["rules"] = [] + for idx, rule_cfg in enumerate(cfg["rules"]): + self["rules"].append( + RuleConfig(idx, self, rule_cfg, debug)) class ModifyMilter(Milter.Base): diff --git a/pymodmilter/actions.py b/pymodmilter/actions.py index 1aa26a5..1ad4e6f 100644 --- a/pymodmilter/actions.py +++ b/pymodmilter/actions.py @@ -12,6 +12,16 @@ # along with PyMod-Milter. If not, see . # +__all__ = [ + "add_header", + "mod_header", + "del_header", + "add_disclaimer", + "rewrite_links", + "store", + "ActionConfig", + "Action"] + import logging import os import re @@ -23,7 +33,9 @@ from copy import copy from datetime import datetime from email.message import MIMEPart -from pymodmilter import CustomLogger, Conditions, replace_illegal_chars +from pymodmilter import CustomLogger, BaseConfig +from pymodmilter.conditions import ConditionsConfig, Conditions +from pymodmilter import replace_illegal_chars def add_header(milter, field, value, pretend=False, @@ -316,13 +328,145 @@ def store(milter, directory, pretend=False, raise RuntimeError(f"unable to store message: {e}") +class ActionConfig(BaseConfig): + def __init__(self, idx, rule_cfg, cfg, debug): + if "name" in cfg: + cfg["name"] = f"{rule_cfg['name']}: {cfg['name']}" + else: + cfg["name"] = f"{rule_cfg['name']}: Action #{idx}" + + if "loglevel" not in cfg: + cfg["loglevel"] = rule_cfg["loglevel"] + + super().__init__(cfg, debug) + + self["pretend"] = rule_cfg["pretend"] + self["conditions"] = None + self["type"] = "" + + if "pretend" in cfg: + pretend = cfg["pretend"] + assert isinstance(pretend, bool), \ + f"{self['name']}: pretend: invalid value, should be bool" + self["pretend"] = pretend + + assert "type" in cfg, \ + f"{self['name']}: mandatory parameter 'type' not found" + assert isinstance(cfg["type"], str), \ + f"{self['name']}: invalid value, should be string" + self["type"] = cfg["type"] + + if self["type"] == "add_header": + self["func"] = add_header + self["need_body"] = False + self.add_string_arg(cfg, ("field", "value")) + + elif self["type"] == "mod_header": + self["func"] = mod_header + self["need_body"] = False + args = ["field", "value"] + if "search" in cfg: + args.append("search") + + for arg in args: + self.add_string_arg(cfg, arg) + if arg in ("field", "search"): + try: + self["args"][arg] = re.compile( + self["args"][arg], + re.MULTILINE + re.DOTALL + re.IGNORECASE) + except re.error as e: + raise ValueError(f"{self['name']}: {arg}: {e}") + + elif self["type"] == "del_header": + self["func"] = del_header + self["need_body"] = False + args = ["field"] + if "value" in cfg: + args.append("value") + + for arg in args: + self.add_string_arg(cfg, arg) + try: + self["args"][arg] = re.compile( + self["args"][arg], + re.MULTILINE + re.DOTALL + re.IGNORECASE) + except re.error as e: + raise ValueError(f"{self['name']}: {arg}: {e}") + + elif self["type"] == "add_disclaimer": + self["func"] = add_disclaimer + self["need_body"] = True + + if "error_policy" not in cfg: + cfg["error_policy"] = "wrap" + + self.add_string_arg( + cfg, ("action", "html_template", "text_template", + "error_policy")) + assert self["args"]["action"] in ("append", "prepend"), \ + f"{self['name']}: action: invalid value, " \ + f"should be 'append' or 'prepend'" + assert self["args"]["error_policy"] in ("wrap", + "ignore", + "reject"), \ + f"{self['name']}: error_policy: invalid value, " \ + f"should be 'wrap', 'ignore' or 'reject'" + + try: + with open(self["args"]["html_template"], "r") as f: + html = BeautifulSoup(f.read(), "html.parser") + body = html.find('body') + if body: + # just use content within the body tag if present + html = body + self["args"]["html_template"] = html + + with open(self["args"]["text_template"], "r") as f: + self["args"]["text_template"] = f.read() + + except IOError as e: + raise RuntimeError( + f"{self['name']}: unable to open/read template file: {e}") + + elif self["type"] == "rewrite_links": + self["func"] = rewrite_links + self["need_body"] = True + self.add_string_arg(cfg, "repl") + + elif self["type"] == "store": + self["func"] = store + self["need_body"] = True + self.add_string_arg(cfg, "storage_type") + assert self["args"]["storage_type"] in ("file"), \ + f"{self['name']}: storage_type: invalid value, " \ + f"should be 'file'" + + if self["args"]["storage_type"] == "file": + self.add_string_arg(cfg, "directory") + else: + raise RuntimeError(f"{self['name']}: type: invalid action type") + + if "conditions" in cfg: + conditions = cfg["conditions"] + assert isinstance(conditions, dict), \ + f"{self['name']}: conditions: invalid value, should be dict" + self["conditions"] = ConditionsConfig(self, conditions, debug) + + self.logger.debug(f"pretend={self['pretend']}, " + f"loglevel={self['loglevel']}, " + f"type={self['type']}, " + f"args={self['args']}") + + class Action: """Action to implement a pre-configured action to perform on e-mails.""" def __init__(self, milter_cfg, cfg): - logger = logging.getLogger(cfg["name"]) - self.logger = CustomLogger(logger, {"name": cfg["name"]}) - self.logger.setLevel(cfg["loglevel"]) + self.logger = cfg.logger + #logger = logging.getLogger(cfg["name"]) + #self.logger = CustomLogger(logger, {"name": cfg["name"]}) + #self.logger.setLevel(cfg["loglevel"]) if cfg["conditions"] is None: self.conditions = None @@ -330,6 +474,7 @@ class Action: self.conditions = Conditions(milter_cfg, cfg["conditions"]) self.pretend = cfg["pretend"] + self._func = cfg["func"] self._args = cfg["args"] action_type = cfg["type"] diff --git a/pymodmilter/base.py b/pymodmilter/base.py new file mode 100644 index 0000000..3235eae --- /dev/null +++ b/pymodmilter/base.py @@ -0,0 +1,143 @@ +# PyMod-Milter is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# PyMod-Milter is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with PyMod-Milter. If not, see . +# + +__all__ = [ + "CustomLogger", + "BaseConfig", + "MilterMessage", + "replace_illegal_chars"] + +import logging + +from email.message import MIMEPart + + +class CustomLogger(logging.LoggerAdapter): + def process(self, msg, kwargs): + if "name" in self.extra: + msg = "{}: {}".format(self.extra["name"], msg) + + if "qid" in self.extra: + msg = "{}: {}".format(self.extra["qid"], msg) + + if self.logger.getEffectiveLevel() != logging.DEBUG: + msg = msg.replace("\n", "").replace("\r", "") + + return msg, kwargs + + +class BaseConfig: + def __init__(self, cfg={}, debug=False, logger=None): + self._cfg = {} + if "name" in cfg: + assert isinstance(cfg["name"], str), \ + "rule: name: invalid value, should be string" + self["name"] = cfg["name"] + else: + self["name"] = __name__ + + if debug: + self["loglevel"] = logging.DEBUG + elif "loglevel" in cfg: + if isinstance(cfg["loglevel"], int): + self["loglevel"] = cfg["loglevel"] + else: + level = getattr(logging, cfg["loglevel"].upper(), None) + assert isinstance(level, int), \ + f"{self['name']}: loglevel: invalid value" + self["loglevel"] = level + else: + self["loglevel"] = logging.INFO + + if logger is None: + logger = logging.getLogger(self["name"]) + logger.setLevel(self["loglevel"]) + + self.logger = CustomLogger(logger, {"name": self["name"]}) + + # the keys/values of args are used as parameters + # to functions + self["args"] = {} + + def __setitem__(self, key, value): + self._cfg[key] = value + + def __getitem__(self, key): + return self._cfg[key] + + def __delitem__(self, key): + del self._cfg[key] + + def __contains__(self, key): + return key in self._cfg + + def add_string_arg(self, cfg, args): + if isinstance(args, str): + args = [args] + + for arg in args: + assert arg in cfg, \ + f"{self['name']}: mandatory parameter '{arg}' not found" + assert isinstance(cfg[arg], str), \ + f"{self['name']}: {arg}: invalid value, should be string" + self["args"][arg] = cfg[arg] + + def add_bool_arg(self, cfg, args): + if isinstance(args, str): + args = [args] + + for arg in args: + assert arg in cfg, \ + f"{self['name']}: mandatory parameter '{arg}' not found" + assert isinstance(cfg[arg], bool), \ + f"{self['name']}: {arg}: invalid value, should be bool" + self["args"][arg] = cfg[arg] + + +class MilterMessage(MIMEPart): + def replace_header(self, _name, _value, idx=None): + _name = _name.lower() + counter = 0 + for i, (k, v) in zip(range(len(self._headers)), self._headers): + if k.lower() == _name: + counter += 1 + if not idx or counter == idx: + self._headers[i] = self.policy.header_store_parse( + k, _value) + break + + else: + raise KeyError(_name) + + def remove_header(self, name, idx=None): + name = name.lower() + newheaders = [] + counter = 0 + for k, v in self._headers: + if k.lower() == name: + counter += 1 + if counter != idx: + newheaders.append((k, v)) + else: + newheaders.append((k, v)) + + self._headers = newheaders + + +def replace_illegal_chars(string): + """Replace illegal characters in header values.""" + return string.replace( + "\x00", "").replace( + "\r", "").replace( + "\n", "") diff --git a/pymodmilter/conditions.py b/pymodmilter/conditions.py index 335baf5..6548862 100644 --- a/pymodmilter/conditions.py +++ b/pymodmilter/conditions.py @@ -12,19 +12,66 @@ # along with PyMod-Milter. If not, see . # -import logging +__all__ = [ + "ConditionsConfig", + "Conditions"] -from netaddr import IPAddress -from pymodmilter import CustomLogger +import logging +import re + +from netaddr import IPAddress, IPNetwork, AddrFormatError +from pymodmilter import CustomLogger, BaseConfig + + +class ConditionsConfig(BaseConfig): + def __init__(self, parent_cfg, cfg, debug): + if "loglevel" not in cfg: + cfg["loglevel"] = parent_cfg["loglevel"] + + cfg["name"] = f"{parent_cfg['name']}: condition" + + super().__init__(cfg, debug) + + if "local" in cfg: + self.add_bool_arg(cfg, "local") + + if "hosts" in cfg: + hosts = cfg["hosts"] + assert isinstance(hosts, list) and all( + [isinstance(host, str) for host in hosts]), \ + f"{self['name']}: hosts: invalid value, " \ + f"should be list of strings" + + self["args"]["hosts"] = [] + try: + for host in cfg["hosts"]: + self["args"]["hosts"].append(IPNetwork(host)) + except AddrFormatError as e: + raise ValueError(f"{self['name']}: hosts: {e}") + + for arg in ("envfrom", "envto"): + if arg in cfg: + self.add_string_arg(cfg, arg) + try: + self["args"][arg] = re.compile( + self["args"][arg], + re.IGNORECASE) + except re.error as e: + raise ValueError(f"{self['name']}: {arg}: {e}") + + self.logger.debug(f"{self['name']}: " + f"loglevel={self['loglevel']}, " + f"args={self['args']}") class Conditions: """Conditions to implement conditions for rules and actions.""" def __init__(self, milter_cfg, cfg): - logger = logging.getLogger(cfg["name"]) - self.logger = CustomLogger(logger, {"name": cfg["name"]}) - self.logger.setLevel(cfg["loglevel"]) + self.logger = cfg.logger + #logger = logging.getLogger(cfg["name"]) + #self.logger = CustomLogger(logger, {"name": cfg["name"]}) + #self.logger.setLevel(cfg["loglevel"]) self._local_addrs = milter_cfg["local_addrs"] self._args = cfg["args"] diff --git a/pymodmilter/config.py b/pymodmilter/config.py deleted file mode 100644 index 3d1b651..0000000 --- a/pymodmilter/config.py +++ /dev/null @@ -1,346 +0,0 @@ -# PyMod-Milter is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# PyMod-Milter is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with PyMod-Milter. If not, see . -# - -__all__ = [ - "BaseConfig", - "ActionConfig", - "RuleConfig", - "ModifyMilterConfig"] - -import json -import logging -import re - -from bs4 import BeautifulSoup -from netaddr import IPNetwork, AddrFormatError - - -class BaseConfig: - def __init__(self, cfg={}, debug=False, logger=None): - if logger is None: - logger = logging.getLogger(__name__) - - self.logger = logger - - self._cfg = {} - if "name" in cfg: - assert isinstance(cfg["name"], str), \ - "rule: name: invalid value, should be string" - self["name"] = cfg["name"] - else: - self["name"] = "" - - if debug: - self["loglevel"] = logging.DEBUG - elif "loglevel" in cfg: - if isinstance(cfg["loglevel"], int): - self["loglevel"] = cfg["loglevel"] - else: - level = getattr(logging, cfg["loglevel"].upper(), None) - assert isinstance(level, int), \ - f"{self['name']}: loglevel: invalid value" - self["loglevel"] = level - else: - self["loglevel"] = logging.INFO - - # the keys/values of args are used as parameters - # to functions - self["args"] = {} - - def __setitem__(self, key, value): - self._cfg[key] = value - - def __getitem__(self, key): - return self._cfg[key] - - def __delitem__(self, key): - del self._cfg[key] - - def __contains__(self, key): - return key in self._cfg - - def add_string_arg(self, cfg, args): - if isinstance(args, str): - args = [args] - - for arg in args: - assert arg in cfg, \ - f"{self['name']}: mandatory parameter '{arg}' not found" - assert isinstance(cfg[arg], str), \ - f"{self['name']}: {arg}: invalid value, should be string" - self["args"][arg] = cfg[arg] - - def add_bool_arg(self, cfg, args): - if isinstance(args, str): - args = [args] - - for arg in args: - assert arg in cfg, \ - f"{self['name']}: mandatory parameter '{arg}' not found" - assert isinstance(cfg[arg], bool), \ - f"{self['name']}: {arg}: invalid value, should be bool" - self["args"][arg] = cfg[arg] - - -class ConditionsConfig(BaseConfig): - def __init__(self, parent_cfg, cfg, debug): - if "loglevel" not in cfg: - cfg["loglevel"] = parent_cfg["loglevel"] - - cfg["name"] = f"{parent_cfg['name']}: condition" - - super().__init__(cfg, debug) - - if "local" in cfg: - self.add_bool_arg(cfg, "local") - - if "hosts" in cfg: - hosts = cfg["hosts"] - assert isinstance(hosts, list) and all( - [isinstance(host, str) for host in hosts]), \ - f"{self['name']}: hosts: invalid value, " \ - f"should be list of strings" - - self["args"]["hosts"] = [] - try: - for host in cfg["hosts"]: - self["args"]["hosts"].append(IPNetwork(host)) - except AddrFormatError as e: - raise ValueError(f"{self['name']}: hosts: {e}") - - for arg in ("envfrom", "envto"): - if arg in cfg: - self.add_string_arg(cfg, arg) - try: - self["args"][arg] = re.compile( - self["args"][arg], - re.IGNORECASE) - except re.error as e: - raise ValueError(f"{self['name']}: {arg}: {e}") - - -class ActionConfig(BaseConfig): - def __init__(self, idx, rule_cfg, cfg, debug): - if "name" in cfg: - cfg["name"] = f"{rule_cfg['name']}: {cfg['name']}" - else: - cfg["name"] = f"{rule_cfg['name']}: Action #{idx}" - - if "loglevel" not in cfg: - cfg["loglevel"] = rule_cfg["loglevel"] - - super().__init__(cfg, debug) - - self["pretend"] = rule_cfg["pretend"] - self["conditions"] = None - self["type"] = "" - - if "pretend" in cfg: - pretend = cfg["pretend"] - assert isinstance(pretend, bool), \ - f"{self['name']}: pretend: invalid value, should be bool" - self["pretend"] = pretend - - assert "type" in cfg, \ - f"{self['name']}: type: invalid value, should be string" - assert cfg["type"] in \ - ("add_header", "del_header", "mod_header", "add_disclaimer", - "rewrite_links", "store"), \ - f"{self['name']}: type: invalid action type" - self["type"] = cfg["type"] - - if self["type"] == "add_header": - self.add_string_arg(cfg, ("field", "value")) - - elif self["type"] == "mod_header": - args = ["field", "value"] - if "search" in cfg: - args.append("search") - - for arg in args: - self.add_string_arg(cfg, arg) - if arg in ("field", "search"): - try: - self["args"][arg] = re.compile( - self["args"][arg], - re.MULTILINE + re.DOTALL + re.IGNORECASE) - except re.error as e: - raise ValueError(f"{self['name']}: {arg}: {e}") - - elif self["type"] == "del_header": - args = ["field"] - if "value" in cfg: - args.append("value") - - for arg in args: - self.add_string_arg(cfg, arg) - try: - self["args"][arg] = re.compile( - self["args"][arg], - re.MULTILINE + re.DOTALL + re.IGNORECASE) - except re.error as e: - raise ValueError(f"{self['name']}: {arg}: {e}") - - elif self["type"] == "add_disclaimer": - if "error_policy" not in cfg: - cfg["error_policy"] = "wrap" - - self.add_string_arg( - cfg, ("action", "html_template", "text_template", - "error_policy")) - assert self["args"]["action"] in ("append", "prepend"), \ - f"{self['name']}: action: invalid value, " \ - f"should be 'append' or 'prepend'" - assert self["args"]["error_policy"] in ("wrap", - "ignore", - "reject"), \ - f"{self['name']}: error_policy: invalid value, " \ - f"should be 'wrap', 'ignore' or 'reject'" - - try: - with open(self["args"]["html_template"], "r") as f: - html = BeautifulSoup(f.read(), "html.parser") - body = html.find('body') - if body: - # just use content within the body tag if present - html = body - self["args"]["html_template"] = html - - with open(self["args"]["text_template"], "r") as f: - self["args"]["text_template"] = f.read() - - except IOError as e: - raise RuntimeError( - f"{self['name']}: unable to open/read template file: {e}") - - elif self["type"] == "rewrite_links": - self.add_string_arg(cfg, "repl") - - elif self["type"] == "store": - self.add_string_arg(cfg, "storage_type") - assert self["args"]["storage_type"] in ("file"), \ - f"{self['name']}: storage_type: invalid value, " \ - f"should be 'file'" - - if self["args"]["storage_type"] == "file": - self.add_string_arg(cfg, "directory") - - if "conditions" in cfg: - conditions = cfg["conditions"] - assert isinstance(conditions, dict), \ - f"{self['name']}: conditions: invalid value, should be dict" - self["conditions"] = ConditionsConfig(self, conditions, debug) - - -class RuleConfig(BaseConfig): - def __init__(self, idx, milter_cfg, cfg, debug=False): - if "name" not in cfg: - cfg["name"] = f"Rule #{idx}" - - if "loglevel" not in cfg: - cfg["loglevel"] = milter_cfg["loglevel"] - - super().__init__(cfg, debug) - - self["pretend"] = milter_cfg["pretend"] - self["conditions"] = None - self["actions"] = [] - - if "pretend" in cfg: - pretend = cfg["pretend"] - assert isinstance(pretend, bool), \ - f"{self['name']}: pretend: invalid value, should be bool" - self["pretend"] = pretend - - assert "actions" in cfg, \ - f"{self['name']}: mandatory parameter 'actions' not found" - actions = cfg["actions"] - assert isinstance(actions, list), \ - f"{self['name']}: actions: invalid value, should be list" - - for idx, action_cfg in enumerate(cfg["actions"]): - self["actions"].append( - ActionConfig(idx, self, action_cfg, debug)) - - if "conditions" in cfg: - conditions = cfg["conditions"] - assert isinstance(conditions, dict), \ - f"{self['name']}: conditions: invalid value, should be dict" - self["conditions"] = ConditionsConfig(self, conditions, debug) - - -class ModifyMilterConfig(BaseConfig): - def __init__(self, cfgfile, debug=False): - try: - with open(cfgfile, "r") as fh: - # remove lines with leading # (comments), they - # are not allowed in json - cfg = re.sub(r"(?m)^\s*#.*\n?", "", fh.read()) - except IOError as e: - raise RuntimeError(f"unable to open/read config file: {e}") - - try: - cfg = json.loads(cfg) - except json.JSONDecodeError as e: - cfg_text = [f"{n+1}: {l}" for n, l in enumerate(cfg.splitlines())] - msg = "\n".join(cfg_text) - e.msg = f"{msg}\n{e.msg}" - raise e - - if "global" in cfg: - assert isinstance(cfg["global"], dict), \ - "global: invalid type, should be dict" - - super().__init__(cfg["global"], debug) - - if "pretend" in cfg["global"]: - pretend = cfg["global"]["pretend"] - assert isinstance(pretend, bool), \ - "global: pretend: invalid value, should be bool" - self["pretend"] = pretend - else: - self["pretend"] = False - - if "socket" in cfg["global"]: - socket = cfg["global"]["socket"] - assert isinstance(socket, str), \ - "global: socket: invalid value, should be string" - self["socket"] = socket - else: - self["socket"] = None - - if "local_addrs" in cfg["global"]: - local_addrs = cfg["global"]["local_addrs"] - assert isinstance(local_addrs, list) and all( - [isinstance(addr, str) for addr in local_addrs]), \ - "global: local_addrs: invalid value, " \ - "should be list of strings" - self["local_addrs"] = local_addrs - else: - self["local_addrs"] = [ - "::1/128", - "127.0.0.0/8", - "10.0.0.0/8", - "172.16.0.0/12", - "192.168.0.0/16"] - - assert "rules" in cfg, \ - "mandatory parameter 'rules' not found" - assert isinstance(cfg["rules"], list), \ - "rules: invalid value, should be list" - - self["rules"] = [] - for idx, rule_cfg in enumerate(cfg["rules"]): - self["rules"].append( - RuleConfig(idx, self, rule_cfg, debug)) diff --git a/pymodmilter/rules.py b/pymodmilter/rules.py new file mode 100644 index 0000000..2f71c55 --- /dev/null +++ b/pymodmilter/rules.py @@ -0,0 +1,124 @@ +# PyMod-Milter is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# PyMod-Milter is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with PyMod-Milter. If not, see . +# + +__all__ = [ + "RuleConfig", + "Rule"] + +import logging + +from pymodmilter import CustomLogger, BaseConfig +from pymodmilter.actions import ActionConfig, Action +from pymodmilter.conditions import ConditionsConfig, Conditions + + +class RuleConfig(BaseConfig): + def __init__(self, idx, milter_cfg, cfg, debug=False): + if "name" not in cfg: + cfg["name"] = f"Rule #{idx}" + + if "loglevel" not in cfg: + cfg["loglevel"] = milter_cfg["loglevel"] + + super().__init__(cfg, debug) + + self["pretend"] = milter_cfg["pretend"] + self["conditions"] = None + self["actions"] = [] + + if "pretend" in cfg: + pretend = cfg["pretend"] + assert isinstance(pretend, bool), \ + f"{self['name']}: pretend: invalid value, should be bool" + self["pretend"] = pretend + + assert "actions" in cfg, \ + f"{self['name']}: mandatory parameter 'actions' not found" + actions = cfg["actions"] + assert isinstance(actions, list), \ + f"{self['name']}: actions: invalid value, should be list" + + self.logger.debug(f"pretend={self['pretend']}, " + f"loglevel={self['loglevel']}") + + if "conditions" in cfg: + conditions = cfg["conditions"] + assert isinstance(conditions, dict), \ + f"{self['name']}: conditions: invalid value, should be dict" + self["conditions"] = ConditionsConfig(self, conditions, debug) + + for idx, action_cfg in enumerate(cfg["actions"]): + self["actions"].append( + ActionConfig(idx, self, action_cfg, debug)) + + +class Rule: + """ + Rule to implement multiple actions on emails. + """ + + def __init__(self, milter_cfg, cfg): + self.logger = cfg.logger + #logger = logging.getLogger(cfg["name"]) + #self.logger = CustomLogger(logger, {"name": cfg["name"]}) + #self.logger.setLevel(cfg["loglevel"]) + + if cfg["conditions"] is None: + self.conditions = None + else: + self.conditions = Conditions(milter_cfg, cfg["conditions"]) + + self._need_body = False + + self.actions = [] + for action_cfg in cfg["actions"]: + action = Action(milter_cfg, action_cfg) + self.actions.append(action) + if action.need_body(): + self._need_body = True + + self.pretend = cfg["pretend"] + + def need_body(self): + """Return True if this rule needs the message body.""" + return self._need_body + + def ignores(self, host=None, envfrom=None, envto=None): + args = {} + + if host is not None: + args["host"] = host + + if envfrom is not None: + args["envfrom"] = envfrom + + if envto is not None: + args["envto"] = envto + + if self.conditions is None or self.conditions.match(args): + for action in self.actions: + if action.conditions is None or action.conditions.match(args): + return False + + return True + + def execute(self, milter, pretend=None): + """Execute all actions of this rule.""" + if pretend is None: + pretend = self.pretend + + for action in self.actions: + milter_action = action.execute(milter, pretend=pretend) + if milter_action is not None: + return milter_action diff --git a/pymodmilter/run.py b/pymodmilter/run.py index b89bebe..4da8f7a 100644 --- a/pymodmilter/run.py +++ b/pymodmilter/run.py @@ -12,6 +12,8 @@ # along with PyMod-Milter. If not, see . # +__all__ = ["main"] + import Milter import argparse import logging @@ -20,7 +22,7 @@ import sys from pymodmilter import ModifyMilter from pymodmilter import __version__ as version -from pymodmilter.config import ModifyMilterConfig +from pymodmilter import ModifyMilterConfig def main(): @@ -83,7 +85,7 @@ def main(): try: logger.debug("prepar milter configuration") - cfg = ModifyMilterConfig(args.cfgfile, args.debug) + cfg = ModifyMilterConfig(args.config, args.debug) if not args.debug: logger.setLevel(cfg["loglevel"]) @@ -100,8 +102,6 @@ def main(): if not cfg["rules"]: raise RuntimeError("no rules configured") - logger.debug("initializing rules ...") - for rule_cfg in cfg["rules"]: if not rule_cfg["actions"]: raise RuntimeError( @@ -112,7 +112,7 @@ def main(): sys.exit(255) if args.test: - print("Configuration ok") + print("Configuration OK") sys.exit(0) # setup console log for runtime diff --git a/pymodmilter/test.py b/pymodmilter/test.py deleted file mode 100755 index dbd784f..0000000 --- a/pymodmilter/test.py +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env python3 - -import config - -a = config.ModifyMilterConfig("test.conf", debug=True) diff --git a/pymodmilter/test.conf b/test.conf similarity index 97% rename from pymodmilter/test.conf rename to test.conf index 998e537..bcbbfde 100644 --- a/pymodmilter/test.conf +++ b/test.conf @@ -176,14 +176,14 @@ # Notes: Path to a file which contains the html representation of the disclaimer. # Value: [ FILE_PATH ] # - "html_template": "docs/templates/disclaimer_html.template", + "html_template": "pymodmilter/docs/templates/disclaimer_html.template", # Option: text_template # Type: String # Notes: Path to a file which contains the text representation of the disclaimer. # Value: [ FILE_PATH ] # - "text_template": "docs/templates/disclaimer_text.template", + "text_template": "pymodmilter/docs/templates/disclaimer_text.template", # Option: error_policy # Type: String