23 Commits
0.0.1 ... 0.0.6

Author SHA1 Message Date
ec9a2e875b Change version to 0.0.6 2020-01-21 15:58:04 +01:00
7a31c01955 Fix handling of mails with missing headers 2020-01-21 15:56:08 +01:00
9e5f51f6f5 Improve email body extraction and decoding 2019-11-26 15:17:02 +01:00
086a3fc0ce Use TLS if available when sending emails 2019-11-19 15:22:05 +01:00
56e03ffffe Change version to 0.0.3 2019-11-18 15:21:13 +01:00
32682cfb8c Add option to send notifications to another host 2019-11-18 15:11:09 +01:00
20b3e3ddd3 Single-sourcing the version string 2019-10-29 17:33:29 +01:00
bacc05cb41 Fix replacement image and change to header 2019-10-29 16:47:52 +01:00
25af4b422a Fix double angle brackets in From header 2019-10-29 15:45:55 +01:00
7020c53b28 Add option notification_email_parser_lib 2019-10-26 13:04:19 +02:00
7509629b44 Fix brackets 2019-10-23 07:36:24 +02:00
9e7691f5ea Add pre tag to whitelist 2019-10-22 23:14:49 +02:00
5ff72dc5e7 Add debug messages in notifications.py 2019-10-22 22:29:06 +02:00
5892d9a2b7 Escape html characters in plain message text 2019-10-22 21:57:53 +02:00
0169c0650e Fix typo in variable name 2019-10-22 20:53:06 +02:00
b6deccc2aa Prefer text/html again, like most email clients 2019-10-22 20:49:52 +02:00
bf28ba64cb Add p tags to whitelist and prefer text/plain 2019-10-22 20:33:53 +02:00
73215bbef7 Change README.md 2019-10-21 19:05:38 +02:00
f0f2c6b742 Add option to strip images from emails 2019-10-21 18:48:04 +02:00
228be9f4be Fix iteration bug while removing attributes 2019-10-21 17:53:44 +02:00
422ed5b4e6 Fix typo in run.py 2019-10-21 14:06:28 +02:00
89a01d92c8 Make source PEP8 conform 2019-10-17 22:25:10 +02:00
6ea167bc52 Add dependency to lxml extra of BeautifulSoup4 2019-10-16 23:27:10 +02:00
11 changed files with 1015 additions and 421 deletions

View File

@@ -45,7 +45,7 @@ The following configuration options are mandatory in each quarantine section:
* **whitelist_type** * **whitelist_type**
One of the whitelist types described below. One of the whitelist types described below.
* **smtp_host** * **smtp_host**
SMTP host to inject original e-mails. This is needed if not all recipients of an e-mail are whitelisted SMTP host used to release original e-mails from the quarantine.
* **smtp_port** * **smtp_port**
SMTP port SMTP port
@@ -87,7 +87,7 @@ The following configuration options are optional in each quarantine section:
* **{EMAIL_QUARANTINE_ID}** * **{EMAIL_QUARANTINE_ID}**
Quarantine-ID of the original e-mail if available, empty otherwise. Quarantine-ID of the original e-mail if available, empty otherwise.
* **{EMAIL_HTML_TEXT}** * **{EMAIL_HTML_TEXT}**
Sanitized version of the e-mail text part of the original e-mail. Only harmless HTML tags and attributes are included. Images are replaced with the image set by notification_email_replacement_img option. Sanitized version of the e-mail text part of the original e-mail. Only harmless HTML tags and attributes are included. Images are optionally stripped or replaced with the image set by notification_email_replacement_img option.
Some template variables are only available if the regex of the matching quarantine contains subgroups or named subgroups (python syntax). This is useful to include information (e.g. virus names, spam points, ...) of the matching header within the notification. Some template variables are only available if the regex of the matching quarantine contains subgroups or named subgroups (python syntax). This is useful to include information (e.g. virus names, spam points, ...) of the matching header within the notification.
The following dynamic template variables are available: The following dynamic template variables are available:
@@ -97,20 +97,31 @@ The following configuration options are optional in each quarantine section:
Content of a named subgroup, 'subgroup_name' will be replaced by its name. Content of a named subgroup, 'subgroup_name' will be replaced by its name.
The following configuration options are mandatory for this notification type: The following configuration options are mandatory for this notification type:
* **notification_email_smtp_host**
SMTP host used to send notification e-mails.
* **notification_email_smtp_port**
SMTP port.
* **notification_email_envelope_from** * **notification_email_envelope_from**
Notification e-mail envelope from-address. Notification e-mail envelope from-address.
* **notification_email_from** * **notification_email_from**
Value of the notification e-mail from header. Every e-mail template variable described above is usable. Value of the notification e-mail from header. Optionally, you may use the EMAIL_FROM template variable described above.
* **notification_email_subject** * **notification_email_subject**
Notification e-mail subject. All e-mail template variable described above is usable. Notification e-mail subject. Optionally, you may use the EMAIL_SUBJECT template variable described above.
* **notification_email_template** * **notification_email_template**
Path to the notification e-mail template. It is hold in memory during runtime. Path to the notification e-mail template. It is hold in memory during runtime.
* **notification_email_replacement_img**
Path to the image to replace images in e-mails. It is hold in memory during runtime. Leave it empty to disable.
* **notification_email_embedded_imgs** * **notification_email_embedded_imgs**
Comma-separated list of images to embed into the notification e-mail. The Content-ID of each image will be set to the filename, so you can reference it from the e-mail template. All images are hold in memory during runtime. Comma-separated list of images to embed into the notification e-mail. The Content-ID of each image will be set to the filename, so you can reference it from the e-mail template. All images are hold in memory during runtime.
Leave empty to disable. Leave empty to disable.
The following configuration options are optional for this notification type:
* **notification_email_strip_images**
Enable to strip images from e-mails. This option superseeds notification_email_replacement_img.
* **notification_email_replacement_img**
Path to an image to replace images in e-mails. It is hold in memory during runtime.
* **notification_email_parser_lib**
HTML parser library used to parse text part of emails.
### Actions ### Actions
Every quarantine responds with a milter-action if an e-mail header matches the configured regular expression. Please think carefully what you set here or your MTA will do something you do not want. Every quarantine responds with a milter-action if an e-mail header matches the configured regular expression. Please think carefully what you set here or your MTA will do something you do not want.
The following actions are available: The following actions are available:

View File

@@ -88,6 +88,18 @@ reject_reason = Message rejected
# #
notification_type = email notification_type = email
# Option: notification_email_smtp_host
# Notes: Set the SMTP host. It will be used to send notification e-mails.
# Values: [ HOSTNAME | IP_ADDRESS ]
#
notification_email_smtp_host = 127.0.0.1
# Option: notification_email_smtp_port
# Notes: Set the SMTP port.
# Values: [ PORT ]
#
notification_email_smtp_port = 25
# Option: notification_email_envelope_from # Option: notification_email_envelope_from
# Notes: Set the envelope-from address used when sending notification emails. # Notes: Set the envelope-from address used when sending notification emails.
# This option is needed by notification type 'email'. # This option is needed by notification type 'email'.
@@ -117,10 +129,15 @@ notification_email_subject = Spam Quarantine Notification
# #
notification_email_template = templates/notification.template notification_email_template = templates/notification.template
# Option: notification_email_strip_images
# Notes: Optionally enable this option to strip img tags from emails.
# Values: [ TRUE | ON | YES | FALSE | OFF | NO ]
#
notification_email_strip_images = False
# Option: notification_email_replacement_img # Option: notification_email_replacement_img
# Notes: Set the path to the replacement image for img tags within emails. # Notes: Optionally set the path to a replacement image for img tags within emails.
# A relative path to this config file can be used. # A relative path to this config file can be used.
# This option is needed by notification type 'email'.
# Values: [ IMAGE_PATH ] # Values: [ IMAGE_PATH ]
# #
notification_email_replacement_img = templates/removed.png notification_email_replacement_img = templates/removed.png
@@ -133,6 +150,13 @@ notification_email_replacement_img = templates/removed.png
# #
notification_email_embedded_imgs = templates/logo.png notification_email_embedded_imgs = templates/logo.png
# Option: notification_email_parser_lib
# Notes: Optionally set the parser library used to parse
# the text part of emails.
# Values: [ lxml | html.parser ]
#
notification_email_parser_lib = lxml
# Option: whitelist_type # Option: whitelist_type
# Notes: Set the whitelist type. # Notes: Set the whitelist type.
# Values: [ db | none ] # Values: [ db | none ]

View File

@@ -12,7 +12,18 @@
# along with PyQuarantineMilter. If not, see <http://www.gnu.org/licenses/>. # along with PyQuarantineMilter. If not, see <http://www.gnu.org/licenses/>.
# #
__all__ = ["QuarantineMilter", "generate_milter_config", "reload_config", "mailer", "notifications", "run", "quarantines", "whitelists"] __all__ = [
"QuarantineMilter",
"generate_milter_config",
"reload_config",
"cli",
"mailer",
"notifications",
"quarantines",
"run",
"version",
"whitelists"]
name = "pyquarantine" name = "pyquarantine"
import Milter import Milter
@@ -42,9 +53,15 @@ class QuarantineMilter(Milter.Base):
global_config = None global_config = None
# list of default config files # list of default config files
_config_files = ["/etc/pyquarantine/pyquarantine.conf", os.path.expanduser('~/pyquarantine.conf'), "pyquarantine.conf"] _config_files = [
"/etc/pyquarantine/pyquarantine.conf",
os.path.expanduser('~/pyquarantine.conf'),
"pyquarantine.conf"]
# list of possible actions # list of possible actions
_actions = {"ACCEPT": Milter.ACCEPT, "REJECT": Milter.REJECT, "DISCARD": Milter.DISCARD} _actions = {
"ACCEPT": Milter.ACCEPT,
"REJECT": Milter.REJECT,
"DISCARD": Milter.DISCARD}
def __init__(self): def __init__(self):
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
@@ -53,11 +70,17 @@ class QuarantineMilter(Milter.Base):
self.config = QuarantineMilter.config self.config = QuarantineMilter.config
def _get_preferred_quarantine(self): def _get_preferred_quarantine(self):
matching_quarantines = [q for q in self.recipients_quarantines.values() if q] matching_quarantines = [
q for q in self.recipients_quarantines.values() if q]
if self.global_config["preferred_quarantine_action"] == "first": if self.global_config["preferred_quarantine_action"] == "first":
quarantine = sorted(matching_quarantines, key=lambda x: x["index"])[0] quarantine = sorted(
matching_quarantines,
key=lambda x: x["index"])[0]
else: else:
quarantine = sorted(matching_quarantines, key=lambda x: x["index"], reverse=True)[0] quarantine = sorted(
matching_quarantines,
key=lambda x: x["index"],
reverse=True)[0]
return quarantine return quarantine
@staticmethod @staticmethod
@@ -73,16 +96,23 @@ class QuarantineMilter(Milter.Base):
QuarantineMilter._config_files = config_files QuarantineMilter._config_files = config_files
def connect(self, IPname, family, hostaddr): def connect(self, IPname, family, hostaddr):
self.logger.debug("accepted milter connection from {} port {}".format(*hostaddr)) self.logger.debug(
"accepted milter connection from {} port {}".format(
*hostaddr))
ip = IPAddress(hostaddr[0]) ip = IPAddress(hostaddr[0])
for quarantine in self.config.copy(): for quarantine in self.config.copy():
for ignore in quarantine["ignore_hosts_list"]: for ignore in quarantine["ignore_hosts_list"]:
if ip in ignore: if ip in ignore:
self.logger.debug("host {} is ignored by quarantine {}".format(hostaddr[0], quarantine["name"])) self.logger.debug(
"host {} is ignored by quarantine {}".format(
hostaddr[0], quarantine["name"]))
self.config.remove(quarantine) self.config.remove(quarantine)
break break
if not self.config: if not self.config:
self.logger.debug("host {} is ignored by all quarantines, skip further processing", hostaddr[0]) self.logger.debug(
"host {} is ignored by all quarantines, "
"skip further processing",
hostaddr[0])
return Milter.ACCEPT return Milter.ACCEPT
return Milter.CONTINUE return Milter.CONTINUE
@@ -100,7 +130,8 @@ class QuarantineMilter(Milter.Base):
@Milter.noreply @Milter.noreply
def data(self): def data(self):
self.queueid = self.getsymval('i') self.queueid = self.getsymval('i')
self.logger.debug("{}: received queue-id from MTA".format(self.queueid)) self.logger.debug(
"{}: received queue-id from MTA".format(self.queueid))
self.recipients = list(self.recipients) self.recipients = list(self.recipients)
self.headers = [] self.headers = []
return Milter.CONTINUE return Milter.CONTINUE
@@ -122,28 +153,43 @@ class QuarantineMilter(Milter.Base):
recipients_to_check = self.recipients.copy() recipients_to_check = self.recipients.copy()
for name, value in self.headers: for name, value in self.headers:
header = "{}: {}".format(name, value) header = "{}: {}".format(name, value)
self.logger.debug("{}: checking header against configured quarantines: {}".format(self.queueid, header)) self.logger.debug(
"{}: checking header against configured quarantines: {}".format(
self.queueid, header))
# iterate quarantines # iterate quarantines
for quarantine in self.config: for quarantine in self.config:
if len(self.recipients_quarantines) == len(self.recipients): if len(self.recipients_quarantines) == len(
self.recipients):
# every recipient matched a quarantine already # every recipient matched a quarantine already
if quarantine["index"] >= max([q["index"] for q in self.recipients_quarantines.values()]): if quarantine["index"] >= max(
# all recipients matched a quarantine with at least the same precedence already, skip checks against quarantines with lower precedence [q["index"] for q in self.recipients_quarantines.values()]):
self.logger.debug("{}: {}: skip further checks of this header".format(self.queueid, quarantine["name"])) # all recipients matched a quarantine with at least
break # 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"]))
break
# check email header against quarantine regex # check email header against quarantine regex
self.logger.debug("{}: {}: checking header against regex '{}'".format(self.queueid, quarantine["name"], quarantine["regex"])) self.logger.debug(
"{}: {}: checking header against regex '{}'".format(
self.queueid, quarantine["name"], quarantine["regex"]))
match = quarantine["regex_compiled"].search(header) match = quarantine["regex_compiled"].search(header)
if match: if match:
self.logger.debug("{}: {}: header matched regex".format(self.queueid, quarantine["name"])) self.logger.debug(
"{}: {}: header matched regex".format(
self.queueid, quarantine["name"]))
# check for whitelisted recipients # check for whitelisted recipients
whitelist = quarantine["whitelist_obj"] whitelist = quarantine["whitelist_obj"]
if whitelist != None: if whitelist is not None:
try: try:
whitelisted_recipients = self.whitelist_cache.get_whitelisted_recipients(whitelist, self.mailfrom, recipients_to_check) whitelisted_recipients = self.whitelist_cache.get_whitelisted_recipients(
whitelist, self.mailfrom, recipients_to_check)
except RuntimeError as e: except RuntimeError as e:
self.logger.error("{}: {}: unable to query whitelist: {}".format(self.queueid, quarantine["name"], e)) self.logger.error(
"{}: {}: unable to query whitelist: {}".format(
self.queueid, quarantine["name"], e))
return Milter.TEMPFAIL return Milter.TEMPFAIL
else: else:
whitelisted_recipients = {} whitelisted_recipients = {}
@@ -152,28 +198,44 @@ class QuarantineMilter(Milter.Base):
for recipient in recipients_to_check.copy(): for recipient in recipients_to_check.copy():
if recipient in whitelisted_recipients: if recipient in whitelisted_recipients:
# recipient is whitelisted in this quarantine # recipient is whitelisted in this quarantine
self.logger.debug("{}: {}: recipient '{}' is whitelisted".format(self.queueid, quarantine["name"], recipient)) self.logger.debug(
"{}: {}: recipient '{}' is whitelisted".format(
self.queueid, quarantine["name"], recipient))
continue continue
if recipient not in self.recipients_quarantines.keys() or self.recipients_quarantines[recipient]["index"] > quarantine["index"]: if recipient not in self.recipients_quarantines.keys() or \
self.logger.debug("{}: {}: set quarantine for recipient '{}'".format(self.queueid, quarantine["name"], recipient)) self.recipients_quarantines[recipient]["index"] > quarantine["index"]:
# save match for later use as template variables self.logger.debug(
"{}: {}: set quarantine for recipient '{}'".format(
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 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 # we do not need to check recipients which
# matched the quarantine with the highest
# precedence already
recipients_to_check.remove(recipient) recipients_to_check.remove(recipient)
else: else:
self.logger.debug("{}: {}: a quarantine with the same or higher precedence matched already for recipient '{}'".format(self.queueid, quarantine["name"], recipient)) self.logger.debug(
"{}: {}: a quarantine with same or higher precedence "
"matched already for recipient '{}'".format(
self.queueid, quarantine["name"], recipient))
if not recipients_to_check: if not recipients_to_check:
self.logger.debug("{}: all recipients matched the first quarantine, skipping all remaining header checks".format(self.queueid)) self.logger.debug(
"{}: all recipients matched the first quarantine, "
"skipping all remaining header checks".format(
self.queueid))
break break
# check if no quarantine has matched for all recipients # check if no quarantine has matched for all recipients
if not self.recipients_quarantines: if not self.recipients_quarantines:
# accept email # accept email
self.logger.info("{}: passed clean for all recipients".format(self.queueid)) self.logger.info(
"{}: passed clean for all recipients".format(
self.queueid))
return Milter.ACCEPT return Milter.ACCEPT
# check if the email body is needed # check if the email body is needed
@@ -184,7 +246,9 @@ class QuarantineMilter(Milter.Base):
break break
if keep_body: if keep_body:
self.logger.debug("{}: initializing memory buffer to save email data".format(self.queueid)) self.logger.debug(
"{}: initializing memory buffer to save email data".format(
self.queueid))
# initialize memory buffer to save email data # initialize memory buffer to save email data
self.fp = BytesIO() self.fp = BytesIO()
# write email headers to memory buffer # write email headers to memory buffer
@@ -192,10 +256,15 @@ class QuarantineMilter(Milter.Base):
self.fp.write("{}: {}\n".format(name, value).encode()) self.fp.write("{}: {}\n".format(name, value).encode())
self.fp.write("\n".encode()) self.fp.write("\n".encode())
else: else:
# quarantine and notification are disabled on all matching quarantines, return configured action # quarantine and notification are disabled on all matching
# quarantines, return configured action
quarantine = self._get_preferred_quarantine() quarantine = self._get_preferred_quarantine()
self.logger.info("{}: {} matching quarantine is '{}', performing milter action {}".format(self.queueid, self.global_config["preferred_quarantine_action"], self.logger.info(
quarantine["name"], quarantine["action"].upper())) "{}: {} matching quarantine is '{}', performing milter action {}".format(
self.queueid,
self.global_config["preferred_quarantine_action"],
quarantine["name"],
quarantine["action"].upper()))
if quarantine["action"] == "reject": if quarantine["action"] == "reject":
self.setreply("554", "5.7.0", quarantine["reject_reason"]) self.setreply("554", "5.7.0", quarantine["reject_reason"])
return quarantine["milter_action"] return quarantine["milter_action"]
@@ -203,7 +272,8 @@ class QuarantineMilter(Milter.Base):
return Milter.CONTINUE return Milter.CONTINUE
except Exception as e: except Exception as e:
self.logger.exception("an exception occured in eoh function: {}".format(e)) self.logger.exception(
"an exception occured in eoh function: {}".format(e))
return Milter.TEMPFAIL return Milter.TEMPFAIL
def body(self, chunk): def body(self, chunk):
@@ -211,7 +281,8 @@ class QuarantineMilter(Milter.Base):
# save received body chunk # save received body chunk
self.fp.write(chunk) self.fp.write(chunk)
except Exception as e: except Exception as e:
self.logger.exception("an exception occured in body function: {}".format(e)) self.logger.exception(
"an exception occured in body function: {}".format(e))
return Milter.TEMPFAIL return Milter.TEMPFAIL
return Milter.CONTINUE return Milter.CONTINUE
@@ -220,39 +291,53 @@ class QuarantineMilter(Milter.Base):
# processing recipients grouped by quarantines # processing recipients grouped by quarantines
quarantines = [] quarantines = []
for quarantine, recipients in groupby( for quarantine, recipients in groupby(
sorted(self.recipients_quarantines, key=lambda x: self.recipients_quarantines[x]["index"]) sorted(self.recipients_quarantines,
, lambda x: self.recipients_quarantines[x]): key=lambda x: self.recipients_quarantines[x]["index"]),
lambda x: self.recipients_quarantines[x]):
quarantines.append((quarantine, list(recipients))) quarantines.append((quarantine, list(recipients)))
# iterate quarantines sorted by index # iterate quarantines sorted by index
for quarantine, recipients in sorted(quarantines, key=lambda x: x[0]["index"]): for quarantine, recipients in sorted(
quarantines, key=lambda x: x[0]["index"]):
quarantine_id = "" quarantine_id = ""
headers = defaultdict(str) headers = defaultdict(str)
for name, value in self.headers: for name, value in self.headers:
headers[name.lower()] = value headers[name.lower()] = value
subgroups = self.quarantines_matches[quarantine["name"]].groups(default="") subgroups = self.quarantines_matches[quarantine["name"]].groups(
named_subgroups = self.quarantines_matches[quarantine["name"]].groupdict(default="") default="")
named_subgroups = self.quarantines_matches[quarantine["name"]].groupdict(
default="")
# check if a quarantine is configured # check if a quarantine is configured
if quarantine["quarantine_obj"] != None: if quarantine["quarantine_obj"] is not None:
# add email to quarantine # add email to quarantine
self.logger.info("{}: adding to quarantine '{}' for: {}".format(self.queueid, quarantine["name"], ", ".join(recipients))) self.logger.info("{}: adding to quarantine '{}' for: {}".format(
self.queueid, quarantine["name"], ", ".join(recipients)))
try: try:
quarantine_id = quarantine["quarantine_obj"].add(self.queueid, self.mailfrom, recipients, headers, self.fp, quarantine_id = quarantine["quarantine_obj"].add(
subgroups, named_subgroups) self.queueid, self.mailfrom, recipients, headers, self.fp,
subgroups, named_subgroups)
except RuntimeError as e: except RuntimeError as e:
self.logger.error("{}: unable to add to quarantine '{}': {}".format(self.queueid, quarantine["name"], e)) self.logger.error(
"{}: unable to add to quarantine '{}': {}".format(
self.queueid, quarantine["name"], e))
return Milter.TEMPFAIL return Milter.TEMPFAIL
# check if a notification is configured # check if a notification is configured
if quarantine["notification_obj"] != None: if quarantine["notification_obj"] is not None:
# notify # notify
self.logger.info("{}: sending notification for quarantine '{}' to: {}".format(self.queueid, quarantine["name"], ", ".join(recipients))) self.logger.info(
"{}: sending notification for quarantine '{}' to: {}".format(
self.queueid, quarantine["name"], ", ".join(recipients)))
try: try:
quarantine["notification_obj"].notify(self.queueid, quarantine_id, self.mailfrom, recipients, headers, self.fp, quarantine["notification_obj"].notify(
subgroups, named_subgroups) self.queueid, quarantine_id,
self.mailfrom, recipients, headers, self.fp,
subgroups, named_subgroups)
except RuntimeError as e: except RuntimeError as e:
self.logger.error("{}: unable to send notification for quarantine '{}': {}".format(self.queueid, quarantine["name"], e)) self.logger.error(
"{}: unable to send notification for quarantine '{}': {}".format(
self.queueid, quarantine["name"], e))
return Milter.TEMPFAIL return Milter.TEMPFAIL
# remove processed recipient # remove processed recipient
@@ -264,19 +349,27 @@ class QuarantineMilter(Milter.Base):
# email passed clean for at least one recipient, accepting email # email passed clean for at least one recipient, accepting email
if self.recipients: if self.recipients:
self.logger.info("{}: passed clean for: {}".format(self.queueid, ", ".join(self.recipients))) self.logger.info(
"{}: passed clean for: {}".format(
self.queueid, ", ".join(
self.recipients)))
return Milter.ACCEPT return Milter.ACCEPT
## return configured action # return configured action
quarantine = self._get_preferred_quarantine() quarantine = self._get_preferred_quarantine()
self.logger.info("{}: {} matching quarantine is '{}', performing milter action {}".format(self.queueid, self.global_config["preferred_quarantine_action"], self.logger.info(
quarantine["name"], quarantine["action"].upper())) "{}: {} matching quarantine is '{}', performing milter action {}".format(
self.queueid,
self.global_config["preferred_quarantine_action"],
quarantine["name"],
quarantine["action"].upper()))
if quarantine["action"] == "reject": if quarantine["action"] == "reject":
self.setreply("554", "5.7.0", quarantine["reject_reason"]) self.setreply("554", "5.7.0", quarantine["reject_reason"])
return quarantine["milter_action"] return quarantine["milter_action"]
except Exception as e: except Exception as e:
self.logger.exception("an exception occured in eom function: {}".format(e)) self.logger.exception(
"an exception occured in eom function: {}".format(e))
return Milter.TEMPFAIL return Milter.TEMPFAIL
@@ -298,24 +391,30 @@ def generate_milter_config(configtest=False, config_files=[]):
# check if mandatory config options in global section are present # check if mandatory config options in global section are present
if "global" not in parser.sections(): if "global" not in parser.sections():
raise RuntimeError("mandatory section 'global' not present in config file") raise RuntimeError(
"mandatory section 'global' not present in config file")
for option in ["quarantines", "preferred_quarantine_action"]: for option in ["quarantines", "preferred_quarantine_action"]:
if not parser.has_option("global", option): if not parser.has_option("global", option):
raise RuntimeError("mandatory option '{}' not present in config section 'global'".format(option)) raise RuntimeError(
"mandatory option '{}' not present in config section 'global'".format(option))
# read global config section # read global config section
global_config = dict(parser.items("global")) global_config = dict(parser.items("global"))
global_config["preferred_quarantine_action"] = global_config["preferred_quarantine_action"].lower() global_config["preferred_quarantine_action"] = global_config["preferred_quarantine_action"].lower()
if global_config["preferred_quarantine_action"] not in ["first", "last"]: if global_config["preferred_quarantine_action"] not in ["first", "last"]:
raise RuntimeError("option preferred_quarantine_action has illegal value") raise RuntimeError(
"option preferred_quarantine_action has illegal value")
# read active quarantine names # read active quarantine names
quarantine_names = [ q.strip() for q in global_config["quarantines"].split(",") ] quarantine_names = [
q.strip() for q in global_config["quarantines"].split(",")]
if len(quarantine_names) != len(set(quarantine_names)): if len(quarantine_names) != len(set(quarantine_names)):
raise RuntimeError("at least one quarantine is specified multiple times in quarantines option") raise RuntimeError(
"at least one quarantine is specified multiple times in quarantines option")
if "global" in quarantine_names: if "global" in quarantine_names:
quarantine_names.remove("global") quarantine_names.remove("global")
logger.warning("removed illegal quarantine name 'global' from list of active quarantines") logger.warning(
"removed illegal quarantine name 'global' from list of active quarantines")
if not quarantine_names: if not quarantine_names:
raise RuntimeError("no quarantines configured") raise RuntimeError("no quarantines configured")
@@ -327,16 +426,20 @@ def generate_milter_config(configtest=False, config_files=[]):
# check if config section for current quarantine exists # check if config section for current quarantine exists
if quarantine_name not in parser.sections(): if quarantine_name not in parser.sections():
raise RuntimeError("config section '{}' does not exist".format(quarantine_name)) raise RuntimeError(
"config section '{}' does not exist".format(quarantine_name))
config = dict(parser.items(quarantine_name)) config = dict(parser.items(quarantine_name))
# check if mandatory config options are present in config # check if mandatory config options are present in config
for option in ["regex", "quarantine_type", "notification_type", "action", "whitelist_type", "smtp_host", "smtp_port"]: for option in ["regex", "quarantine_type", "notification_type",
"action", "whitelist_type", "smtp_host", "smtp_port"]:
if option not in config.keys() and \ if option not in config.keys() and \
option in global_config.keys(): option in global_config.keys():
config[option] = global_config[option] config[option] = global_config[option]
if option not in config.keys(): if option not in config.keys():
raise RuntimeError("mandatory option '{}' not present in config section '{}' or 'global'".format(option, quarantine_name)) raise RuntimeError(
"mandatory option '{}' not present in config section '{}' or 'global'".format(
option, quarantine_name))
# check if optional config options are present in config # check if optional config options are present in config
defaults = { defaults = {
@@ -357,45 +460,67 @@ def generate_milter_config(configtest=False, config_files=[]):
config["index"] = index config["index"] = index
# pre-compile regex # pre-compile regex
logger.debug("{}: compiling regex '{}'".format(quarantine_name, config["regex"])) logger.debug(
config["regex_compiled"] = re.compile(config["regex"], re.MULTILINE + re.DOTALL + re.IGNORECASE) "{}: compiling regex '{}'".format(
quarantine_name,
config["regex"]))
config["regex_compiled"] = re.compile(
config["regex"], re.MULTILINE + re.DOTALL + re.IGNORECASE)
# create quarantine instance # create quarantine instance
quarantine_type = config["quarantine_type"].lower() quarantine_type = config["quarantine_type"].lower()
if quarantine_type in quarantines.TYPES.keys(): if quarantine_type in quarantines.TYPES.keys():
logger.debug("{}: initializing quarantine type '{}'".format(quarantine_name, quarantine_type.upper())) logger.debug(
quarantine = quarantines.TYPES[quarantine_type](global_config, config, configtest) "{}: initializing quarantine type '{}'".format(
quarantine_name,
quarantine_type.upper()))
quarantine = quarantines.TYPES[quarantine_type](
global_config, config, configtest)
elif quarantine_type == "none": elif quarantine_type == "none":
logger.debug("{}: quarantine is NONE".format(quarantine_name)) logger.debug("{}: quarantine is NONE".format(quarantine_name))
quarantine = None quarantine = None
else: else:
raise RuntimeError("{}: unknown quarantine type '{}'".format(quarantine_name, quarantine_type)) raise RuntimeError(
"{}: unknown quarantine type '{}'".format(
quarantine_name, quarantine_type))
config["quarantine_obj"] = quarantine config["quarantine_obj"] = quarantine
# create whitelist instance # create whitelist instance
whitelist_type = config["whitelist_type"].lower() whitelist_type = config["whitelist_type"].lower()
if whitelist_type in whitelists.TYPES.keys(): if whitelist_type in whitelists.TYPES.keys():
logger.debug("{}: initializing whitelist type '{}'".format(quarantine_name, whitelist_type.upper())) logger.debug(
whitelist = whitelists.TYPES[whitelist_type](global_config, config, configtest) "{}: initializing whitelist type '{}'".format(
quarantine_name,
whitelist_type.upper()))
whitelist = whitelists.TYPES[whitelist_type](
global_config, config, configtest)
elif whitelist_type == "none": elif whitelist_type == "none":
logger.debug("{}: whitelist is NONE".format(quarantine_name)) logger.debug("{}: whitelist is NONE".format(quarantine_name))
whitelist = None whitelist = None
else: else:
raise RuntimeError("{}: unknown whitelist type '{}'".format(quarantine_name, whitelist_type)) raise RuntimeError(
"{}: unknown whitelist type '{}'".format(
quarantine_name, whitelist_type))
config["whitelist_obj"] = whitelist config["whitelist_obj"] = whitelist
# create notification instance # create notification instance
notification_type = config["notification_type"].lower() notification_type = config["notification_type"].lower()
if notification_type in notifications.TYPES.keys(): if notification_type in notifications.TYPES.keys():
logger.debug("{}: initializing notification type '{}'".format(quarantine_name, notification_type.upper())) logger.debug(
notification = notifications.TYPES[notification_type](global_config, config, configtest) "{}: initializing notification type '{}'".format(
quarantine_name,
notification_type.upper()))
notification = notifications.TYPES[notification_type](
global_config, config, configtest)
elif notification_type == "none": elif notification_type == "none":
logger.debug("{}: notification is NONE".format(quarantine_name)) logger.debug("{}: notification is NONE".format(quarantine_name))
notification = None notification = None
else: else:
raise RuntimeError("{}: unknown notification type '{}'".format(quarantine_name, notification_type)) raise RuntimeError(
"{}: unknown notification type '{}'".format(
quarantine_name, notification_type))
config["notification_obj"] = notification config["notification_obj"] = notification
@@ -405,11 +530,14 @@ def generate_milter_config(configtest=False, config_files=[]):
logger.debug("{}: action is {}".format(quarantine_name, action)) logger.debug("{}: action is {}".format(quarantine_name, action))
config["milter_action"] = QuarantineMilter.get_actions()[action] config["milter_action"] = QuarantineMilter.get_actions()[action]
else: else:
raise RuntimeError("{}: unknown action '{}'".format(quarantine_name, action)) raise RuntimeError(
"{}: unknown action '{}'".format(
quarantine_name, action))
# create host/network whitelist # create host/network whitelist
config["ignore_hosts_list"] = [] config["ignore_hosts_list"] = []
ignored = set([ p.strip() for p in config["ignore_hosts"].split(",") if p]) ignored = set([p.strip()
for p in config["ignore_hosts"].split(",") if p])
for ignore in ignored: for ignore in ignored:
if not ignore: if not ignore:
continue continue
@@ -421,7 +549,10 @@ def generate_milter_config(configtest=False, config_files=[]):
else: else:
config["ignore_hosts_list"].append(net) config["ignore_hosts_list"].append(net)
if config["ignore_hosts_list"]: if config["ignore_hosts_list"]:
logger.debug("{}: ignore hosts: {}".format(quarantine_name, ", ".join(ignored))) logger.debug(
"{}: ignore hosts: {}".format(
quarantine_name,
", ".join(ignored)))
milter_config.append(config) milter_config.append(config)

View File

@@ -22,10 +22,12 @@ import time
import pyquarantine import pyquarantine
from pyquarantine.version import __version__ as version
def _get_quarantine_obj(config, quarantine): def _get_quarantine_obj(config, quarantine):
try: try:
quarantine_obj = next((q["quarantine_obj"] for q in config if q["name"] == quarantine)) quarantine_obj = next((q["quarantine_obj"]
for q in config if q["name"] == quarantine))
except StopIteration: except StopIteration:
raise RuntimeError("invalid quarantine '{}'".format(quarantine)) raise RuntimeError("invalid quarantine '{}'".format(quarantine))
return quarantine_obj return quarantine_obj
@@ -33,7 +35,8 @@ def _get_quarantine_obj(config, quarantine):
def _get_whitelist_obj(config, quarantine): def _get_whitelist_obj(config, quarantine):
try: try:
whitelist_obj = next((q["whitelist_obj"] for q in config if q["name"] == quarantine)) whitelist_obj = next((q["whitelist_obj"]
for q in config if q["name"] == quarantine))
except StopIteration: except StopIteration:
raise RuntimeError("invalid quarantine '{}'".format(quarantine)) raise RuntimeError("invalid quarantine '{}'".format(quarantine))
return whitelist_obj return whitelist_obj
@@ -51,7 +54,8 @@ def print_table(columns, rows):
# get the length of the header string # get the length of the header string
lengths = [len(header)] lengths = [len(header)]
# get the length of the longest value # get the length of the longest value
lengths.append(len(str(max(rows, key=lambda x: len(str(x[key])))[key]))) lengths.append(
len(str(max(rows, key=lambda x: len(str(x[key])))[key])))
# use the the longer one # use the the longer one
length = max(lengths) length = max(lengths)
column_lengths.append(length) column_lengths.append(length)
@@ -70,7 +74,7 @@ def print_table(columns, rows):
print(row_format.format(*[column[0] for column in columns])) print(row_format.format(*[column[0] for column in columns]))
print(separator) print(separator)
keys = [ entry[1] for entry in columns ] keys = [entry[1] for entry in columns]
# print rows # print rows
for entry in rows: for entry in rows:
row = [] row = []
@@ -81,10 +85,11 @@ def print_table(columns, rows):
def list_quarantines(config, args): def list_quarantines(config, args):
if args.batch: if args.batch:
print("\n".join([ quarantine["name"] for quarantine in config ])) print("\n".join([quarantine["name"] for quarantine in config]))
else: else:
print_table( print_table(
[("Name", "name"), ("Quarantine", "quarantine_type"), ("Notification", "notification_type"), ("Action", "action")], [("Name", "name"), ("Quarantine", "quarantine_type"),
("Notification", "notification_type"), ("Action", "action")],
config config
) )
@@ -94,19 +99,29 @@ def list_quarantine_emails(config, args):
# get quarantine object # get quarantine object
quarantine = _get_quarantine_obj(config, args.quarantine) quarantine = _get_quarantine_obj(config, args.quarantine)
if quarantine == None: if quarantine is None:
raise RuntimeError("quarantine type is set to None, unable to list emails") raise RuntimeError(
"quarantine type is set to None, unable to list emails")
# find emails and transform some metadata values to strings # find emails and transform some metadata values to strings
rows = [] rows = []
emails = quarantine.find(mailfrom=args.mailfrom, recipients=args.recipients, older_than=args.older_than) emails = quarantine.find(
mailfrom=args.mailfrom,
recipients=args.recipients,
older_than=args.older_than)
for quarantine_id, metadata in emails.items(): for quarantine_id, metadata in emails.items():
row = emails[quarantine_id] row = emails[quarantine_id]
row["quarantine_id"] = quarantine_id row["quarantine_id"] = quarantine_id
row["date"] = time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(metadata["date"])) row["date"] = time.strftime(
'%Y-%m-%d %H:%M:%S',
time.localtime(
metadata["date"]))
row["mailfrom"] = metadata["mailfrom"] row["mailfrom"] = metadata["mailfrom"]
row["recipient"] = metadata["recipients"].pop(0) row["recipient"] = metadata["recipients"].pop(0)
row["subject"] = emails[quarantine_id]["headers"]["subject"][:60] if "subject" in emails[quarantine_id]["headers"].keys():
row["subject"] = emails[quarantine_id]["headers"]["subject"][:60]
else
row["subject"] = ""
rows.append(row) rows.append(row)
if metadata["recipients"]: if metadata["recipients"]:
@@ -124,9 +139,12 @@ def list_quarantine_emails(config, args):
print("\n".join(emails.keys())) print("\n".join(emails.keys()))
return return
if not emails: logger.info("quarantine '{}' is empty".format(args.quarantine)) if not emails:
logger.info("quarantine '{}' is empty".format(args.quarantine))
print_table( print_table(
[("Quarantine-ID", "quarantine_id"), ("Date", "date"), ("From", "mailfrom"), ("Recipient(s)", "recipient"), ("Subject", "subject")], [("Quarantine-ID", "quarantine_id"), ("Date", "date"),
("From", "mailfrom"), ("Recipient(s)", "recipient"),
("Subject", "subject")],
rows rows
) )
@@ -136,25 +154,34 @@ def list_whitelist(config, args):
# get whitelist object # get whitelist object
whitelist = _get_whitelist_obj(config, args.quarantine) whitelist = _get_whitelist_obj(config, args.quarantine)
if whitelist == None: if whitelist is None:
raise RuntimeError("whitelist type is set to None, unable to list entries") raise RuntimeError(
"whitelist type is set to None, unable to list entries")
# find whitelist entries # find whitelist entries
entries = whitelist.find(mailfrom=args.mailfrom, recipients=args.recipients, older_than=args.older_than) entries = whitelist.find(
mailfrom=args.mailfrom,
recipients=args.recipients,
older_than=args.older_than)
if not entries: if not entries:
logger.info("whitelist of quarantine '{}' is empty".format(args.quarantine)) logger.info(
"whitelist of quarantine '{}' is empty".format(
args.quarantine))
return return
# transform some values to strings # transform some values to strings
for entry_id, entry in entries.items(): for entry_id, entry in entries.items():
entries[entry_id]["permanent_str"] = str(entry["permanent"]) entries[entry_id]["permanent_str"] = str(entry["permanent"])
entries[entry_id]["created_str"] = entry["created"].strftime('%Y-%m-%d %H:%M:%S') entries[entry_id]["created_str"] = entry["created"].strftime(
entries[entry_id]["last_used_str"] = entry["last_used"].strftime('%Y-%m-%d %H:%M:%S') '%Y-%m-%d %H:%M:%S')
entries[entry_id]["last_used_str"] = entry["last_used"].strftime(
'%Y-%m-%d %H:%M:%S')
print_table( print_table(
[ [
("ID", "id"), ("From", "mailfrom"), ("To", "recipient"), ("Created", "created_str"), ("ID", "id"), ("From", "mailfrom"), ("To", "recipient"),
("Last used", "last_used_str"), ("Comment", "comment"), ("Permanent", "permanent_str") ("Created", "created_str"), ("Last used", "last_used_str"),
("Comment", "comment"), ("Permanent", "permanent_str")
], ],
entries.values() entries.values()
) )
@@ -165,32 +192,40 @@ def add_whitelist_entry(config, args):
# get whitelist object # get whitelist object
whitelist = _get_whitelist_obj(config, args.quarantine) whitelist = _get_whitelist_obj(config, args.quarantine)
if whitelist == None: if whitelist is None:
raise RuntimeError("whitelist type is set to None, unable to add entries") raise RuntimeError(
"whitelist type is set to None, unable to add entries")
# check existing entries # check existing entries
entries = whitelist.check(args.mailfrom, args.recipient) entries = whitelist.check(args.mailfrom, args.recipient)
if entries: if entries:
# check if the exact entry exists already # check if the exact entry exists already
for entry in entries.values(): for entry in entries.values():
if entry["mailfrom"] == args.mailfrom and entry["recipient"] == args.recipient: if entry["mailfrom"] == args.mailfrom and \
raise RuntimeError("an entry with this from/to combination already exists") entry["recipient"] == args.recipient:
raise RuntimeError(
"an entry with this from/to combination already exists")
if not args.force: if not args.force:
# the entry is already covered by others # the entry is already covered by others
for entry_id, entry in entries.items(): for entry_id, entry in entries.items():
entries[entry_id]["permanent_str"] = str(entry["permanent"]) entries[entry_id]["permanent_str"] = str(entry["permanent"])
entries[entry_id]["created_str"] = entry["created"].strftime('%Y-%m-%d %H:%M:%S') entries[entry_id]["created_str"] = entry["created"].strftime(
entries[entry_id]["last_used_str"] = entry["last_used"].strftime('%Y-%m-%d %H:%M:%S') '%Y-%m-%d %H:%M:%S')
entries[entry_id]["last_used_str"] = entry["last_used"].strftime(
'%Y-%m-%d %H:%M:%S')
print_table( print_table(
[ [
("ID", "id"), ("From", "mailfrom"), ("To", "recipient"), ("Created", "created_str"), ("ID", "id"), ("From", "mailfrom"), ("To", "recipient"),
("Last used", "last_used_str"), ("Comment", "comment"), ("Permanent", "permanent_str") ("Created", "created_str"), ("Last used", "last_used_str"),
("Comment", "comment"), ("Permanent", "permanent_str")
], ],
entries.values() entries.values()
) )
print("") print("")
raise RuntimeError("from/to combination is already covered by the entries above, use --force to override.") raise RuntimeError(
"from/to combination is already covered by the entries above, "
"use --force to override.")
# add entry to whitelist # add entry to whitelist
whitelist.add(args.mailfrom, args.recipient, args.comment, args.permanent) whitelist.add(args.mailfrom, args.recipient, args.comment, args.permanent)
@@ -201,8 +236,9 @@ def delete_whitelist_entry(config, args):
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
whitelist = _get_whitelist_obj(config, args.quarantine) whitelist = _get_whitelist_obj(config, args.quarantine)
if whitelist == None: if whitelist is None:
raise RuntimeError("whitelist type is set to None, unable to delete entries") raise RuntimeError(
"whitelist type is set to None, unable to delete entries")
whitelist.delete(args.whitelist_id) whitelist.delete(args.whitelist_id)
logger.info("whitelist entry deleted successfully") logger.info("whitelist entry deleted successfully")
@@ -212,8 +248,9 @@ def notify_email(config, args):
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
quarantine = _get_quarantine_obj(config, args.quarantine) quarantine = _get_quarantine_obj(config, args.quarantine)
if quarantine == None: if quarantine is None:
raise RuntimeError("quarantine type is set to None, unable to send notification") raise RuntimeError(
"quarantine type is set to None, unable to send notification")
quarantine.notify(args.quarantine_id, args.recipient) quarantine.notify(args.quarantine_id, args.recipient)
logger.info("sent notification successfully") logger.info("sent notification successfully")
@@ -222,8 +259,9 @@ def release_email(config, args):
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
quarantine = _get_quarantine_obj(config, args.quarantine) quarantine = _get_quarantine_obj(config, args.quarantine)
if quarantine == None: if quarantine is None:
raise RuntimeError("quarantine type is set to None, unable to release email") raise RuntimeError(
"quarantine type is set to None, unable to release email")
quarantine.release(args.quarantine_id, args.recipient) quarantine.release(args.quarantine_id, args.recipient)
logger.info("quarantined email released successfully") logger.info("quarantined email released successfully")
@@ -233,8 +271,9 @@ def delete_email(config, args):
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
quarantine = _get_quarantine_obj(config, args.quarantine) quarantine = _get_quarantine_obj(config, args.quarantine)
if quarantine == None: if quarantine is None:
raise RuntimeError("quarantine type is set to None, unable to delete email") raise RuntimeError(
"quarantine type is set to None, unable to delete email")
quarantine.delete(args.quarantine_id, args.recipient) quarantine.delete(args.quarantine_id, args.recipient)
logger.info("quarantined email deleted successfully") logger.info("quarantined email deleted successfully")
@@ -253,78 +292,238 @@ class StdOutFilter(logging.Filter):
def main(): def main():
"PyQuarantine command-line interface." "PyQuarantine command-line interface."
# parse command line # parse command line
formatter_class = lambda prog: argparse.HelpFormatter(prog, max_help_position=50, width=140) def formatter_class(prog): return argparse.HelpFormatter(
parser = argparse.ArgumentParser(description="PyQuarantine CLI", formatter_class=formatter_class) prog, max_help_position=50, width=140)
parser.add_argument("-c", "--config", help="Config files to read.", nargs="+", metavar="CFG", parser = argparse.ArgumentParser(
default=pyquarantine.QuarantineMilter.get_configfiles()) description="PyQuarantine CLI",
parser.add_argument("-d", "--debug", help="Log debugging messages.", action="store_true") formatter_class=formatter_class)
parser.add_argument(
"-c", "--config",
help="Config files to read.",
nargs="+", metavar="CFG",
default=pyquarantine.QuarantineMilter.get_configfiles())
parser.add_argument(
"-d", "--debug",
help="Log debugging messages.",
action="store_true")
parser.add_argument(
"-v", "--version",
help="Print version.",
action="version",
version="%(prog)s ({})".format(version))
parser.set_defaults(syslog=False) parser.set_defaults(syslog=False)
subparsers = parser.add_subparsers(dest="command", title="Commands") subparsers = parser.add_subparsers(
dest="command",
title="Commands")
subparsers.required = True subparsers.required = True
# list command # list command
list_parser = subparsers.add_parser("list", help="List available quarantines.", formatter_class=formatter_class) list_parser = subparsers.add_parser(
list_parser.add_argument("-b", "--batch", help="Print results using only quarantine names, each on a new line.", action="store_true") "list",
help="List available quarantines.",
formatter_class=formatter_class)
list_parser.add_argument(
"-b", "--batch",
help="Print results using only quarantine names, each on a new line.",
action="store_true")
list_parser.set_defaults(func=list_quarantines) list_parser.set_defaults(func=list_quarantines)
# quarantine command group # quarantine command group
quarantine_parser = subparsers.add_parser("quarantine", description="Manage quarantines.", help="Manage quarantines.", formatter_class=formatter_class) quarantine_parser = subparsers.add_parser(
quarantine_parser.add_argument("quarantine", metavar="QUARANTINE", help="Quarantine name.") "quarantine",
quarantine_subparsers = quarantine_parser.add_subparsers(dest="command", title="Quarantine commands") description="Manage quarantines.",
help="Manage quarantines.",
formatter_class=formatter_class)
quarantine_parser.add_argument(
"quarantine",
metavar="QUARANTINE",
help="Quarantine name.")
quarantine_subparsers = quarantine_parser.add_subparsers(
dest="command",
title="Quarantine commands")
quarantine_subparsers.required = True quarantine_subparsers.required = True
# quarantine list command # quarantine list command
quarantine_list_parser = quarantine_subparsers.add_parser("list", description="List emails in quarantines.", help="List emails in quarantine.", formatter_class=formatter_class) quarantine_list_parser = quarantine_subparsers.add_parser(
quarantine_list_parser.add_argument("-f", "--from", dest="mailfrom", help="Filter emails by from address.", default=None, nargs="+") "list",
quarantine_list_parser.add_argument("-t", "--to", dest="recipients", help="Filter emails by recipient address.", default=None, nargs="+") description="List emails in quarantines.",
quarantine_list_parser.add_argument("-o", "--older-than", dest="older_than", help="Filter emails by age (days).", default=None, type=float) help="List emails in quarantine.",
quarantine_list_parser.add_argument("-b", "--batch", help="Print results using only email quarantine IDs, each on a new line.", action="store_true") formatter_class=formatter_class)
quarantine_list_parser.add_argument(
"-f", "--from",
dest="mailfrom",
help="Filter emails by from address.",
default=None,
nargs="+")
quarantine_list_parser.add_argument(
"-t", "--to",
dest="recipients",
help="Filter emails by recipient address.",
default=None,
nargs="+")
quarantine_list_parser.add_argument(
"-o", "--older-than",
dest="older_than",
help="Filter emails by age (days).",
default=None,
type=float)
quarantine_list_parser.add_argument(
"-b", "--batch",
help="Print results using only email quarantine IDs, each on a new line.",
action="store_true")
quarantine_list_parser.set_defaults(func=list_quarantine_emails) quarantine_list_parser.set_defaults(func=list_quarantine_emails)
# quarantine notify command # quarantine notify command
quarantine_notify_parser = quarantine_subparsers.add_parser("notify", description="Notify recipient about email in quarantine.", help="Notify recipient about email in quarantine.", formatter_class=formatter_class) quarantine_notify_parser = quarantine_subparsers.add_parser(
quarantine_notify_parser.add_argument("quarantine_id", metavar="ID", help="Quarantine ID.") "notify",
quarantine_notify_parser_group = quarantine_notify_parser.add_mutually_exclusive_group(required=True) description="Notify recipient about email in quarantine.",
quarantine_notify_parser_group.add_argument("-t", "--to", dest="recipient", help="Release email for one recipient address.") help="Notify recipient about email in quarantine.",
quarantine_notify_parser_group.add_argument("-a", "--all", help="Release email for all recipients.", action="store_true") formatter_class=formatter_class)
quarantine_notify_parser.add_argument(
"quarantine_id",
metavar="ID",
help="Quarantine ID.")
quarantine_notify_parser_group = quarantine_notify_parser.add_mutually_exclusive_group(
required=True)
quarantine_notify_parser_group.add_argument(
"-t", "--to",
dest="recipient",
help="Release email for one recipient address.")
quarantine_notify_parser_group.add_argument(
"-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_email)
# quarantine release command # quarantine release command
quarantine_release_parser = quarantine_subparsers.add_parser("release", description="Release email from quarantine.", help="Release email from quarantine.", formatter_class=formatter_class) quarantine_release_parser = quarantine_subparsers.add_parser(
quarantine_release_parser.add_argument("quarantine_id", metavar="ID", help="Quarantine ID.") "release",
quarantine_release_parser.add_argument("-n", "--disable-syslog", dest="syslog", help="Disable syslog messages.", action="store_false") description="Release email from quarantine.",
quarantine_release_parser_group = quarantine_release_parser.add_mutually_exclusive_group(required=True) help="Release email from quarantine.",
quarantine_release_parser_group.add_argument("-t", "--to", dest="recipient", help="Release email for one recipient address.") formatter_class=formatter_class)
quarantine_release_parser_group.add_argument("-a", "--all", help="Release email for all recipients.", action="store_true") quarantine_release_parser.add_argument(
"quarantine_id",
metavar="ID",
help="Quarantine ID.")
quarantine_release_parser.add_argument(
"-n",
"--disable-syslog",
dest="syslog",
help="Disable syslog messages.",
action="store_false")
quarantine_release_parser_group = quarantine_release_parser.add_mutually_exclusive_group(
required=True)
quarantine_release_parser_group.add_argument(
"-t", "--to",
dest="recipient",
help="Release email for one recipient address.")
quarantine_release_parser_group.add_argument(
"-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_email)
# quarantine delete command # quarantine delete command
quarantine_delete_parser = quarantine_subparsers.add_parser("delete", description="Delete email from quarantine.", help="Delete email from quarantine.", formatter_class=formatter_class) quarantine_delete_parser = quarantine_subparsers.add_parser(
quarantine_delete_parser.add_argument("quarantine_id", metavar="ID", help="Quarantine ID.") "delete",
quarantine_delete_parser.add_argument("-n", "--disable-syslog", dest="syslog", help="Disable syslog messages.", action="store_false") description="Delete email from quarantine.",
quarantine_delete_parser_group = quarantine_delete_parser.add_mutually_exclusive_group(required=True) help="Delete email from quarantine.",
quarantine_delete_parser_group.add_argument("-t", "--to", dest="recipient", help="Delete email for one recipient address.") formatter_class=formatter_class)
quarantine_delete_parser_group.add_argument("-a", "--all", help="Delete email for all recipients.", action="store_true") quarantine_delete_parser.add_argument(
"quarantine_id",
metavar="ID",
help="Quarantine ID.")
quarantine_delete_parser.add_argument(
"-n", "--disable-syslog",
dest="syslog",
help="Disable syslog messages.",
action="store_false")
quarantine_delete_parser_group = quarantine_delete_parser.add_mutually_exclusive_group(
required=True)
quarantine_delete_parser_group.add_argument(
"-t", "--to",
dest="recipient",
help="Delete email for one recipient address.")
quarantine_delete_parser_group.add_argument(
"-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_email)
# whitelist command group # whitelist command group
whitelist_parser = subparsers.add_parser("whitelist", description="Manage whitelists.", help="Manage whitelists.", formatter_class=formatter_class) whitelist_parser = subparsers.add_parser(
whitelist_parser.add_argument("quarantine", metavar="QUARANTINE", help="Quarantine name.") "whitelist",
whitelist_subparsers = whitelist_parser.add_subparsers(dest="command", title="Whitelist commands") description="Manage whitelists.",
help="Manage whitelists.",
formatter_class=formatter_class)
whitelist_parser.add_argument(
"quarantine",
metavar="QUARANTINE",
help="Quarantine name.")
whitelist_subparsers = whitelist_parser.add_subparsers(
dest="command",
title="Whitelist commands")
whitelist_subparsers.required = True whitelist_subparsers.required = True
# whitelist list command # whitelist list command
whitelist_list_parser = whitelist_subparsers.add_parser("list", description="List whitelist entries.", help="List whitelist entries.", formatter_class=formatter_class) whitelist_list_parser = whitelist_subparsers.add_parser(
whitelist_list_parser.add_argument("-f", "--from", dest="mailfrom", help="Filter entries by from address.", default=None, nargs="+") "list",
whitelist_list_parser.add_argument("-t", "--to", dest="recipients", help="Filter entries by recipient address.", default=None, nargs="+") description="List whitelist entries.",
whitelist_list_parser.add_argument("-o", "--older-than", dest="older_than", help="Filter emails by last used date (days).", default=None, type=float) help="List whitelist entries.",
formatter_class=formatter_class)
whitelist_list_parser.add_argument(
"-f", "--from",
dest="mailfrom",
help="Filter entries by from address.",
default=None,
nargs="+")
whitelist_list_parser.add_argument(
"-t", "--to",
dest="recipients",
help="Filter entries by recipient address.",
default=None,
nargs="+")
whitelist_list_parser.add_argument(
"-o", "--older-than",
dest="older_than",
help="Filter emails by last used date (days).",
default=None,
type=float)
whitelist_list_parser.set_defaults(func=list_whitelist) whitelist_list_parser.set_defaults(func=list_whitelist)
# whitelist add command # whitelist add command
whitelist_add_parser = whitelist_subparsers.add_parser("add", description="Add whitelist entry.", help="Add whitelist entry.", formatter_class=formatter_class) whitelist_add_parser = whitelist_subparsers.add_parser(
whitelist_add_parser.add_argument("-f", "--from", dest="mailfrom", help="From address.", required=True) "add",
whitelist_add_parser.add_argument("-t", "--to", dest="recipient", help="Recipient address.", required=True) description="Add whitelist entry.",
whitelist_add_parser.add_argument("-c", "--comment", help="Comment.", default="added by CLI") help="Add whitelist entry.",
whitelist_add_parser.add_argument("-p", "--permanent", help="Add a permanent entry.", action="store_true") formatter_class=formatter_class)
whitelist_add_parser.add_argument("--force", help="Force adding an entry, even if already covered by another entry.", action="store_true") whitelist_add_parser.add_argument(
"-f", "--from",
dest="mailfrom",
help="From address.",
required=True)
whitelist_add_parser.add_argument(
"-t", "--to",
dest="recipient",
help="Recipient address.",
required=True)
whitelist_add_parser.add_argument(
"-c", "--comment",
help="Comment.",
default="added by CLI")
whitelist_add_parser.add_argument(
"-p", "--permanent",
help="Add a permanent entry.",
action="store_true")
whitelist_add_parser.add_argument(
"--force",
help="Force adding an entry, even if already covered by another entry.",
action="store_true")
whitelist_add_parser.set_defaults(func=add_whitelist_entry) whitelist_add_parser.set_defaults(func=add_whitelist_entry)
# whitelist delete command # whitelist delete command
whitelist_delete_parser = whitelist_subparsers.add_parser("delete", description="Delete whitelist entry.", help="Delete whitelist entry.", formatter_class=formatter_class) whitelist_delete_parser = whitelist_subparsers.add_parser(
whitelist_delete_parser.add_argument("whitelist_id", metavar="ID", help="Whitelist ID.") "delete",
description="Delete whitelist entry.",
help="Delete whitelist entry.",
formatter_class=formatter_class)
whitelist_delete_parser.add_argument(
"whitelist_id",
metavar="ID",
help="Whitelist ID.")
whitelist_delete_parser.set_defaults(func=delete_whitelist_entry) whitelist_delete_parser.set_defaults(func=delete_whitelist_entry)
args = parser.parse_args() args = parser.parse_args()
@@ -336,7 +535,8 @@ def main():
# setup console log # setup console log
if args.debug: if args.debug:
formatter = logging.Formatter("%(levelname)s: [%(name)s] - %(message)s") formatter = logging.Formatter(
"%(levelname)s: [%(name)s] - %(message)s")
else: else:
formatter = logging.Formatter("%(levelname)s: %(message)s") formatter = logging.Formatter("%(levelname)s: %(message)s")
# stdout # stdout
@@ -355,17 +555,21 @@ def main():
# try to generate milter configs # try to generate milter configs
try: try:
global_config, config = pyquarantine.generate_milter_config(config_files=args.config, configtest=True) global_config, config = pyquarantine.generate_milter_config(
config_files=args.config, configtest=True)
except RuntimeError as e: except RuntimeError as e:
logger.error(e) logger.error(e)
sys.exit(255) sys.exit(255)
if args.syslog: if args.syslog:
# setup syslog # setup syslog
sysloghandler = logging.handlers.SysLogHandler(address="/dev/log", facility=logging.handlers.SysLogHandler.LOG_MAIL) sysloghandler = logging.handlers.SysLogHandler(
address="/dev/log",
facility=logging.handlers.SysLogHandler.LOG_MAIL)
sysloghandler.setLevel(loglevel) sysloghandler.setLevel(loglevel)
if args.debug: if args.debug:
formatter = logging.Formatter("pyquarantine: [%(name)s] [%(levelname)s] %(message)s") formatter = logging.Formatter(
"pyquarantine: [%(name)s] [%(levelname)s] %(message)s")
else: else:
formatter = logging.Formatter("pyquarantine: %(message)s") formatter = logging.Formatter("pyquarantine: %(message)s")
sysloghandler.setFormatter(formatter) sysloghandler.setFormatter(formatter)

View File

@@ -26,6 +26,10 @@ process = None
def smtp_send(smtp_host, smtp_port, mailfrom, recipient, mail): def smtp_send(smtp_host, smtp_port, mailfrom, recipient, mail):
s = smtplib.SMTP(host=smtp_host, port=smtp_port) s = smtplib.SMTP(host=smtp_host, port=smtp_port)
s.ehlo()
if s.has_extn("STARTTLS"):
s.starttls()
s.ehlo()
s.sendmail(mailfrom, [recipient], mail) s.sendmail(mailfrom, [recipient], mail)
s.quit() s.quit()
@@ -38,31 +42,37 @@ def mailprocess():
try: try:
while True: while True:
m = queue.get() m = queue.get()
if not m: break if not m:
break
smtp_host, smtp_port, queueid, mailfrom, recipient, mail, emailtype = m smtp_host, smtp_port, queueid, mailfrom, recipient, mail, emailtype = m
try: try:
smtp_send(smtp_host, smtp_port, mailfrom, recipient, mail) smtp_send(smtp_host, smtp_port, mailfrom, recipient, mail)
except Exception as e: except Exception as e:
logger.error("{}: error while sending {} to '{}': {}".format(queueid, emailtype, recipient, e)) logger.error(
"{}: error while sending {} to '{}': {}".format(
queueid, emailtype, recipient, e))
else: else:
logger.info("{}: successfully sent {} to: {}".format(queueid, emailtype, recipient)) logger.info(
"{}: successfully sent {} to: {}".format(
queueid, emailtype, recipient))
except KeyboardInterrupt: except KeyboardInterrupt:
pass pass
logger.debug("mailer process terminated") logger.debug("mailer process terminated")
def sendmail(smtp_host, smtp_port, queueid, mailfrom, recipients, mail, emailtype="email"): def sendmail(smtp_host, smtp_port, queueid, mailfrom, recipients, mail,
emailtype="email"):
"Send an email." "Send an email."
global logger global logger
global process global process
global queue global queue
if type(recipients) == str: if isinstance(recipients, str):
recipients = [recipients] recipients = [recipients]
# start mailprocess if it is not started yet # start mailprocess if it is not started yet
if process == None: if process is None:
process = Process(target=mailprocess) process = Process(target=mailprocess)
process.daemon = True process.daemon = True
logger.debug("starting mailer process") logger.debug("starting mailer process")
@@ -70,6 +80,9 @@ def sendmail(smtp_host, smtp_port, queueid, mailfrom, recipients, mail, emailtyp
for recipient in recipients: for recipient in recipients:
try: try:
queue.put((smtp_host, smtp_port, queueid, mailfrom, recipient, mail, emailtype), timeout=30) queue.put(
(smtp_host, smtp_port, queueid, mailfrom, recipient, mail,
emailtype),
timeout=30)
except Queue.Full as e: except Queue.Full as e:
raise RuntimeError("email queue is full") raise RuntimeError("email queue is full")

View File

@@ -26,15 +26,18 @@ from os.path import basename
from pyquarantine import mailer from pyquarantine import mailer
class BaseNotification(object): class BaseNotification(object):
"Notification base class" "Notification base class"
def __init__(self, global_config, config, configtest=False): def __init__(self, global_config, config, configtest=False):
self.quarantine_name = config["name"] self.quarantine_name = config["name"]
self.global_config = global_config self.global_config = global_config
self.config = config self.config = config
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
def notify(self, queueid, quarantine_id, mailfrom, recipients, headers, fp, subgroups=None, named_subgroups=None, synchronous=False): def notify(self, queueid, quarantine_id, mailfrom, recipients, headers,
fp, subgroups=None, named_subgroups=None, synchronous=False):
fp.seek(0) fp.seek(0)
pass pass
@@ -44,77 +47,103 @@ class EMailNotification(BaseNotification):
_html_text = "text/html" _html_text = "text/html"
_plain_text = "text/plain" _plain_text = "text/plain"
_bad_tags = [ _bad_tags = [
"applet", "applet",
"embed", "embed",
"frame", "frame",
"frameset", "frameset",
"head", "head",
"iframe", "iframe",
"script" "script"
] ]
_good_tags = [ _good_tags = [
"a", "a",
"b", "b",
"br", "br",
"center", "center",
"div", "div",
"font", "font",
"h1", "h1",
"h2", "h2",
"h3", "h3",
"h4", "h4",
"h5", "h5",
"h6", "h6",
"i", "i",
"img", "img",
"li", "li",
"span", "p",
"table", "pre",
"td", "span",
"th", "table",
"tr", "td",
"tt", "th",
"u", "tr",
"ul" "tt",
"u",
"ul"
] ]
good_attributes = [ _good_attributes = [
"align", "align",
"alt", "alt",
"bgcolor", "bgcolor",
"border", "border",
"cellpadding", "cellpadding",
"cellspacing", "cellspacing",
"color", "class",
"colspan", "color",
"dir", "colspan",
"face", "dir",
"headers", "face",
"height", "headers",
"id", "height",
"name", "id",
"rowspan", "name",
"size", "rowspan",
"src", "size",
"style", "src",
"title", "style",
"type", "title",
"valign", "type",
"value", "valign",
"width" "value",
"width"
] ]
def __init__(self, global_config, config, configtest=False): def __init__(self, global_config, config, configtest=False):
super(EMailNotification, self).__init__(global_config, config, configtest) super(EMailNotification, self).__init__(
global_config, config, configtest)
# check if mandatory options are present in config # check if mandatory options are present in config
for option in ["smtp_host", "smtp_port", "notification_email_envelope_from", "notification_email_from", "notification_email_subject", "notification_email_template", "notification_email_replacement_img", "notification_email_embedded_imgs"]: for option 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(): if option not in self.config.keys() and option in self.global_config.keys():
self.config[option] = self.global_config[option] self.config[option] = self.global_config[option]
if option not in self.config.keys(): if option not in self.config.keys():
raise RuntimeError("mandatory option '{}' not present in config section '{}' or 'global'".format(option, self.quarantine_name)) raise RuntimeError(
"mandatory option '{}' not present in config section '{}' or 'global'".format(
option, self.quarantine_name))
self.smtp_host = self.config["smtp_host"] # check if optional config options are present in config
self.smtp_port = self.config["smtp_port"] 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.mailfrom = self.config["notification_email_envelope_from"]
self.from_header = self.config["notification_email_from"] self.from_header = self.config["notification_email_from"]
self.subject = self.config["notification_email_subject"] self.subject = self.config["notification_email_subject"]
@@ -125,37 +154,56 @@ class EMailNotification(BaseNotification):
try: try:
self.from_header.format_map(testvars) self.from_header.format_map(testvars)
except ValueError as e: except ValueError as e:
raise RuntimeError("error parsing notification_email_from: {}".format(e)) raise RuntimeError(
"error parsing notification_email_from: {}".format(e))
# test-parse subject # test-parse subject
try: try:
self.subject.format_map(testvars) self.subject.format_map(testvars)
except ValueError as e: except ValueError as e:
raise RuntimeError("error parsing notification_email_subject: {}".format(e)) raise RuntimeError(
"error parsing notification_email_subject: {}".format(e))
# read and parse email notification template # read and parse email notification template
try: try:
self.template = open(self.config["notification_email_template"], "r").read() self.template = open(
self.config["notification_email_template"], "r").read()
self.template.format_map(testvars) self.template.format_map(testvars)
except IOError as e: except IOError as e:
raise RuntimeError("error reading template: {}".format(e)) raise RuntimeError("error reading template: {}".format(e))
except ValueError as e: except ValueError as e:
raise RuntimeError("error parsing template: {}".format(e)) raise RuntimeError("error parsing template: {}".format(e))
strip_images = self.config["notification_email_strip_images"].strip().upper()
if strip_images in ["TRUE", "ON", "YES"]:
self.strip_images = True
elif strip_images in ["FALSE", "OFF", "NO"]:
self.strip_images = False
else:
raise RuntimeError("error parsing notification_email_strip_images: unknown value")
self.parser_lib = self.config["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 # read email replacement image if specified
replacement_img_path = self.config["notification_email_replacement_img"].strip() replacement_img = self.config["notification_email_replacement_img"].strip()
if replacement_img_path: if not self.strip_images and replacement_img:
try: try:
self.replacement_img = MIMEImage(open(replacement_img_path, "rb").read()) self.replacement_img = MIMEImage(
open(replacement_img, "rb").read())
except IOError as e: except IOError as e:
raise RuntimeError("error reading replacement image: {}".format(e)) raise RuntimeError(
"error reading replacement image: {}".format(e))
else: else:
self.replacement_img.add_header("Content-ID", "<removed_for_security_reasons>") self.replacement_img.add_header(
"Content-ID", "<removed_for_security_reasons>")
else: else:
self.replacement_img = None self.replacement_img = None
# read images to embed if specified # read images to embed if specified
embedded_img_paths = [ p.strip() for p in self.config["notification_email_embedded_imgs"].split(",") if p] embedded_img_paths = [
p.strip() for p in self.config["notification_email_embedded_imgs"].split(",") if p]
self.embedded_imgs = [] self.embedded_imgs = []
for img_path in embedded_img_paths: for img_path in embedded_img_paths:
# read image # read image
@@ -167,36 +215,47 @@ class EMailNotification(BaseNotification):
img.add_header("Content-ID", "<{}>".format(basename(img_path))) img.add_header("Content-ID", "<{}>".format(basename(img_path)))
self.embedded_imgs.append(img) self.embedded_imgs.append(img)
def get_decoded_email_body(self, queueid, msg, preferred=_html_text):
"Find and decode email body."
# try to find the body part
self.logger.debug("{}: trying to find email body".format(queueid))
body = None
for part in msg.walk():
content_type = part.get_content_type()
if content_type in [EMailNotification._plain_text,
EMailNotification._html_text]:
body = part
if content_type == preferred:
break
def get_text(self, queueid, part): if body is not None:
"Get the mail text in html form from email part." # get the character set, fallback to utf-8 if not defined in header
mimetype = part.get_content_type() charset = body.get_content_charset()
if charset is None:
charset = "utf-8"
self.logger.debug("{}: extracting content of email text part".format(queueid)) # decode content
text = part.get_payload(decode=True) content = body.get_payload(decode=True).decode(
encoding=charset, errors="replace")
if mimetype == EMailNotification._plain_text: content_type = body.get_content_type()
self.logger.debug("{}: content mimetype is {}, converting to {}".format(queueid, mimetype, self._html_text)) if content_type == EMailNotification._plain_text:
text = re.sub(r"^(.*)$", r"\1<br/>\n", text.decode(), flags=re.MULTILINE) # convert text/plain to text/html
self.logger.debug(
"{}: content type is {}, converting to {}".format(
queueid, content_type, EMailNotification._html_text))
content = re.sub(r"^(.*)$", r"\1<br/>",
escape(content), flags=re.MULTILINE)
else:
self.logger.debug(
"{}: content type is {}".format(
queueid, content_type))
else: else:
self.logger.debug("{}: content mimetype is {}".format(queueid, mimetype)) self.logger.error(
"{}: unable to find email body".format(queueid))
content = "ERROR: unable to find email body"
return BeautifulSoup(text, "lxml") return content
def get_text_multipart(self, queueid, msg, preferred=_html_text):
"Get the mail text of a multipart email in html form."
soup = None
for part in msg.get_payload():
mimetype = part.get_content_type()
if mimetype in [EMailNotification._plain_text, EMailNotification._html_text]:
soup = self.get_text(queueid, part)
elif mimetype.startswith("multipart"):
soup = self.get_text_multipart(queueid, part, preferred)
if soup != None and mimetype == preferred:
break
return soup
def sanitize(self, queueid, soup): def sanitize(self, queueid, soup):
"Sanitize mail html text." "Sanitize mail html text."
@@ -204,59 +263,83 @@ class EMailNotification(BaseNotification):
# completly remove bad elements # completly remove bad elements
for element in soup(EMailNotification._bad_tags): for element in soup(EMailNotification._bad_tags):
self.logger.debug("{}: removing dangerous tag '{}' and its content".format(queueid, element.name)) self.logger.debug(
"{}: removing dangerous tag '{}' and its content".format(
queueid, element.name))
element.extract() element.extract()
# remove not whitelisted elements, but keep their content # remove not whitelisted elements, but keep their content
for element in soup.find_all(True): for element in soup.find_all(True):
if element.name not in EMailNotification._good_tags: if element.name not in EMailNotification._good_tags:
self.logger.debug("{}: removing tag '{}', keep its content".format(queueid, element.name)) self.logger.debug(
"{}: removing tag '{}', keep its content".format(
queueid, element.name))
element.replaceWithChildren() element.replaceWithChildren()
# remove not whitelisted attributes # remove not whitelisted attributes
for element in soup.find_all(True): for element in soup.find_all(True):
for attribute in element.attrs.keys(): for attribute in list(element.attrs.keys()):
if attribute not in EMailNotification.good_attributes: if attribute not in EMailNotification._good_attributes:
if element.name == "a" and attribute == "href": if element.name == "a" and attribute == "href":
self.logger.debug("{}: setting attribute href to '#' on tag '{}'".format(queueid, element.name)) self.logger.debug(
"{}: setting attribute href to '#' on tag '{}'".format(
queueid, element.name))
element["href"] = "#" element["href"] = "#"
else: else:
self.logger.debug("{}: removing attribute '{}' from tag '{}'".format(queueid, attribute, element.name)) self.logger.debug(
"{}: removing attribute '{}' from tag '{}'".format(
queueid, attribute, element.name))
del(element.attrs[attribute]) del(element.attrs[attribute])
return soup return soup
def get_html_text_part(self, queueid, msg): def notify(self, queueid, quarantine_id, mailfrom, recipients, headers, fp,
"Get the mail text of an email in html form." subgroups=None, named_subgroups=None, synchronous=False):
soup = None
mimetype = msg.get_content_type()
self.logger.debug("{}: trying to find text part of email".format(queueid))
if mimetype in [EMailNotification._plain_text, EMailNotification._html_text]:
soup = self.get_text(queueid, msg)
elif mimetype.startswith("multipart"):
soup = self.get_text_multipart(queueid, msg)
if soup == None:
self.logger.error("{}: unable to extract text part of email".format(queueid))
text = "ERROR: unable to extract text from email body"
soup = BeautifulSoup(text, "lxml", "UTF-8")
return soup
def notify(self, queueid, quarantine_id, mailfrom, recipients, headers, fp, subgroups=None, named_subgroups=None, synchronous=False):
"Notify recipients via email." "Notify recipients via email."
super(EMailNotification, self).notify(queueid, quarantine_id, mailfrom, recipients, headers, fp, subgroups, named_subgroups, synchronous) super(
EMailNotification,
self).notify(
queueid,
quarantine_id,
mailfrom,
recipients,
headers,
fp,
subgroups,
named_subgroups,
synchronous)
# extract html text from email # extract body from email
self.logger.debug("{}: extraction email text from original email".format(queueid)) content = self.get_decoded_email_body(
soup = self.get_html_text_part(queueid, email.message_from_binary_file(fp)) queueid, email.message_from_binary_file(fp))
# create BeautifulSoup object
self.logger.debug(
"{}: trying to create BeatufilSoup object with parser lib {}, "
"text length is {} bytes".format(
queueid, self.parser_lib, len(content)))
soup = BeautifulSoup(content, self.parser_lib)
self.logger.debug(
"{}: sucessfully created BeautifulSoup object".format(queueid))
# replace picture sources # replace picture sources
image_replaced = False image_replaced = False
if self.replacement_img: if self.strip_images:
self.logger.debug(
"{}: looking for images to strip".format(queueid))
for element in soup("img"): for element in soup("img"):
if "src" in element.attrs.keys(): if "src" in element.attrs.keys():
self.logger.debug("{}: replacing image: {}".format(queueid, element["src"])) self.logger.debug(
"{}: strip image: {}".format(
queueid, element["src"]))
element.extract()
elif self.replacement_img:
self.logger.debug(
"{}: looking for images to replace".format(queueid))
for element in soup("img"):
if "src" in element.attrs.keys():
self.logger.debug(
"{}: replacing image: {}".format(
queueid, element["src"]))
element["src"] = "cid:removed_for_security_reasons" element["src"] = "cid:removed_for_security_reasons"
image_replaced = True image_replaced = True
@@ -266,52 +349,70 @@ class EMailNotification(BaseNotification):
# sending email notifications # sending email notifications
for recipient in recipients: for recipient in recipients:
self.logger.debug("{}: generating notification email for '{}'".format(queueid, recipient)) self.logger.debug(
"{}: generating notification email for '{}'".format(
queueid, recipient))
self.logger.debug("{}: parsing email template".format(queueid)) self.logger.debug("{}: parsing email template".format(queueid))
if "from" not in headers.keys():
headers["from"] = ""
if "to" not in headers.keys():
headers["to"] = ""
if "subject" not in headers.keys():
headers["subject"] = ""
# generate dict containing all template variables # generate dict containing all template variables
variables = defaultdict(str, variables = defaultdict(str,
EMAIL_HTML_TEXT=sanitized_text, EMAIL_HTML_TEXT=sanitized_text,
EMAIL_FROM=escape(headers["from"]), EMAIL_FROM=escape(headers["from"]),
EMAIL_ENVELOPE_FROM=escape(mailfrom), EMAIL_ENVELOPE_FROM=escape(mailfrom),
EMAIL_TO=escape(recipient), EMAIL_TO=escape(recipient),
EMAIL_SUBJECT=escape(headers["subject"]), EMAIL_SUBJECT=escape(headers["subject"]),
EMAIL_QUARANTINE_ID=quarantine_id) EMAIL_QUARANTINE_ID=quarantine_id)
if subgroups: if subgroups:
number = 0 number = 0
for subgroup in subgroups: for subgroup in subgroups:
variables["SUBGROUP_{}".format(number)] = escape(subgroup) variables["SUBGROUP_{}".format(number)] = escape(subgroup)
if named_subgroups: if named_subgroups:
for key, value in named_subgroups.items(): named_subgroups[key] = escape(value) for key, value in named_subgroups.items():
named_subgroups[key] = escape(value)
variables.update(named_subgroups) variables.update(named_subgroups)
# parse template # parse template
htmltext = self.template.format_map(variables) htmltext = self.template.format_map(variables)
msg = MIMEMultipart('related') msg = MIMEMultipart('related')
msg["Subject"] = self.subject.format_map(variables) msg["From"] = self.from_header.format_map(
msg["From"] = "<{}>".format(self.from_header.format_map(variables)) defaultdict(str, EMAIL_FROM=headers["from"]))
msg["To"] = "<{}>".format(recipient) msg["To"] = headers["to"]
msg["Subject"] = self.subject.format_map(
defaultdict(str, EMAIL_SUBJECT=headers["subject"]))
msg["Date"] = email.utils.formatdate() msg["Date"] = email.utils.formatdate()
msg.attach(MIMEText(htmltext, "html", 'UTF-8')) msg.attach(MIMEText(htmltext, "html", 'UTF-8'))
if image_replaced: if image_replaced:
self.logger.debug("{}: attaching notification_replacement_img".format(queueid)) self.logger.debug(
"{}: attaching notification_replacement_img".format(queueid))
msg.attach(self.replacement_img) msg.attach(self.replacement_img)
for img in self.embedded_imgs: for img in self.embedded_imgs:
self.logger.debug("{}: attaching imgage".format(queueid)) self.logger.debug("{}: attaching imgage".format(queueid))
msg.attach(img) msg.attach(img)
self.logger.debug("{}: sending notification email to: {}".format(queueid, recipient)) self.logger.debug(
"{}: sending notification email to: {}".format(
queueid, recipient))
if synchronous: if synchronous:
try: try:
mailer.smtp_send(self.smtp_host, self.smtp_port, self.mailfrom, recipient, msg.as_string()) mailer.smtp_send(self.smtp_host, self.smtp_port,
self.mailfrom, recipient, msg.as_string())
except Exception as e: except Exception as e:
raise RuntimeError("error while sending email to '{}': {}".format(recipient, e)) raise RuntimeError(
"error while sending email to '{}': {}".format(
recipient, e))
else: else:
mailer.sendmail(self.smtp_host, self.smtp_port, queueid, self.mailfrom, recipient, msg.as_string(), "notification email") mailer.sendmail(self.smtp_host, self.smtp_port, queueid,
self.mailfrom, recipient, msg.as_string(),
"notification email")
# list of notification types and their related notification classes # list of notification types and their related notification classes

View File

@@ -27,13 +27,15 @@ from pyquarantine import mailer
class BaseQuarantine(object): class BaseQuarantine(object):
"Quarantine base class" "Quarantine base class"
def __init__(self, global_config, config, configtest=False): def __init__(self, global_config, config, configtest=False):
self.name = config["name"] self.name = config["name"]
self.global_config = global_config self.global_config = global_config
self.config = config self.config = config
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
def add(self, queueid, mailfrom, recipients, headers, fp, subgroups=None, named_subgroups=None): def add(self, queueid, mailfrom, recipients, headers,
fp, subgroups=None, named_subgroups=None):
"Add email to quarantine." "Add email to quarantine."
fp.seek(0) fp.seek(0)
return "" return ""
@@ -53,7 +55,8 @@ class BaseQuarantine(object):
def notify(self, quarantine_id, recipient=None): def notify(self, quarantine_id, recipient=None):
"Notify recipient about email in quarantine." "Notify recipient about email in quarantine."
if not self.config["notification_obj"]: if not self.config["notification_obj"]:
raise RuntimeError("notification type is set to None, unable to send notifications") raise RuntimeError(
"notification type is set to None, unable to send notifications")
return return
def release(self, quarantine_id, recipient=None): def release(self, quarantine_id, recipient=None):
@@ -63,6 +66,7 @@ class BaseQuarantine(object):
class FileQuarantine(BaseQuarantine): class FileQuarantine(BaseQuarantine):
"Quarantine class to store mails on filesystem." "Quarantine class to store mails on filesystem."
def __init__(self, global_config, config, configtest=False): def __init__(self, global_config, config, configtest=False):
super(FileQuarantine, self).__init__(global_config, config, configtest) super(FileQuarantine, self).__init__(global_config, config, configtest)
@@ -71,12 +75,17 @@ class FileQuarantine(BaseQuarantine):
if option not in self.config.keys() and option in self.global_config.keys(): if option not in self.config.keys() and option in self.global_config.keys():
self.config[option] = self.global_config[option] self.config[option] = self.global_config[option]
if option not in self.config.keys(): if option not in self.config.keys():
raise RuntimeError("mandatory option '{}' not present in config section '{}' or 'global'".format(option, self.name)) raise RuntimeError(
"mandatory option '{}' not present in config section '{}' or 'global'".format(
option, self.name))
self.directory = self.config["quarantine_directory"] self.directory = self.config["quarantine_directory"]
# check if quarantine directory exists and is writable # check if quarantine directory exists and is writable
if not os.path.isdir(self.directory) or not os.access(self.directory, os.W_OK): if not os.path.isdir(self.directory) or not os.access(
raise RuntimeError("file quarantine directory '{}' does not exist or is not writable".format(self.directory)) self.directory, os.W_OK):
raise RuntimeError(
"file quarantine directory '{}' does not exist or is not writable".format(
self.directory))
self._metadata_suffix = ".metadata" self._metadata_suffix = ".metadata"
def _save_datafile(self, quarantine_id, fp): def _save_datafile(self, quarantine_id, fp):
@@ -88,7 +97,9 @@ class FileQuarantine(BaseQuarantine):
raise RuntimeError("unable save data file: {}".format(e)) raise RuntimeError("unable save data file: {}".format(e))
def _save_metafile(self, quarantine_id, metadata): def _save_metafile(self, quarantine_id, metadata):
metafile = os.path.join(self.directory, "{}{}".format(quarantine_id, self._metadata_suffix)) metafile = os.path.join(
self.directory, "{}{}".format(
quarantine_id, self._metadata_suffix))
try: try:
with open(metafile, "w") as f: with open(metafile, "w") as f:
json.dump(metadata, f, indent=2) json.dump(metadata, f, indent=2)
@@ -109,10 +120,21 @@ class FileQuarantine(BaseQuarantine):
except IOError as e: except IOError as e:
raise RuntimeError("unable to remove data file: {}".format(e)) raise RuntimeError("unable to remove data file: {}".format(e))
def add(self, queueid, mailfrom, recipients, headers, fp, subgroups=None, named_subgroups=None): 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 quarantine and return quarantine-id."
super(FileQuarantine, self).add(queueid, mailfrom, recipients, headers, fp, subgroups, named_subgroups) super(
quarantine_id = "{}_{}".format(datetime.now().strftime("%Y%m%d%H%M%S"), queueid) FileQuarantine,
self).add(
queueid,
mailfrom,
recipients,
headers,
fp,
subgroups,
named_subgroups)
quarantine_id = "{}_{}".format(
datetime.now().strftime("%Y%m%d%H%M%S"), queueid)
# save mail # save mail
self._save_datafile(quarantine_id, fp) self._save_datafile(quarantine_id, fp)
@@ -140,9 +162,12 @@ class FileQuarantine(BaseQuarantine):
"Return metadata of quarantined email." "Return metadata of quarantined email."
super(FileQuarantine, self).get_metadata(quarantine_id) super(FileQuarantine, self).get_metadata(quarantine_id)
metafile = os.path.join(self.directory, "{}{}".format(quarantine_id, self._metadata_suffix)) metafile = os.path.join(
self.directory, "{}{}".format(
quarantine_id, self._metadata_suffix))
if not os.path.isfile(metafile): if not os.path.isfile(metafile):
raise RuntimeError("invalid quarantine id '{}'".format(quarantine_id)) raise RuntimeError(
"invalid quarantine id '{}'".format(quarantine_id))
try: try:
with open(metafile, "r") as f: with open(metafile, "r") as f:
@@ -150,33 +175,41 @@ class FileQuarantine(BaseQuarantine):
except IOError as e: except IOError as e:
raise RuntimeError("unable to read metadata file: {}".format(e)) raise RuntimeError("unable to read metadata file: {}".format(e))
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
raise RuntimeError("invalid meta file '{}': {}".format(metafile, e)) raise RuntimeError(
"invalid meta file '{}': {}".format(
metafile, e))
return metadata return metadata
def find(self, mailfrom=None, recipients=None, older_than=None): def find(self, mailfrom=None, recipients=None, older_than=None):
"Find emails in quarantine." "Find emails in quarantine."
super(FileQuarantine, self).find(mailfrom, recipients, older_than) super(FileQuarantine, self).find(mailfrom, recipients, older_than)
if type(mailfrom) == str: mailfrom = [mailfrom] if isinstance(mailfrom, str):
if type(recipients) == str: recipients = [recipients] mailfrom = [mailfrom]
if isinstance(recipients, str):
recipients = [recipients]
emails = {} emails = {}
metafiles = glob(os.path.join(self.directory, "*{}".format(self._metadata_suffix))) metafiles = glob(os.path.join(
self.directory, "*{}".format(self._metadata_suffix)))
for metafile in metafiles: for metafile in metafiles:
if not os.path.isfile(metafile): continue if not os.path.isfile(metafile):
continue
quarantine_id = os.path.basename(metafile[:-len(self._metadata_suffix)]) quarantine_id = os.path.basename(
metafile[:-len(self._metadata_suffix)])
metadata = self.get_metadata(quarantine_id) metadata = self.get_metadata(quarantine_id)
if older_than != None: if older_than is not None:
if timegm(gmtime()) - metadata["date"] < (older_than * 24 * 3600): if timegm(gmtime()) - metadata["date"] < (older_than * 86400):
continue continue
if mailfrom != None: if mailfrom is not None:
if metadata["mailfrom"] not in mailfrom: if metadata["mailfrom"] not in mailfrom:
continue continue
if recipients != None: if recipients is not None:
if len(recipients) == 1 and recipients[0] not in metadata["recipients"]: if len(recipients) == 1 and \
recipients[0] not in metadata["recipients"]:
continue continue
elif len(set(recipients + metadata["recipients"])) == len(recipients + metadata["recipients"]): elif len(set(recipients + metadata["recipients"])) == len(recipients + metadata["recipients"]):
continue continue
@@ -194,7 +227,7 @@ class FileQuarantine(BaseQuarantine):
except RuntimeError as e: except RuntimeError as e:
raise RuntimeError("unable to delete email: {}".format(e)) raise RuntimeError("unable to delete email: {}".format(e))
if recipient == None: if recipient is None:
self._remove(quarantine_id) self._remove(quarantine_id)
else: else:
if recipient not in metadata["recipients"]: if recipient not in metadata["recipients"]:
@@ -215,7 +248,7 @@ class FileQuarantine(BaseQuarantine):
except RuntimeError as e: except RuntimeError as e:
raise RuntimeError("unable to release email: {}".format(e)) raise RuntimeError("unable to release email: {}".format(e))
if recipient != None: if recipient is not None:
if recipient not in metadata["recipients"]: if recipient not in metadata["recipients"]:
raise RuntimeError("invalid recipient '{}'".format(recipient)) raise RuntimeError("invalid recipient '{}'".format(recipient))
recipients = [recipient] recipients = [recipient]
@@ -225,11 +258,13 @@ class FileQuarantine(BaseQuarantine):
datafile = os.path.join(self.directory, quarantine_id) datafile = os.path.join(self.directory, quarantine_id)
try: try:
with open(datafile, "rb") as fp: with open(datafile, "rb") as fp:
self.config["notification_obj"].notify(metadata["queue_id"], quarantine_id, metadata["mailfrom"], recipients, metadata["headers"], fp, self.config["notification_obj"].notify(
metadata["subgroups"], metadata["named_subgroups"], synchronous=True) metadata["queue_id"], quarantine_id, metadata["mailfrom"],
recipients, metadata["headers"], fp,
metadata["subgroups"], metadata["named_subgroups"],
synchronous=True)
except IOError as e: except IOError as e:
raise(RuntimeError("unable to read data file: {}".format(e))) raise RuntimeError
def release(self, quarantine_id, recipient=None): def release(self, quarantine_id, recipient=None):
"Release email from quarantine." "Release email from quarantine."
@@ -240,7 +275,7 @@ class FileQuarantine(BaseQuarantine):
except RuntimeError as e: except RuntimeError as e:
raise RuntimeError("unable to release email: {}".format(e)) raise RuntimeError("unable to release email: {}".format(e))
if recipient != None: if recipient is not None:
if recipient not in metadata["recipients"]: if recipient not in metadata["recipients"]:
raise RuntimeError("invalid recipient '{}'".format(recipient)) raise RuntimeError("invalid recipient '{}'".format(recipient))
recipients = [recipient] recipients = [recipient]
@@ -256,9 +291,16 @@ class FileQuarantine(BaseQuarantine):
for recipient in recipients: for recipient in recipients:
try: try:
mailer.smtp_send(self.config["smtp_host"], self.config["smtp_port"], metadata["mailfrom"], recipient, mail) mailer.smtp_send(
self.config["smtp_host"],
self.config["smtp_port"],
metadata["mailfrom"],
recipient,
mail)
except Exception as e: except Exception as e:
raise RuntimeError("error while sending email to '{}': {}".format(recipient, e)) raise RuntimeError(
"error while sending email to '{}': {}".format(
recipient, e))
self.delete(quarantine_id, recipient) self.delete(quarantine_id, recipient)

View File

@@ -22,17 +22,37 @@ import sys
import pyquarantine import pyquarantine
from pyquarantine.version import __version__ as version
def main(): def main():
"Run PyQuarantine-Milter." "Run PyQuarantine-Milter."
# parse command line # parse command line
parser = argparse.ArgumentParser(description="PyQuarantine milter daemon", parser = argparse.ArgumentParser(
formatter_class=lambda prog: argparse.HelpFormatter(prog, max_help_position=45, width=140)) description="PyQuarantine milter daemon",
parser.add_argument("-c", "--config", help="List of config files to read.", nargs="+", formatter_class=lambda prog: argparse.HelpFormatter(
default=pyquarantine.QuarantineMilter.get_configfiles()) prog, max_help_position=45, width=140))
parser.add_argument("-s", "--socket", help="Socket used to communicate with the MTA.", required=True) parser.add_argument(
parser.add_argument("-d", "--debug", help="Log debugging messages.", action="store_true") "-c", "--config",
parser.add_argument("-t", "--test", help="Check configuration.", action="store_true") help="List of config files to read.",
nargs="+",
default=pyquarantine.QuarantineMilter.get_configfiles())
parser.add_argument(
"-s", "--socket",
help="Socket used to communicate with the MTA.",
required=True)
parser.add_argument(
"-d", "--debug",
help="Log debugging messages.",
action="store_true")
parser.add_argument(
"-t", "--test",
help="Check configuration.",
action="store_true")
parser.add_argument(
"-v", "--version",
help="Print version.",
action="version",
version="%(prog)s ({})".format(version))
args = parser.parse_args() args = parser.parse_args()
# setup logging # setup logging
@@ -65,11 +85,14 @@ def main():
sys.exit(255) sys.exit(255)
else: else:
sys.exit(0) sys.exit(0)
formatter = logging.Formatter("%(asctime)s {}: [%(levelname)s] %(message)s".format(logname), datefmt="%Y-%m-%d %H:%M:%S") formatter = logging.Formatter(
"%(asctime)s {}: [%(levelname)s] %(message)s".format(logname),
datefmt="%Y-%m-%d %H:%M:%S")
stdouthandler.setFormatter(formatter) stdouthandler.setFormatter(formatter)
# setup syslog # setup syslog
sysloghandler = logging.handlers.SysLogHandler(address="/dev/log", facility=logging.handlers.SysLogHandler.LOG_MAIL) sysloghandler = logging.handlers.SysLogHandler(
address="/dev/log", facility=logging.handlers.SysLogHandler.LOG_MAIL)
sysloghandler.setLevel(loglevel) sysloghandler.setLevel(loglevel)
formatter = logging.Formatter("{}: %(message)s".format(syslog_name)) formatter = logging.Formatter("{}: %(message)s".format(syslog_name))
sysloghandler.setFormatter(formatter) sysloghandler.setFormatter(formatter)
@@ -89,12 +112,13 @@ def main():
# register to have the Milter factory create instances of your class: # register to have the Milter factory create instances of your class:
Milter.factory = pyquarantine.QuarantineMilter Milter.factory = pyquarantine.QuarantineMilter
Milter.set_exception_policy(Milter.TEMPFAIL) Milter.set_exception_policy(Milter.TEMPFAIL)
#Milter.set_flags(0) # tell sendmail which features we use # Milter.set_flags(0) # tell sendmail which features we use
# run milter # run milter
rc = 0 rc = 0
try: try:
Milter.runmilter("pyquarantine-milter", socketname=args.socket, timeout=300) Milter.runmilter("pyquarantine-milter", socketname=args.socket,
timeout=300)
except Milter.milter.error as e: except Milter.milter.error as e:
logger.error(e) logger.error(e)
rc = 255 rc = 255

1
pyquarantine/version.py Normal file
View File

@@ -0,0 +1 @@
__version__ = "0.0.6"

View File

@@ -12,24 +12,26 @@
# along with PyQuarantineMilter. If not, see <http://www.gnu.org/licenses/>. # along with PyQuarantineMilter. If not, see <http://www.gnu.org/licenses/>.
# #
import datetime
import logging import logging
import peewee import peewee
import re import re
import sys import sys
from datetime import datetime
from playhouse.db_url import connect from playhouse.db_url import connect
class WhitelistBase(object): class WhitelistBase(object):
"Whitelist base class" "Whitelist base class"
def __init__(self, global_config, config, configtest=False): def __init__(self, global_config, config, configtest=False):
self.global_config = global_config self.global_config = global_config
self.config = config self.config = config
self.configtest = configtest self.configtest = configtest
self.name = config["name"] self.name = config["name"]
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
self.valid_entry_regex = re.compile(r"^[a-zA-Z0-9_.+-]*?(@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+)?$") self.valid_entry_regex = re.compile(
r"^[a-zA-Z0-9_.+-]*?(@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+)?$")
def check(self, mailfrom, recipient): def check(self, mailfrom, recipient):
"Check if mailfrom/recipient combination is whitelisted." "Check if mailfrom/recipient combination is whitelisted."
@@ -56,15 +58,16 @@ class WhitelistBase(object):
class WhitelistModel(peewee.Model): class WhitelistModel(peewee.Model):
mailfrom = peewee.CharField() mailfrom = peewee.CharField()
recipient = peewee.CharField() recipient = peewee.CharField()
created = peewee.DateTimeField(default=datetime.datetime.now) created = peewee.DateTimeField(default=datetime.now)
last_used = peewee.DateTimeField(default=datetime.datetime.now) last_used = peewee.DateTimeField(default=datetime.now)
comment = peewee.TextField(default="") comment = peewee.TextField(default="")
permanent = peewee.BooleanField(default=False) permanent = peewee.BooleanField(default=False)
class Meta(object): class Meta(object):
indexes = ( indexes = (
(('mailfrom', 'recipient'), True), # trailing comma is mandatory if only one index should be created # trailing comma is mandatory if only one index should be created
(('mailfrom', 'recipient'), True),
) )
@@ -74,14 +77,21 @@ class DatabaseWhitelist(WhitelistBase):
_db_tables = {} _db_tables = {}
def __init__(self, global_config, config, configtest=False): def __init__(self, global_config, config, configtest=False):
super(DatabaseWhitelist, self).__init__(global_config, config, configtest) super(
DatabaseWhitelist,
self).__init__(
global_config,
config,
configtest)
# check if mandatory options are present in config # check if mandatory options are present in config
for option in ["whitelist_db_connection", "whitelist_db_table"]: for option in ["whitelist_db_connection", "whitelist_db_table"]:
if option not in self.config.keys() and option in self.global_config.keys(): if option not in self.config.keys() and option in self.global_config.keys():
self.config[option] = self.global_config[option] self.config[option] = self.global_config[option]
if option not in self.config.keys(): if option not in self.config.keys():
raise RuntimeError("mandatory option '{}' not present in config section '{}' or 'global'".format(option, self.name)) raise RuntimeError(
"mandatory option '{}' not present in config section '{}' or 'global'".format(
option, self.name))
tablename = self.config["whitelist_db_table"] tablename = self.config["whitelist_db_table"]
connection_string = self.config["whitelist_db_connection"] connection_string = self.config["whitelist_db_connection"]
@@ -91,10 +101,16 @@ class DatabaseWhitelist(WhitelistBase):
else: else:
try: try:
# connect to database # connect to database
self.logger.debug("connecting to database '{}'".format(re.sub(r"(.*?://.*?):.*?(@.*)", r"\1:<PASSWORD>\2", connection_string))) self.logger.debug(
"connecting to database '{}'".format(
re.sub(
r"(.*?://.*?):.*?(@.*)",
r"\1:<PASSWORD>\2",
connection_string)))
db = connect(connection_string) db = connect(connection_string)
except Exception as e: except Exception as e:
raise RuntimeError("unable to connect to database: {}".format(e)) raise RuntimeError(
"unable to connect to database: {}".format(e))
DatabaseWhitelist._db_connections[connection_string] = db DatabaseWhitelist._db_connections[connection_string] = db
@@ -115,7 +131,9 @@ class DatabaseWhitelist(WhitelistBase):
try: try:
db.create_tables([self.model]) db.create_tables([self.model])
except Exception as e: except Exception as e:
raise RuntimeError("unable to initialize table '{}': {}".format(tablename, e)) raise RuntimeError(
"unable to initialize table '{}': {}".format(
tablename, e))
def _entry_to_dict(self, entry): def _entry_to_dict(self, entry):
result = {} result = {}
@@ -144,7 +162,9 @@ class DatabaseWhitelist(WhitelistBase):
super(DatabaseWhitelist, self).check(mailfrom, recipient) super(DatabaseWhitelist, self).check(mailfrom, recipient)
# generate list of possible mailfroms # generate list of possible mailfroms
self.logger.debug("query database for whitelist entries from <{}> to <{}>".format(mailfrom, recipient)) self.logger.debug(
"query database for whitelist entries from <{}> to <{}>".format(
mailfrom, recipient))
mailfroms = [""] mailfroms = [""]
if "@" in mailfrom and not mailfrom.startswith("@"): if "@" in mailfrom and not mailfrom.startswith("@"):
mailfroms.append("@{}".format(mailfrom.split("@")[1])) mailfroms.append("@{}".format(mailfrom.split("@")[1]))
@@ -158,7 +178,10 @@ class DatabaseWhitelist(WhitelistBase):
# query the database # query the database
try: try:
entries = list(self.model.select().where(self.model.mailfrom.in_(mailfroms), self.model.recipient.in_(recipients))) entries = list(
self.model.select().where(
self.model.mailfrom.in_(mailfroms),
self.model.recipient.in_(recipients)))
except Exception as e: except Exception as e:
raise RuntimeError("unable to query database: {}".format(e)) raise RuntimeError("unable to query database: {}".format(e))
@@ -171,7 +194,7 @@ class DatabaseWhitelist(WhitelistBase):
# use entry with the highest weight # use entry with the highest weight
entry = entries[0] entry = entries[0]
entry.last_used = datetime.datetime.now() entry.last_used = datetime.now()
entry.save() entry.save()
result = {} result = {}
for entry in entries: for entry in entries:
@@ -183,21 +206,23 @@ class DatabaseWhitelist(WhitelistBase):
"Find whitelist entries." "Find whitelist entries."
super(DatabaseWhitelist, self).find(mailfrom, recipients, older_than) super(DatabaseWhitelist, self).find(mailfrom, recipients, older_than)
if type(mailfrom) == str: mailfrom = [mailfrom] if isinstance(mailfrom, str):
if type(recipients) == str: recipients = [recipients] mailfrom = [mailfrom]
if isinstance(recipients, str):
recipients = [recipients]
entries = {} entries = {}
try: try:
for entry in list(self.model.select()): for entry in list(self.model.select()):
if older_than != None: if older_than is not None:
if (datetime.datetime.now() - entry.last_used).total_seconds() < (older_than * 24 * 3600): if (datetime.now() - entry.last_used).total_seconds() < (older_than * 86400):
continue continue
if mailfrom != None: if mailfrom is not None:
if entry.mailfrom not in mailfrom: if entry.mailfrom not in mailfrom:
continue continue
if recipients != None: if recipients is not None:
if entry.recipient not in recipients: if entry.recipient not in recipients:
continue continue
@@ -209,10 +234,20 @@ class DatabaseWhitelist(WhitelistBase):
def add(self, mailfrom, recipient, comment, permanent): def add(self, mailfrom, recipient, comment, permanent):
"Add entry to whitelist." "Add entry to whitelist."
super(DatabaseWhitelist, self).add(mailfrom, recipient, comment, permanent) super(
DatabaseWhitelist,
self).add(
mailfrom,
recipient,
comment,
permanent)
try: try:
self.model.create(mailfrom=mailfrom, recipient=recipient, comment=comment, permanent=permanent) self.model.create(
mailfrom=mailfrom,
recipient=recipient,
comment=comment,
permanent=permanent)
except Exception as e: except Exception as e:
raise RuntimeError("unable to add entry to database: {}".format(e)) raise RuntimeError("unable to add entry to database: {}".format(e))
@@ -224,7 +259,8 @@ class DatabaseWhitelist(WhitelistBase):
query = self.model.delete().where(self.model.id == whitelist_id) query = self.model.delete().where(self.model.id == whitelist_id)
deleted = query.execute() deleted = query.execute()
except Exception as e: except Exception as e:
raise RuntimeError("unable to delete entry from database: {}".format(e)) raise RuntimeError(
"unable to delete entry from database: {}".format(e))
if deleted == 0: if deleted == 0:
raise RuntimeError("invalid whitelist id") raise RuntimeError("invalid whitelist id")
@@ -239,15 +275,19 @@ class WhitelistCache(object):
self.check(whitelist, mailfrom, recipient) self.check(whitelist, mailfrom, recipient)
def check(self, whitelist, mailfrom, recipient): def check(self, whitelist, mailfrom, recipient):
if whitelist not in self.cache.keys(): self.cache[whitelist] = {} if whitelist not in self.cache.keys():
if recipient not in self.cache[whitelist].keys(): self.cache[whitelist][recipient] = None self.cache[whitelist] = {}
if self.cache[whitelist][recipient] == None: if recipient not in self.cache[whitelist].keys():
self.cache[whitelist][recipient] = whitelist.check(mailfrom, recipient) self.cache[whitelist][recipient] = None
if self.cache[whitelist][recipient] is None:
self.cache[whitelist][recipient] = whitelist.check(
mailfrom, recipient)
return self.cache[whitelist][recipient] return self.cache[whitelist][recipient]
def get_whitelisted_recipients(self, whitelist, mailfrom, recipients): def get_whitelisted_recipients(self, whitelist, mailfrom, recipients):
self.load(whitelist, mailfrom, recipients) self.load(whitelist, mailfrom, recipients)
return list(filter(lambda x: self.cache[whitelist][x], self.cache[whitelist].keys())) return list(
filter(lambda x: self.cache[whitelist][x], self.cache[whitelist].keys()))
# list of whitelist types and their related whitelist classes # list of whitelist types and their related whitelist classes

View File

@@ -4,8 +4,11 @@ def read_file(fname):
with open(fname, 'r') as f: with open(fname, 'r') as f:
return f.read() return f.read()
version = {}
exec(read_file("pyquarantine/version.py"), version)
setup(name = "pyquarantine", setup(name = "pyquarantine",
version = "0.0.1", version = version["__version__"],
author = "Thomas Oettli", author = "Thomas Oettli",
author_email = "spacefreak@noop.ch", author_email = "spacefreak@noop.ch",
description = "A pymilter based sendmail/postfix pre-queue filter.", description = "A pymilter based sendmail/postfix pre-queue filter.",
@@ -32,6 +35,6 @@ setup(name = "pyquarantine",
"pyquarantine=pyquarantine.cli:main" "pyquarantine=pyquarantine.cli:main"
] ]
}, },
install_requires = ["pymilter", "netaddr", "beautifulsoup4", "peewee"], install_requires = ["pymilter", "netaddr", "beautifulsoup4[lxml]", "peewee"],
python_requires = ">=3" python_requires = ">=3"
) )