diff --git a/README.md b/README.md index 7fc42de..9af04d8 100644 --- a/README.md +++ b/README.md @@ -36,8 +36,8 @@ The following configuration options are mandatory in the global section: The following configuration options are mandatory in each quarantine section: * **regex** Case insensitive regular expression to filter e-mail headers. -* **quarantine_type** - One of the quarantine-types described below. +* **storage_type** + One of the storage types described below. * **action** One of the actions described below. * **notification_type** @@ -50,13 +50,13 @@ The following configuration options are mandatory in each quarantine section: SMTP port The following configuration options are optional in each quarantine section: -* **ignore_hosts** +* **host_whitelist** Comma-separated list of host and network addresses to be ignored by this quarantine. * **reject_reason** Reason to return to the client if action is set to reject. -### Quarantine types +### Storage types * **NONE** Original e-mails scrapped, sent to nirvana, black-holed or however you want to call it. @@ -64,7 +64,7 @@ The following configuration options are optional in each quarantine section: Original e-mails are stored on the filesystem with a unique filename. The filename is available as a template variable used in notifiaction templates. The following configuration options are mandatory for this quarantine type: - * **quarantine_directory** + * **storage_directory** The directory in which quarantined e-mails are stored. diff --git a/docs/pyquarantine.conf.example b/docs/pyquarantine.conf.example index 30ad6df..5105d25 100644 --- a/docs/pyquarantine.conf.example +++ b/docs/pyquarantine.conf.example @@ -30,12 +30,12 @@ preferred_quarantine_action = last [spam] -# Option: ignore_hosts +# Option: host_whitelist # Notes: Set a list of host and network addresses to be ignored by this quarantine. # All the common host/network notations are supported, including IPv6. # Value: [ HOST ] # -ignore_hosts = 10.0.0.0/8,172.16.0.0/12,192.168.0.0/16 +host_whitelist = 10.0.0.0/8,172.16.0.0/12,192.168.0.0/16 # Option: regex # Notes: Set the case insensitive regular expression to match against email headers. @@ -57,18 +57,18 @@ smtp_host = 127.0.0.1 # smtp_port = 25 -# Option: quarantine_type -# Notes: Set the quarantine type. +# Option: storage_type +# Notes: Set the storage type. # Values: [ file | none ] # -quarantine_type = file +storage_type = file -# Option: quarantine_directory +# Option: storage_directory # Notes: Set the directory to store quarantined emails. # This option is needed by quarantine type 'file'. # Values: [ DIRECTORY ] # -quarantine_directory = /var/lib/pyquarantine/spam +storage_directory = /var/lib/pyquarantine/spam # Option: action # Notes: Set the milter action to perform if email is processed by this quarantine. diff --git a/pyquarantine/__init__.py b/pyquarantine/__init__.py index 7692455..ca41075 100644 --- a/pyquarantine/__init__.py +++ b/pyquarantine/__init__.py @@ -13,13 +13,14 @@ # __all__ = [ + "Quarantine", "QuarantineMilter", - "generate_milter_config", + "setup_milter", "reload_config", "cli", "mailer", "notifications", - "quarantines", + "storages", "run", "version", "whitelists"] @@ -38,83 +39,300 @@ from collections import defaultdict from io import BytesIO from itertools import groupby from netaddr import IPAddress, IPNetwork -from pyquarantine import quarantines +from pyquarantine import mailer from pyquarantine import notifications +from pyquarantine import storages from pyquarantine import whitelists -class QuarantineMilter(Milter.Base): - """QuarantineMilter based on Milter.Base to implement milter communication +class Quarantine(object): + """Quarantine class suitable for QuarantineMilter - The class variable config needs to be filled with the result of the generate_milter_config function. + The class holds all the objects and functions needed for QuarantineMilter quarantine. """ - config = None - global_config = None - # list of default config files - _config_files = [ - "/etc/pyquarantine/pyquarantine.conf", - os.path.expanduser('~/pyquarantine.conf'), - "pyquarantine.conf"] # list of possible actions _actions = { "ACCEPT": Milter.ACCEPT, "REJECT": Milter.REJECT, "DISCARD": Milter.DISCARD} + def __init__(self, name, index=0, regex=None, storage=None, whitelist=None, + host_whitelist=[], notification=None, action="ACCEPT", + reject_reason=None): + self.logger = logging.getLogger(__name__) + self.name = name + self.index = index + if regex: + self.regex = re.compile( + regex, re.MULTILINE + re.DOTALL + re.IGNORECASE) + self.storage = storage + self.whitelist = whitelist + self.host_whitelist = host_whitelist + self.notification = notification + action = action.upper() + assert action in self._actions + self.action = action + self.milter_action = self._actions[action] + self.reject_reason = reject_reason + + def setup_from_cfg(self, global_cfg, cfg, test=False): + defaults = { + "action": "accept", + "reject_reason": "Message rejected", + "storage_type": "none", + "notification_type": "none", + "whitelist_type": "none", + "host_whitelist": "" + } + # check config + for opt in ["regex", "smtp_host", "smtp_port"] + list(defaults.keys()): + if opt in cfg: + continue + if opt in global_cfg: + cfg[opt] = global_cfg[opt] + elif opt in defaults: + cfg[opt] = defaults[opt] + else: + raise RuntimeError( + "mandatory option '{}' not present in config section '{}' or 'global'".format( + opt, self.name)) + + # pre-compile regex + self.logger.debug( + "{}: compiling regex '{}'".format( + self.name, cfg["regex"])) + self.regex = re.compile( + cfg["regex"], re.MULTILINE + re.DOTALL + re.IGNORECASE) + + self.smtp_host = cfg["smtp_host"] + self.smtp_port = cfg["smtp_port"] + + # create storage instance + storage_type = cfg["storage_type"].lower() + if storage_type in storages.TYPES: + self.logger.debug( + "{}: initializing storage type '{}'".format( + self.name, + storage_type.upper())) + self.storage = storages.TYPES[storage_type]( + self.name, global_cfg, cfg, test) + elif storage_type == "none": + self.logger.debug("{}: storage is NONE".format(self.name)) + self.storage = None + else: + raise RuntimeError( + "{}: unknown storage type '{}'".format( + self.name, storage_type)) + + # create whitelist instance + whitelist_type = cfg["whitelist_type"].lower() + if whitelist_type in whitelists.TYPES: + self.logger.debug( + "{}: initializing whitelist type '{}'".format( + self.name, + whitelist_type.upper())) + self.whitelist = whitelists.TYPES[whitelist_type]( + self.name, global_cfg, cfg, test) + elif whitelist_type == "none": + logger.debug("{}: whitelist is NONE".format(self.name)) + self.whitelist = None + else: + raise RuntimeError( + "{}: unknown whitelist type '{}'".format( + self.name, whitelist_type)) + + # create notification instance + notification_type = cfg["notification_type"].lower() + if notification_type in notifications.TYPES: + self.logger.debug( + "{}: initializing notification type '{}'".format( + self.name, + notification_type.upper())) + self.notification = notifications.TYPES[notification_type]( + self.name, global_cfg, cfg, test) + elif notification_type == "none": + self.logger.debug("{}: notification is NONE".format(self.name)) + self.notification = None + else: + raise RuntimeError( + "{}: unknown notification type '{}'".format( + self.name, notification_type)) + + # determining milter action for this quarantine + action = cfg["action"].upper() + if action in self._actions: + self.logger.debug("{}: action is {}".format(self.name, action)) + self.action = action + self.milter_action = self._actions[action] + else: + raise RuntimeError( + "{}: unknown action '{}'".format(self._name, action)) + + self.reject_reason = cfg["reject_reason"] + + # create host/network whitelist + self.host_whitelist = [] + host_whitelist = set([p.strip() + for p in cfg["host_whitelist"].split(",") if p]) + for host in host_whitelist: + if not host: + continue + # parse network notation + try: + net = IPNetwork(host) + except AddrFormatError as e: + raise RuntimeError("{}: error parsing host_whitelist: {}".format( + self.name, e)) + else: + self.host_whitelist.append(net) + if self.host_whitelist: + self.logger.debug( + "{}: ignore hosts: {}".format( + self.name, + ", ".join(ignored))) + + def notify(self, storage_id, recipient=None, synchronous=True): + "Notify recipient about email in storage." + if not self.storage: + raise RuntimeError( + "storage type is set to None, unable to send notification") + + if not self.notification: + raise RuntimeError( + "notification type is set to None, unable to send notification") + + fp, metadata = self.storage.get_mail(storage_id) + + if recipient is not None: + if recipient not in metadata["recipients"]: + raise RuntimeError("invalid recipient '{}'".format(recipient)) + recipients = [recipient] + else: + recipients = metadata["recipients"] + + self.notification.notify( + metadata["queue_id"], storage_id, metadata["mailfrom"], + recipients, metadata["headers"], fp, + metadata["subgroups"], metadata["named_subgroups"], + synchronous) + fp.close() + + def release(self, storage_id, recipients=None): + "Release email from storage." + if not self.storage: + raise RuntimeError( + "storage type is set to None, unable to release email") + + fp, metadata = self.storage.get_mail(storage_id) + try: + mail = fp.read() + fp.close() + except IOError as e: + raise RuntimeError("unable to read data file: {}".format(e)) + + if recipients and type(recipients) == str: + recipients = [recipients] + else: + recipients = metadata["recipients"] + + for recipient in recipients: + if recipient not in metadata["recipients"]: + raise RuntimeError("invalid recipient '{}'".format(recipient)) + + try: + mailer.smtp_send( + self.smtp_host, + self.smtp_port, + metadata["mailfrom"], + recipient, + mail) + except Exception as e: + raise RuntimeError( + "error while sending email to '{}': {}".format( + recipient, e)) + self.storage.delete(storage_id, recipient) + + def get_storage(self): + return self.storage + + def get_notification(self): + return self.notification + + def get_whitelist(self): + return self.whitelist + + def host_in_whitelist(self, hostaddr): + ip = IPAddress(hostaddr[0]) + for entry in self.host_whitelist: + if ip in entry: + return true + return False + + def match(self, header): + return self.regex.search(header) + + +class QuarantineMilter(Milter.Base): + """QuarantineMilter based on Milter.Base to implement milter communication + + The class variable quarantines needs to be filled by runng the setup_milter function. + + """ + quarantines = [] + preferred_quarantine_action = "first" + + # list of default config files + _cfg_files = [ + "/etc/pyquarantine/pyquarantine.conf", + os.path.expanduser('~/pyquarantine.conf'), + "pyquarantine.conf"] + def __init__(self): self.logger = logging.getLogger(__name__) - # save config, it must not change during runtime - self.global_config = QuarantineMilter.global_config - self.config = QuarantineMilter.config + # save runtime config, it must not change during runtime + self.quarantines = QuarantineMilter.quarantines def _get_preferred_quarantine(self): matching_quarantines = [ q for q in self.recipients_quarantines.values() if q] - if self.global_config["preferred_quarantine_action"] == "first": + if self.preferred_quarantine_action == "first": quarantine = sorted( matching_quarantines, - key=lambda x: x["index"])[0] + key=lambda q: q.index)[0] else: quarantine = sorted( matching_quarantines, - key=lambda x: x["index"], + key=lambda q: q.index, reverse=True)[0] return quarantine @staticmethod - def get_configfiles(): - return QuarantineMilter._config_files + def get_cfg_files(): + return QuarantineMilter._cfg_files @staticmethod - def get_actions(): - return QuarantineMilter._actions - - @staticmethod - def set_configfiles(config_files): - QuarantineMilter._config_files = config_files + def set_cfg_files(cfg_files): + QuarantineMilter._cfg_files = cfg_files def connect(self, hostname, family, hostaddr): self.hostaddr = hostaddr self.logger.debug( "accepted milter connection from {} port {}".format( *hostaddr)) - ip = IPAddress(hostaddr[0]) - for quarantine in self.config.copy(): - for ignore in quarantine["ignore_hosts_list"]: - if ip in ignore: - self.logger.debug( - "host {} is ignored by quarantine {}".format( - hostaddr[0], quarantine["name"])) - self.config.remove(quarantine) - break - if not self.config: + for quarantine in self.quarantines.copy(): + if quarantine.host_in_whitelist(hostaddr): self.logger.debug( - "host {} is ignored by all quarantines, " - "skip further processing", - hostaddr[0]) - return Milter.ACCEPT + "host {} is ignored by quarantine {}".format( + hostaddr[0], quarantine["name"])) + self.quarantines.remove(quarantine) + if not self.quarantines: + self.logger.debug( + "host {} is ignored by all quarantines, " + "skip further processing", + hostaddr[0]) + return Milter.ACCEPT return Milter.CONTINUE @Milter.noreply @@ -176,39 +394,39 @@ class QuarantineMilter(Milter.Base): "{}: checking header against configured quarantines: {}".format( self.queueid, header)) # iterate quarantines - for quarantine in self.config: + for quarantine in self.quarantines: if len(self.recipients_quarantines) == len( self.recipients): # every recipient matched a quarantine already - if quarantine["index"] >= max( - [q["index"] for q in self.recipients_quarantines.values()]): + if quarantine.index >= max( + [q.index for q in self.recipients_quarantines.values()]): # all recipients matched a quarantine with at least # the same precedence already, skip checks against # quarantines with lower precedence self.logger.debug( "{}: {}: skip further checks of this header".format( - self.queueid, quarantine["name"])) + self.queueid, quarantine.name)) break # check email header against quarantine regex self.logger.debug( "{}: {}: checking header against regex '{}'".format( - self.queueid, quarantine["name"], quarantine["regex"])) - match = quarantine["regex_compiled"].search(header) + self.queueid, quarantine.name, quarantine.regex)) + match = quarantine.match(header) if match: self.logger.debug( "{}: {}: header matched regex".format( - self.queueid, quarantine["name"])) + self.queueid, quarantine.name)) # check for whitelisted recipients - whitelist = quarantine["whitelist_obj"] - if whitelist is not None: + whitelist = quarantine.get_whitelist() + if whitelist: try: whitelisted_recipients = self.whitelist_cache.get_whitelisted_recipients( whitelist, self.mailfrom, recipients_to_check) except RuntimeError as e: self.logger.error( "{}: {}: unable to query whitelist: {}".format( - self.queueid, quarantine["name"], e)) + self.queueid, quarantine.name, e)) return Milter.TEMPFAIL else: whitelisted_recipients = {} @@ -219,19 +437,19 @@ class QuarantineMilter(Milter.Base): # recipient is whitelisted in this quarantine self.logger.debug( "{}: {}: recipient '{}' is whitelisted".format( - self.queueid, quarantine["name"], recipient)) + self.queueid, quarantine.name, recipient)) continue if recipient not in self.recipients_quarantines.keys() or \ - self.recipients_quarantines[recipient]["index"] > quarantine["index"]: + self.recipients_quarantines[recipient].index > quarantine.index: self.logger.debug( "{}: {}: set quarantine for recipient '{}'".format( - self.queueid, quarantine["name"], recipient)) + self.queueid, quarantine.name, recipient)) # save match for later use as template # variables - self.quarantines_matches[quarantine["name"]] = match + self.quarantines_matches[quarantine.name] = match self.recipients_quarantines[recipient] = quarantine - if quarantine["index"] == 0: + if quarantine.index == 0: # we do not need to check recipients which # matched the quarantine with the highest # precedence already @@ -240,7 +458,7 @@ class QuarantineMilter(Milter.Base): self.logger.debug( "{}: {}: a quarantine with same or higher precedence " "matched already for recipient '{}'".format( - self.queueid, quarantine["name"], recipient)) + self.queueid, quarantine.name, recipient)) if not recipients_to_check: self.logger.debug( @@ -259,7 +477,7 @@ class QuarantineMilter(Milter.Base): # check if the mail body is needed for recipient, quarantine in self.recipients_quarantines.items(): - if quarantine["quarantine_obj"] or quarantine["notification_obj"]: + if quarantine.get_storage() or quarantine.get_notification(): # mail body is needed, continue processing return Milter.CONTINUE @@ -269,12 +487,12 @@ class QuarantineMilter(Milter.Base): self.logger.info( "{}: {} matching quarantine is '{}', performing milter action {}".format( self.queueid, - self.global_config["preferred_quarantine_action"], - quarantine["name"], - quarantine["action"].upper())) - if quarantine["action"] == "reject": - self.setreply("554", "5.7.0", quarantine["reject_reason"]) - return quarantine["milter_action"] + self.preferred_quarantine_action, + quarantine.name, + quarantine.action)) + if quarantine.action == "REJECT": + self.setreply("554", "5.7.0", quarantine.reject_reason) + return quarantine.milter_action except Exception as e: self.logger.exception( @@ -297,52 +515,54 @@ class QuarantineMilter(Milter.Base): quarantines = [] for quarantine, recipients in groupby( sorted(self.recipients_quarantines, - key=lambda x: self.recipients_quarantines[x]["index"]), + key=lambda x: self.recipients_quarantines[x].index), lambda x: self.recipients_quarantines[x]): quarantines.append((quarantine, list(recipients))) # iterate quarantines sorted by index for quarantine, recipients in sorted( - quarantines, key=lambda x: x[0]["index"]): - quarantine_id = "" + quarantines, key=lambda x: x[0].index): headers = defaultdict(str) for name, value in self.headers: headers[name.lower()] = value - subgroups = self.quarantines_matches[quarantine["name"]].groups( + subgroups = self.quarantines_matches[quarantine.name].groups( default="") - named_subgroups = self.quarantines_matches[quarantine["name"]].groupdict( + named_subgroups = self.quarantines_matches[quarantine.name].groupdict( default="") - # check if a quarantine is configured - if quarantine["quarantine_obj"] is not None: + # check if a storage is configured + storage_id = "" + storage = quarantine.get_storage() + if storage: # add email to quarantine self.logger.info("{}: adding to quarantine '{}' for: {}".format( - self.queueid, quarantine["name"], ", ".join(recipients))) + self.queueid, quarantine.name, ", ".join(recipients))) try: - quarantine_id = quarantine["quarantine_obj"].add( + storage_id = storage.add( self.queueid, self.mailfrom, recipients, headers, self.fp, subgroups, named_subgroups) except RuntimeError as e: self.logger.error( "{}: unable to add to quarantine '{}': {}".format( - self.queueid, quarantine["name"], e)) + self.queueid, quarantine.name, e)) return Milter.TEMPFAIL # check if a notification is configured - if quarantine["notification_obj"] is not None: + notification = quarantine.get_notification() + if notification: # notify self.logger.info( "{}: sending notification for quarantine '{}' to: {}".format( - self.queueid, quarantine["name"], ", ".join(recipients))) + self.queueid, quarantine.name, ", ".join(recipients))) try: - quarantine["notification_obj"].notify( - self.queueid, quarantine_id, + notification.notify( + self.queueid, storage_id, self.mailfrom, recipients, headers, self.fp, subgroups, named_subgroups) except RuntimeError as e: self.logger.error( "{}: unable to send notification for quarantine '{}': {}".format( - self.queueid, quarantine["name"], e)) + self.queueid, quarantine.name, e)) return Milter.TEMPFAIL # remove processed recipient @@ -365,12 +585,12 @@ class QuarantineMilter(Milter.Base): self.logger.info( "{}: {} matching quarantine is '{}', performing milter action {}".format( self.queueid, - self.global_config["preferred_quarantine_action"], - quarantine["name"], - quarantine["action"].upper())) - if quarantine["action"] == "reject": - self.setreply("554", "5.7.0", quarantine["reject_reason"]) - return quarantine["milter_action"] + self.preferred_quarantine_action, + quarantine.name, + quarantine.action)) + if quarantine.action == "REJECT": + self.setreply("554", "5.7.0", quarantine.reject_reason) + return quarantine.milter_action except Exception as e: self.logger.exception( @@ -381,23 +601,24 @@ class QuarantineMilter(Milter.Base): self.logger.debug( "disconnect from {} port {}".format( *self.hostaddr)) + return Milter.CONTINUE -def generate_milter_config(configtest=False, config_files=[]): +def setup_milter(test=False, cfg_files=[]): "Generate the configuration for QuarantineMilter class." logger = logging.getLogger(__name__) # read config file parser = configparser.ConfigParser() - if not config_files: - config_files = parser.read(QuarantineMilter.get_configfiles()) + if not cfg_files: + cfg_files = parser.read(QuarantineMilter.get_cfg_files()) else: - config_files = parser.read(config_files) - if not config_files: + cfg_files = parser.read(cfg_files) + if not cfg_files: raise RuntimeError("config file not found") - QuarantineMilter.set_configfiles(config_files) - os.chdir(os.path.dirname(config_files[0])) + QuarantineMilter.set_cfg_files(cfg_files) + os.chdir(os.path.dirname(cfg_files[0])) # check if mandatory config options in global section are present if "global" not in parser.sections(): @@ -409,164 +630,41 @@ def generate_milter_config(configtest=False, config_files=[]): "mandatory option '{}' not present in config section 'global'".format(option)) # read global config section - global_config = dict(parser.items("global")) - global_config["preferred_quarantine_action"] = global_config["preferred_quarantine_action"].lower() - if global_config["preferred_quarantine_action"] not in ["first", "last"]: + global_cfg = dict(parser.items("global")) + preferred_quarantine_action = global_cfg["preferred_quarantine_action"].lower() + if preferred_quarantine_action not in ["first", "last"]: raise RuntimeError( "option preferred_quarantine_action has illegal value") # read active quarantine names - quarantine_names = [ - q.strip() for q in global_config["quarantines"].split(",")] - if len(quarantine_names) != len(set(quarantine_names)): + quarantines = [ + q.strip() for q in global_cfg["quarantines"].split(",")] + if len(quarantines) != len(set(quarantines)): raise RuntimeError( "at least one quarantine is specified multiple times in quarantines option") - if "global" in quarantine_names: - quarantine_names.remove("global") + if "global" in quarantines: + quarantines.remove("global") logger.warning( "removed illegal quarantine name 'global' from list of active quarantines") - if not quarantine_names: + if not quarantines: raise RuntimeError("no quarantines configured") - milter_config = [] - + milter_quarantines = [] logger.debug("preparing milter configuration ...") # iterate quarantine names - for index, quarantine_name in enumerate(quarantine_names): - + for index, name in enumerate(quarantines): # check if config section for current quarantine exists - if quarantine_name not in parser.sections(): + if name not in parser.sections(): raise RuntimeError( - "config section '{}' does not exist".format(quarantine_name)) - config = dict(parser.items(quarantine_name)) + "config section '{}' does not exist".format(name)) - # check if mandatory config options are present in config - for option in ["regex", "quarantine_type", "notification_type", - "action", "whitelist_type", "smtp_host", "smtp_port"]: - if option not in config.keys() and \ - option in global_config.keys(): - config[option] = global_config[option] - if option not in config.keys(): - raise RuntimeError( - "mandatory option '{}' not present in config section '{}' or 'global'".format( - option, quarantine_name)) + cfg = dict(parser.items(name)) + quarantine = Quarantine(name, index) + quarantine.setup_from_cfg(global_cfg, cfg, test) + milter_quarantines.append(quarantine) - # check if optional config options are present in config - defaults = { - "reject_reason": "Message rejected", - "ignore_hosts": "" - } - for option in defaults.keys(): - if option not in config.keys() and \ - option in global_config.keys(): - config[option] = global_config[option] - if option not in config.keys(): - config[option] = defaults[option] - - # set quarantine name - config["name"] = quarantine_name - - # set the index - config["index"] = index - - # pre-compile regex - logger.debug( - "{}: compiling regex '{}'".format( - quarantine_name, - config["regex"])) - config["regex_compiled"] = re.compile( - config["regex"], re.MULTILINE + re.DOTALL + re.IGNORECASE) - - # create quarantine instance - quarantine_type = config["quarantine_type"].lower() - if quarantine_type in quarantines.TYPES.keys(): - logger.debug( - "{}: initializing quarantine type '{}'".format( - quarantine_name, - quarantine_type.upper())) - quarantine = quarantines.TYPES[quarantine_type]( - global_config, config, configtest) - elif quarantine_type == "none": - logger.debug("{}: quarantine is NONE".format(quarantine_name)) - quarantine = None - else: - raise RuntimeError( - "{}: unknown quarantine type '{}'".format( - quarantine_name, quarantine_type)) - - config["quarantine_obj"] = quarantine - - # create whitelist instance - whitelist_type = config["whitelist_type"].lower() - if whitelist_type in whitelists.TYPES.keys(): - logger.debug( - "{}: initializing whitelist type '{}'".format( - quarantine_name, - whitelist_type.upper())) - whitelist = whitelists.TYPES[whitelist_type]( - global_config, config, configtest) - elif whitelist_type == "none": - logger.debug("{}: whitelist is NONE".format(quarantine_name)) - whitelist = None - else: - raise RuntimeError( - "{}: unknown whitelist type '{}'".format( - quarantine_name, whitelist_type)) - - config["whitelist_obj"] = whitelist - - # create notification instance - notification_type = config["notification_type"].lower() - if notification_type in notifications.TYPES.keys(): - logger.debug( - "{}: initializing notification type '{}'".format( - quarantine_name, - notification_type.upper())) - notification = notifications.TYPES[notification_type]( - global_config, config, configtest) - elif notification_type == "none": - logger.debug("{}: notification is NONE".format(quarantine_name)) - notification = None - else: - raise RuntimeError( - "{}: unknown notification type '{}'".format( - quarantine_name, notification_type)) - - config["notification_obj"] = notification - - # determining milter action for this quarantine - action = config["action"].upper() - if action in QuarantineMilter.get_actions().keys(): - logger.debug("{}: action is {}".format(quarantine_name, action)) - config["milter_action"] = QuarantineMilter.get_actions()[action] - else: - raise RuntimeError( - "{}: unknown action '{}'".format( - quarantine_name, action)) - - # create host/network whitelist - config["ignore_hosts_list"] = [] - ignored = set([p.strip() - for p in config["ignore_hosts"].split(",") if p]) - for ignore in ignored: - if not ignore: - continue - # parse network notation - try: - net = IPNetwork(ignore) - except AddrFormatError as e: - raise RuntimeError("error parsing ignore_hosts: {}".format(e)) - else: - config["ignore_hosts_list"].append(net) - if config["ignore_hosts_list"]: - logger.debug( - "{}: ignore hosts: {}".format( - quarantine_name, - ", ".join(ignored))) - - milter_config.append(config) - - return global_config, milter_config + QuarantineMilter.preferred_quarantine_action = preferred_quarantine_action + QuarantineMilter.quarantines = milter_quarantines def reload_config(): @@ -574,11 +672,9 @@ def reload_config(): logger = logging.getLogger(__name__) try: - global_config, config = generate_milter_config() + setup_milter() except RuntimeError as e: logger.info(e) logger.info("daemon is still running with previous configuration") else: - logger.info("reloading configuration") - QuarantineMilter.global_config = global_config - QuarantineMilter.config = config + logger.info("reloaded configuration") diff --git a/pyquarantine/cli.py b/pyquarantine/cli.py index 3fb0701..42e7bdf 100644 --- a/pyquarantine/cli.py +++ b/pyquarantine/cli.py @@ -26,23 +26,36 @@ import pyquarantine from pyquarantine.version import __version__ as version -def _get_quarantine_obj(config, quarantine): +def _get_quarantine(quarantines, name): try: - quarantine_obj = next((q["quarantine_obj"] - for q in config if q["name"] == quarantine)) + quarantine = next((q for q in quarantines if q.name == name)) except StopIteration: - raise RuntimeError("invalid quarantine '{}'".format(quarantine)) - return quarantine_obj + raise RuntimeError("invalid quarantine '{}'".format(name)) + return quarantine +def _get_storage(quarantines, name): + quarantine = _get_quarantine(quarantines, name) + storage = quarantine.get_storage() + if not storage: + raise RuntimeError( + "storage type is set to NONE") + return storage -def _get_whitelist_obj(config, quarantine): - try: - whitelist_obj = next((q["whitelist_obj"] - for q in config if q["name"] == quarantine)) - except StopIteration: - raise RuntimeError("invalid quarantine '{}'".format(quarantine)) - return whitelist_obj +def _get_notification(quarantines, name): + quarantine = _get_quarantine(quarantines, name) + notification = quarantine.get_notification() + if not notification: + raise RuntimeError( + "notification type is set to NONE") + return notification +def _get_whitelist(quarantines, name): + quarantine = _get_quarantine(quarantines, name) + whitelist = quarantine.get_whitelist() + if not whitelist: + raise RuntimeError( + "whitelist type is set to NONE") + return whitelist def print_table(columns, rows): if not rows: @@ -85,51 +98,72 @@ def print_table(columns, rows): print(row_format.format(*row)) -def list_quarantines(config, args): +def list_quarantines(quarantines, args): if args.batch: - print("\n".join([quarantine["name"] for quarantine in config])) + print("\n".join([q.name for q in quarantines])) else: + qlist = [] + for q in quarantines: + storage = q.get_storage() + if storage: + storage_type = q.get_storage().storage_type + else: + storage_type = "NONE" + + notification = q.get_notification() + if notification: + notification_type = q.get_notification().notification_type + else: + notification_type = "NONE" + + whitelist = q.get_whitelist() + if whitelist: + whitelist_type = q.get_whitelist().whitelist_type + else: + whitelist_type = "NONE" + + qlist.append({ + "name": q.name, + "storage": storage_type, + "notification": notification_type, + "whitelist": whitelist_type, + "action": q.action}) print_table( - [("Name", "name"), ("Quarantine", "quarantine_type"), - ("Notification", "notification_type"), ("Action", "action")], - config + [("Name", "name"), + ("Storage", "storage"), + ("Notification", "notification"), + ("Whitelist", "whitelist"), + ("Action", "action")], + qlist ) -def list_quarantine_emails(config, args): +def list_quarantine_emails(quarantines, args): logger = logging.getLogger(__name__) - - # get quarantine object - quarantine = _get_quarantine_obj(config, args.quarantine) - if quarantine is None: - raise RuntimeError( - "quarantine type is set to None, unable to list emails") - + storage = _get_storage(quarantines, args.quarantine) # find emails and transform some metadata values to strings rows = [] - emails = quarantine.find( - mailfrom=args.mailfrom, - recipients=args.recipients, - older_than=args.older_than) - for quarantine_id, metadata in emails.items(): - row = emails[quarantine_id] - row["quarantine_id"] = quarantine_id + emails = storage.find( + args.mailfrom, args.recipients, args.older_than) + for storage_id, metadata in emails.items(): + row = emails[storage_id] + row["storage_id"] = storage_id row["date"] = time.strftime( '%Y-%m-%d %H:%M:%S', time.localtime( metadata["date"])) row["mailfrom"] = metadata["mailfrom"] row["recipient"] = metadata["recipients"].pop(0) - if "subject" not in emails[quarantine_id]["headers"].keys(): - emails[quarantine_id]["headers"]["subject"] = "" + if "subject" not in emails[storage_id]["headers"].keys(): + emails[storage_id]["headers"]["subject"] = "" row["subject"] = str(make_header(decode_header( - emails[quarantine_id]["headers"]["subject"])))[:60].replace( + emails[storage_id]["headers"]["subject"])))[:60].replace( "\r", "").replace("\n", "").strip() rows.append(row) if metadata["recipients"]: row = { - "quarantine_id": "", + "storage_id": "", "date": "", "mailfrom": "", "recipient": metadata["recipients"].pop(0), @@ -145,21 +179,16 @@ def list_quarantine_emails(config, args): if not emails: logger.info("quarantine '{}' is empty".format(args.quarantine)) print_table( - [("Quarantine-ID", "quarantine_id"), ("Date", "date"), + [("Quarantine-ID", "storage_id"), ("Date", "date"), ("From", "mailfrom"), ("Recipient(s)", "recipient"), ("Subject", "subject")], rows ) -def list_whitelist(config, args): +def list_whitelist(quarantines, args): logger = logging.getLogger(__name__) - - # get whitelist object - whitelist = _get_whitelist_obj(config, args.quarantine) - if whitelist is None: - raise RuntimeError( - "whitelist type is set to None, unable to list entries") + whitelist = _get_whitelist(quarantines, args.quarantine) # find whitelist entries entries = whitelist.find( @@ -190,14 +219,9 @@ def list_whitelist(config, args): ) -def add_whitelist_entry(config, args): +def add_whitelist_entry(quarantines, args): logger = logging.getLogger(__name__) - - # get whitelist object - whitelist = _get_whitelist_obj(config, args.quarantine) - if whitelist is None: - raise RuntimeError( - "whitelist type is set to None, unable to add entries") + whitelist = _get_whitelist(quarantines, args.quarantine) # check existing entries entries = whitelist.check(args.mailfrom, args.recipient) @@ -235,50 +259,31 @@ def add_whitelist_entry(config, args): logger.info("whitelist entry added successfully") -def delete_whitelist_entry(config, args): +def delete_whitelist_entry(quarantines, args): logger = logging.getLogger(__name__) - - whitelist = _get_whitelist_obj(config, args.quarantine) - if whitelist is None: - raise RuntimeError( - "whitelist type is set to None, unable to delete entries") - + whitelist = _get_whitelist(quarantines, args.quarantine) whitelist.delete(args.whitelist_id) logger.info("whitelist entry deleted successfully") -def notify_email(config, args): +def notify(quarantines, args): logger = logging.getLogger(__name__) - - quarantine = _get_quarantine_obj(config, args.quarantine) - if quarantine is None: - raise RuntimeError( - "quarantine type is set to None, unable to send notification") + quarantine = _get_quarantine(quarantines, args.quarantine) quarantine.notify(args.quarantine_id, args.recipient) - logger.info("sent notification successfully") + logger.info("notification sent successfully") -def release_email(config, args): +def release(quarantines, args): logger = logging.getLogger(__name__) - - quarantine = _get_quarantine_obj(config, args.quarantine) - if quarantine is None: - raise RuntimeError( - "quarantine type is set to None, unable to release email") - + quarantine = _get_quarantine(quarantines, args.quarantine) quarantine.release(args.quarantine_id, args.recipient) logger.info("quarantined email released successfully") -def delete_email(config, args): +def delete(quarantines, args): logger = logging.getLogger(__name__) - - quarantine = _get_quarantine_obj(config, args.quarantine) - if quarantine is None: - raise RuntimeError( - "quarantine type is set to None, unable to delete email") - - quarantine.delete(args.quarantine_id, args.recipient) + storage = _get_storage(quarantines, args.quarantine) + storage.delete(args.quarantine_id, args.recipient) logger.info("quarantined email deleted successfully") @@ -304,7 +309,7 @@ def main(): "-c", "--config", help="Config files to read.", nargs="+", metavar="CFG", - default=pyquarantine.QuarantineMilter.get_configfiles()) + default=pyquarantine.QuarantineMilter.get_cfg_files()) parser.add_argument( "-d", "--debug", help="Log debugging messages.", @@ -394,7 +399,7 @@ def main(): "-a", "--all", help="Release email for all recipients.", action="store_true") - quarantine_notify_parser.set_defaults(func=notify_email) + quarantine_notify_parser.set_defaults(func=notify) # quarantine release command quarantine_release_parser = quarantine_subparsers.add_parser( "release", @@ -421,7 +426,7 @@ def main(): "-a", "--all", help="Release email for all recipients.", action="store_true") - quarantine_release_parser.set_defaults(func=release_email) + quarantine_release_parser.set_defaults(func=release) # quarantine delete command quarantine_delete_parser = quarantine_subparsers.add_parser( "delete", @@ -447,7 +452,7 @@ def main(): "-a", "--all", help="Delete email for all recipients.", action="store_true") - quarantine_delete_parser.set_defaults(func=delete_email) + quarantine_delete_parser.set_defaults(func=delete) # whitelist command group whitelist_parser = subparsers.add_parser( @@ -558,8 +563,8 @@ def main(): # try to generate milter configs try: - global_config, config = pyquarantine.generate_milter_config( - config_files=args.config, configtest=True) + pyquarantine.setup_milter( + cfg_files=args.config, test=True) except RuntimeError as e: logger.error(e) sys.exit(255) @@ -580,7 +585,7 @@ def main(): # call the commands function try: - args.func(config, args) + args.func(pyquarantine.QuarantineMilter.quarantines, args) except RuntimeError as e: logger.error(e) sys.exit(1) diff --git a/pyquarantine/notifications.py b/pyquarantine/notifications.py index a93525d..db46167 100644 --- a/pyquarantine/notifications.py +++ b/pyquarantine/notifications.py @@ -31,14 +31,13 @@ from pyquarantine import mailer class BaseNotification(object): "Notification base class" + notification_type = "base" - def __init__(self, global_config, config, configtest=False): - self.quarantine_name = config["name"] - self.global_config = global_config - self.config = config + def __init__(self, name, global_cfg, cfg, test=False): + self.name = name self.logger = logging.getLogger(__name__) - def notify(self, queueid, quarantine_id, mailfrom, recipients, headers, + def notify(self, queueid, storage_id, mailfrom, recipients, headers, fp, subgroups=None, named_subgroups=None, synchronous=False): fp.seek(0) pass @@ -46,6 +45,7 @@ class BaseNotification(object): class EMailNotification(BaseNotification): "Notification class to send notifications via mail." + notification_type = "email" _html_text = "text/html" _plain_text = "text/plain" _bad_tags = [ @@ -111,44 +111,40 @@ class EMailNotification(BaseNotification): "width" ] - def __init__(self, global_config, config, configtest=False): + def __init__(self, name, global_cfg, cfg, test=False): super(EMailNotification, self).__init__( - global_config, config, configtest) + name, global_cfg, cfg, test) - # check if mandatory options are present in config - for option in [ + defaults = { + "notification_email_replacement_img": "", + "notification_email_strip_images": "false", + "notification_email_parser_lib": "lxml" + } + # check config + for opt in [ "notification_email_smtp_host", "notification_email_smtp_port", "notification_email_envelope_from", "notification_email_from", "notification_email_subject", "notification_email_template", - "notification_email_embedded_imgs"]: - if option not in self.config.keys() and option in self.global_config.keys(): - self.config[option] = self.global_config[option] - if option not in self.config.keys(): + "notification_email_embedded_imgs"] + list(defaults.keys()): + if opt in cfg: + continue + if opt in global_cfg: + cfg[opt] = global_cfg[opt] + elif opt in defaults: + cfg[opt] = defaults[opt] + else: raise RuntimeError( "mandatory option '{}' not present in config section '{}' or 'global'".format( - option, self.quarantine_name)) + opt, self.name)) - # check if optional config options are present in config - defaults = { - "notification_email_replacement_img": "", - "notification_email_strip_images": "false", - "notification_email_parser_lib": "lxml" - } - for option in defaults.keys(): - if option not in config.keys() and \ - option in global_config.keys(): - config[option] = global_config[option] - if option not in config.keys(): - config[option] = defaults[option] - - self.smtp_host = self.config["notification_email_smtp_host"] - self.smtp_port = self.config["notification_email_smtp_port"] - self.mailfrom = self.config["notification_email_envelope_from"] - self.from_header = self.config["notification_email_from"] - self.subject = self.config["notification_email_subject"] + self.smtp_host = cfg["notification_email_smtp_host"] + self.smtp_port = cfg["notification_email_smtp_port"] + self.mailfrom = cfg["notification_email_envelope_from"] + self.from_header = cfg["notification_email_from"] + self.subject = cfg["notification_email_subject"] testvars = defaultdict(str, test="TEST") @@ -169,14 +165,14 @@ class EMailNotification(BaseNotification): # read and parse email notification template try: self.template = open( - self.config["notification_email_template"], "r").read() + cfg["notification_email_template"], "r").read() self.template.format_map(testvars) except IOError as e: raise RuntimeError("error reading template: {}".format(e)) except ValueError as e: raise RuntimeError("error parsing template: {}".format(e)) - strip_images = self.config["notification_email_strip_images"].strip().upper() + strip_images = cfg["notification_email_strip_images"].strip().upper() if strip_images in ["TRUE", "ON", "YES"]: self.strip_images = True elif strip_images in ["FALSE", "OFF", "NO"]: @@ -184,12 +180,12 @@ class EMailNotification(BaseNotification): else: raise RuntimeError("error parsing notification_email_strip_images: unknown value") - self.parser_lib = self.config["notification_email_parser_lib"].strip() + self.parser_lib = cfg["notification_email_parser_lib"].strip() if self.parser_lib not in ["lxml", "html.parser"]: raise RuntimeError("error parsing notification_email_parser_lib: unknown value") # read email replacement image if specified - replacement_img = self.config["notification_email_replacement_img"].strip() + replacement_img = cfg["notification_email_replacement_img"].strip() if not self.strip_images and replacement_img: try: self.replacement_img = MIMEImage( @@ -205,7 +201,7 @@ class EMailNotification(BaseNotification): # read images to embed if specified embedded_img_paths = [ - p.strip() for p in self.config["notification_email_embedded_imgs"].split(",") if p] + p.strip() for p in cfg["notification_email_embedded_imgs"].split(",") if p] self.embedded_imgs = [] for img_path in embedded_img_paths: # read image @@ -291,14 +287,14 @@ class EMailNotification(BaseNotification): del(element.attrs[attribute]) return soup - def notify(self, queueid, quarantine_id, mailfrom, recipients, headers, fp, + def notify(self, queueid, storage_id, mailfrom, recipients, headers, fp, subgroups=None, named_subgroups=None, synchronous=False): "Notify recipients via email." super( EMailNotification, self).notify( queueid, - quarantine_id, + storage_id, mailfrom, recipients, headers, @@ -372,7 +368,7 @@ class EMailNotification(BaseNotification): EMAIL_ENVELOPE_TO=escape(recipient), EMAIL_ENVELOPE_TO_URL=escape(quote(recipient)), EMAIL_SUBJECT=escape(decoded_headers["subject"]), - EMAIL_QUARANTINE_ID=quarantine_id) + EMAIL_QUARANTINE_ID=storage_id) if subgroups: number = 0 diff --git a/pyquarantine/quarantines.py b/pyquarantine/quarantines.py index ec6dd53..864aaab 100644 --- a/pyquarantine/quarantines.py +++ b/pyquarantine/quarantines.py @@ -22,63 +22,60 @@ from glob import glob from shutil import copyfileobj from time import gmtime -from pyquarantine import mailer +class BaseMailStorage(object): + "Mail storage base class" + storage_type = "base" -class BaseQuarantine(object): - "Quarantine base class" - - def __init__(self, global_config, config, configtest=False): - self.name = config["name"] - self.global_config = global_config - self.config = config + def __init__(self, name, global_cfg, cfg, test=False): + self.name = name self.logger = logging.getLogger(__name__) def add(self, queueid, mailfrom, recipients, headers, fp, subgroups=None, named_subgroups=None): - "Add email to quarantine." + "Add email to storage." fp.seek(0) return "" def find(self, mailfrom=None, recipients=None, older_than=None): - "Find emails in quarantine." + "Find emails in storage." return - def get_metadata(self, quarantine_id): - "Return metadata of quarantined email." + def get_metadata(self, storage_id): + "Return metadata of email in storage." return - def delete(self, quarantine_id, recipient=None): - "Delete email from quarantine." + def delete(self, storage_id, recipients=None): + "Delete email from storage." return - def notify(self, quarantine_id, recipient=None): - "Notify recipient about email in quarantine." - if not self.config["notification_obj"]: - raise RuntimeError( - "notification type is set to None, unable to send notifications") - return - - def release(self, quarantine_id, recipient=None): - "Release email from quarantine." + def get_mail(self, storage_id): + "Return a file pointer to the email and metadata." return -class FileQuarantine(BaseQuarantine): - "Quarantine class to store mails on filesystem." +class FileMailStorage(BaseMailStorage): + "Storage class to store mails on filesystem." + storage_type = "file" - def __init__(self, global_config, config, configtest=False): - super(FileQuarantine, self).__init__(global_config, config, configtest) + def __init__(self, name, global_cfg, cfg, test=False): + super(FileMailStorage, self).__init__(name, global_cfg, cfg, test) - # check if mandatory options are present in config - for option in ["quarantine_directory"]: - if option not in self.config.keys() and option in self.global_config.keys(): - self.config[option] = self.global_config[option] - if option not in self.config.keys(): + defaults = {} + # check config + + for opt in ["storage_directory"] + list(defaults.keys()): + if opt in cfg: + continue + if opt in global_cfg: + cfg[opt] = global_cfg[opt] + elif opt in defaults: + cfg[opt] = defaults[opt] + else: raise RuntimeError( "mandatory option '{}' not present in config section '{}' or 'global'".format( - option, self.name)) - self.directory = self.config["quarantine_directory"] + opt, self.name)) + self.directory = cfg["storage_directory"] # check if quarantine directory exists and is writable if not os.path.isdir(self.directory) or not os.access( @@ -88,26 +85,26 @@ class FileQuarantine(BaseQuarantine): self.directory)) self._metadata_suffix = ".metadata" - def _save_datafile(self, quarantine_id, fp): - datafile = os.path.join(self.directory, quarantine_id) + def _save_datafile(self, storage_id, fp): + datafile = os.path.join(self.directory, storage_id) try: with open(datafile, "wb") as f: copyfileobj(fp, f) except IOError as e: raise RuntimeError("unable save data file: {}".format(e)) - def _save_metafile(self, quarantine_id, metadata): + def _save_metafile(self, storage_id, metadata): metafile = os.path.join( self.directory, "{}{}".format( - quarantine_id, self._metadata_suffix)) + storage_id, self._metadata_suffix)) try: with open(metafile, "w") as f: json.dump(metadata, f, indent=2) except IOError as e: raise RuntimeError("unable to save metadata file: {}".format(e)) - def _remove(self, quarantine_id): - datafile = os.path.join(self.directory, quarantine_id) + def _remove(self, storage_id): + datafile = os.path.join(self.directory, storage_id) metafile = "{}{}".format(datafile, self._metadata_suffix) try: @@ -122,9 +119,9 @@ class FileQuarantine(BaseQuarantine): def add(self, queueid, mailfrom, recipients, headers, fp, subgroups=None, named_subgroups=None): - "Add email to file quarantine and return quarantine-id." + "Add email to file storage and return storage id." super( - FileQuarantine, + FileMailStorage, self).add( queueid, mailfrom, @@ -133,11 +130,11 @@ class FileQuarantine(BaseQuarantine): fp, subgroups, named_subgroups) - quarantine_id = "{}_{}".format( + storage_id = "{}_{}".format( datetime.now().strftime("%Y%m%d%H%M%S"), queueid) # save mail - self._save_datafile(quarantine_id, fp) + self._save_datafile(storage_id, fp) # save metadata metadata = { @@ -150,24 +147,24 @@ class FileQuarantine(BaseQuarantine): "named_subgroups": named_subgroups } try: - self._save_metafile(quarantine_id, metadata) + self._save_metafile(storage_id, metadata) except RuntimeError as e: - datafile = os.path.join(self.directory, quarantine_id) + datafile = os.path.join(self.directory, storage_id) os.remove(datafile) raise e - return quarantine_id + return storage_id - def get_metadata(self, quarantine_id): - "Return metadata of quarantined email." - super(FileQuarantine, self).get_metadata(quarantine_id) + def get_metadata(self, storage_id): + "Return metadata of email in storage." + super(FileMailStorage, self).get_metadata(storage_id) metafile = os.path.join( self.directory, "{}{}".format( - quarantine_id, self._metadata_suffix)) + storage_id, self._metadata_suffix)) if not os.path.isfile(metafile): raise RuntimeError( - "invalid quarantine id '{}'".format(quarantine_id)) + "invalid storage id '{}'".format(storage_id)) try: with open(metafile, "r") as f: @@ -182,8 +179,8 @@ class FileQuarantine(BaseQuarantine): return metadata def find(self, mailfrom=None, recipients=None, older_than=None): - "Find emails in quarantine." - super(FileQuarantine, self).find(mailfrom, recipients, older_than) + "Find emails in storage." + super(FileMailStorage, self).find(mailfrom, recipients, older_than) if isinstance(mailfrom, str): mailfrom = [mailfrom] if isinstance(recipients, str): @@ -196,9 +193,9 @@ class FileQuarantine(BaseQuarantine): if not os.path.isfile(metafile): continue - quarantine_id = os.path.basename( + storage_id = os.path.basename( metafile[:-len(self._metadata_suffix)]) - metadata = self.get_metadata(quarantine_id) + metadata = self.get_metadata(storage_id) if older_than is not None: if timegm(gmtime()) - metadata["date"] < (older_than * 86400): continue @@ -214,96 +211,44 @@ class FileQuarantine(BaseQuarantine): elif len(set(recipients + metadata["recipients"])) == len(recipients + metadata["recipients"]): continue - emails[quarantine_id] = metadata + emails[storage_id] = metadata return emails - def delete(self, quarantine_id, recipient=None): - "Delete email in quarantine." - super(FileQuarantine, self).delete(quarantine_id, recipient) + def delete(self, storage_id, recipients=None): + "Delete email from storage." + super(FileMailStorage, self).delete(storage_id, recipients) try: - metadata = self.get_metadata(quarantine_id) + metadata = self.get_metadata(storage_id) except RuntimeError as e: raise RuntimeError("unable to delete email: {}".format(e)) - if recipient is None: - self._remove(quarantine_id) + if not recipients: + self._remove(storage_id) else: - if recipient not in metadata["recipients"]: - raise RuntimeError("invalid recipient '{}'".format(recipient)) + if type(recipients) == str: + recipients = [recipients] + for recipient in recipients: + if recipient not in metadata["recipients"]: + raise RuntimeError("invalid recipient '{}'".format(recipient)) + metadata["recipients"].remove(recipient) + if not metadata["recipients"]: + self._remove(storage_id) + else: + self._save_metafile(storage_id, metadata) - metadata["recipients"].remove(recipient) - if not metadata["recipients"]: - self._remove(quarantine_id) - else: - self._save_metafile(quarantine_id, metadata) - - def notify(self, quarantine_id, recipient=None): - "Notify recipient about email in quarantine." - super(FileQuarantine, self).notify(quarantine_id, recipient) + def get_mail(self, storage_id): + super(FileMailStorage, self).get_mail(storage_id) + metadata = self.get_metadata(storage_id) + datafile = os.path.join(self.directory, storage_id) try: - metadata = self.get_metadata(quarantine_id) - except RuntimeError as e: - raise RuntimeError("unable to release email: {}".format(e)) - - if recipient is not None: - if recipient not in metadata["recipients"]: - raise RuntimeError("invalid recipient '{}'".format(recipient)) - recipients = [recipient] - else: - recipients = metadata["recipients"] - - datafile = os.path.join(self.directory, quarantine_id) - try: - with open(datafile, "rb") as fp: - self.config["notification_obj"].notify( - metadata["queue_id"], quarantine_id, metadata["mailfrom"], - recipients, metadata["headers"], fp, - metadata["subgroups"], metadata["named_subgroups"], - synchronous=True) + fp = open(datafile, "rb") except IOError as e: - raise RuntimeError - - def release(self, quarantine_id, recipient=None): - "Release email from quarantine." - super(FileQuarantine, self).release(quarantine_id, recipient) - - try: - metadata = self.get_metadata(quarantine_id) - except RuntimeError as e: - raise RuntimeError("unable to release email: {}".format(e)) - - if recipient is not None: - if recipient not in metadata["recipients"]: - raise RuntimeError("invalid recipient '{}'".format(recipient)) - recipients = [recipient] - else: - recipients = metadata["recipients"] - - datafile = os.path.join(self.directory, quarantine_id) - try: - with open(datafile, "rb") as f: - mail = f.read() - except IOError as e: - raise RuntimeError("unable to read data file: {}".format(e)) - - for recipient in recipients: - try: - mailer.smtp_send( - self.config["smtp_host"], - self.config["smtp_port"], - metadata["mailfrom"], - recipient, - mail) - except Exception as e: - raise RuntimeError( - "error while sending email to '{}': {}".format( - recipient, e)) - - self.delete(quarantine_id, recipient) + raise RuntimeError("unable to open email data file: {}".format(e)) + return (fp, metadata) -# list of quarantine types and their related quarantine classes -TYPES = {"file": FileQuarantine} +# list of storage types and their related storage classes +TYPES = {"file": FileMailStorage} diff --git a/pyquarantine/run.py b/pyquarantine/run.py index a38a064..487d736 100644 --- a/pyquarantine/run.py +++ b/pyquarantine/run.py @@ -35,11 +35,11 @@ def main(): "-c", "--config", help="List of config files to read.", nargs="+", - default=pyquarantine.QuarantineMilter.get_configfiles()) + default=pyquarantine.QuarantineMilter.get_cfg_files()) parser.add_argument( "-s", "--socket", help="Socket used to communicate with the MTA.", - required=True) + default="inet:8899@127.0.0.1") parser.add_argument( "-d", "--debug", help="Log debugging messages.", @@ -65,7 +65,7 @@ def main(): syslog_name = "{}: [%(name)s] %(levelname)s".format(syslog_name) # set config files for milter class - pyquarantine.QuarantineMilter.set_configfiles(args.config) + pyquarantine.QuarantineMilter.set_cfg_files(args.config) root_logger = logging.getLogger() root_logger.setLevel(loglevel) @@ -78,7 +78,7 @@ def main(): logger = logging.getLogger(__name__) if args.test: try: - pyquarantine.generate_milter_config(args.test) + pyquarantine.setup_milter(test=args.test) print("Configuration ok") except RuntimeError as e: logger.error(e) @@ -101,18 +101,14 @@ def main(): logger.info("PyQuarantine-Milter starting") try: # generate milter config - global_config, config = pyquarantine.generate_milter_config() + pyquarantine.setup_milter() except RuntimeError as e: logger.error(e) sys.exit(255) - pyquarantine.QuarantineMilter.global_config = global_config - pyquarantine.QuarantineMilter.config = config - # register to have the Milter factory create instances of your class: Milter.factory = pyquarantine.QuarantineMilter Milter.set_exception_policy(Milter.TEMPFAIL) - # Milter.set_flags(0) # tell sendmail which features we use # run milter rc = 0 @@ -122,6 +118,7 @@ def main(): except Milter.milter.error as e: logger.error(e) rc = 255 + pyquarantine.mailer.queue.put(None) logger.info("PyQuarantine-Milter terminated") sys.exit(rc) diff --git a/pyquarantine/whitelists.py b/pyquarantine/whitelists.py index db8dcf1..208768f 100644 --- a/pyquarantine/whitelists.py +++ b/pyquarantine/whitelists.py @@ -24,11 +24,11 @@ from playhouse.db_url import connect class WhitelistBase(object): "Whitelist base class" - def __init__(self, global_config, config, configtest=False): - self.global_config = global_config - self.config = config - self.configtest = configtest - self.name = config["name"] + whitelist_type = "base" + + def __init__(self, name, global_cfg, cfg, test=False): + self.name = name + self.test = test self.logger = logging.getLogger(__name__) self.valid_entry_regex = re.compile( r"^[a-zA-Z0-9_.+-]*?(@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+)?$") @@ -73,28 +73,35 @@ class Meta(object): class DatabaseWhitelist(WhitelistBase): "Whitelist class to store whitelist in a database" + whitelist_type = "db" _db_connections = {} _db_tables = {} - def __init__(self, global_config, config, configtest=False): + def __init__(self, name, global_cfg, cfg, test=False): super( DatabaseWhitelist, self).__init__( - global_config, - config, - configtest) + global_cfg, + cfg, + test) - # check if mandatory options are present in config - for option in ["whitelist_db_connection", "whitelist_db_table"]: - if option not in self.config.keys() and option in self.global_config.keys(): - self.config[option] = self.global_config[option] - if option not in self.config.keys(): + defaults = {} + + # check config + for opt in ["whitelist_db_connection", "whitelist_db_table"] + list(defaults.keys()): + if opt in cfg: + continue + if opt in global_cfg: + cfg[opt] = global_cfg[opt] + elif opt in defaults: + cfg[opt] = defaults[opt] + else: raise RuntimeError( "mandatory option '{}' not present in config section '{}' or 'global'".format( - option, self.name)) + opt, self.name)) - tablename = self.config["whitelist_db_table"] - connection_string = self.config["whitelist_db_connection"] + tablename = cfg["whitelist_db_table"] + connection_string = cfg["whitelist_db_connection"] if connection_string in DatabaseWhitelist._db_connections.keys(): db = DatabaseWhitelist._db_connections[connection_string] @@ -127,7 +134,7 @@ class DatabaseWhitelist(WhitelistBase): if tablename not in DatabaseWhitelist._db_tables[connection_string]: DatabaseWhitelist._db_tables[connection_string].append(tablename) - if not self.configtest: + if not self.test: try: db.create_tables([self.model]) except Exception as e: