Restructure code and rename some config variables
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user