14 Commits
1.0.0 ... 1.0.6

11 changed files with 348 additions and 339 deletions

View File

@@ -8,7 +8,7 @@ Each quarantine can be configured with a quarantine type, notification type, whi
Addionally, pyquarantine-milter provides a sanitized, harmless version of the text parts of e-mails, which can be embedded in e-mail notifications. This makes it easier for users to decide, if a match is a false-positive or not. If a matching quarantine provides a quarantine ID of the original e-mail, it is also available as a template variable. This is useful if you want to add links to a webservice to notification e-mails, to give your users the ability to release e-mails or whitelist the from-address for example. The webservice then releases the e-mail from the quarantine. Addionally, pyquarantine-milter provides a sanitized, harmless version of the text parts of e-mails, which can be embedded in e-mail notifications. This makes it easier for users to decide, if a match is a false-positive or not. If a matching quarantine provides a quarantine ID of the original e-mail, it is also available as a template variable. This is useful if you want to add links to a webservice to notification e-mails, to give your users the ability to release e-mails or whitelist the from-address for example. The webservice then releases the e-mail from the quarantine.
The project is currently in alpha status, but will soon be used in a productive enterprise environment and possibly existing bugs will be fixed. The project is currently in beta status, but it is already used in a productive enterprise environment which processes about a million e-mails per month.
## Requirements ## Requirements
* pymilter <https://pythonhosted.org/pymilter/> * pymilter <https://pythonhosted.org/pymilter/>

View File

@@ -12,7 +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 Milter
import configparser
import logging
import os
import re
from Milter.utils import parse_addr
from collections import defaultdict
from email.charset import Charset
from email.header import Header, decode_header
from io import BytesIO
from itertools import groupby
from netaddr import IPAddress, IPNetwork, AddrFormatError
from pyquarantine import mailer
from pyquarantine import notifications
from pyquarantine import storages
from pyquarantine import whitelists
__all__ = [ __all__ = [
"make_header",
"Quarantine", "Quarantine",
"QuarantineMilter", "QuarantineMilter",
"setup_milter", "setup_milter",
@@ -22,33 +41,38 @@ __all__ = [
"notifications", "notifications",
"storages", "storages",
"run", "run",
"version",
"whitelists"] "whitelists"]
name = "pyquarantine" __version__ = "1.0.6"
import Milter
import configparser
import logging
import os
import re
import sys
from Milter.utils import parse_addr def make_header(decoded_seq, maxlinelen=None, header_name=None,
from collections import defaultdict continuation_ws=' ', errors='strict'):
from io import BytesIO """Create a Header from a sequence of pairs as returned by decode_header()
from itertools import groupby
from netaddr import IPAddress, IPNetwork decode_header() takes a header value string and returns a sequence of
from pyquarantine import mailer pairs of the format (decoded_string, charset) where charset is the string
from pyquarantine import notifications name of the character set.
from pyquarantine import storages
from pyquarantine import whitelists This function takes one of those sequence of pairs and returns a Header
instance. Optional maxlinelen, header_name, and continuation_ws are as in
the Header constructor.
"""
h = Header(maxlinelen=maxlinelen, header_name=header_name,
continuation_ws=continuation_ws)
for s, charset in decoded_seq:
# None means us-ascii but we can simply pass it on to h.append()
if charset is not None and not isinstance(charset, Charset):
charset = Charset(charset)
h.append(s, charset, errors=errors)
return h
class Quarantine(object): class Quarantine(object):
"""Quarantine class suitable for QuarantineMilter """Quarantine class suitable for QuarantineMilter
The class holds all the objects and functions needed for QuarantineMilter quarantine. The class holds all the objects and functions needed
for QuarantineMilter quarantine.
""" """
@@ -64,8 +88,9 @@ class Quarantine(object):
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
self.name = name self.name = name
self.index = index self.index = index
self.regex = regex
if regex: if regex:
self.regex = re.compile( self.re = re.compile(
regex, re.MULTILINE + re.DOTALL + re.IGNORECASE) regex, re.MULTILINE + re.DOTALL + re.IGNORECASE)
self.storage = storage self.storage = storage
self.whitelist = whitelist self.whitelist = whitelist
@@ -96,14 +121,13 @@ class Quarantine(object):
cfg[opt] = defaults[opt] cfg[opt] = defaults[opt]
else: else:
raise RuntimeError( raise RuntimeError(
"mandatory option '{}' not present in config section '{}' or 'global'".format( f"mandatory option '{opt}' not present in "
opt, self.name)) f"config section '{self.name}' or 'global'")
# pre-compile regex # pre-compile regex
self.logger.debug( self.logger.debug(
"{}: compiling regex '{}'".format( f"{self.name}: compiling regex '{cfg['regex']}'")
self.name, cfg["regex"])) self.re = re.compile(
self.regex = re.compile(
cfg["regex"], re.MULTILINE + re.DOTALL + re.IGNORECASE) cfg["regex"], re.MULTILINE + re.DOTALL + re.IGNORECASE)
self.smtp_host = cfg["smtp_host"] self.smtp_host = cfg["smtp_host"]
@@ -113,62 +137,57 @@ class Quarantine(object):
storage_type = cfg["storage_type"].lower() storage_type = cfg["storage_type"].lower()
if storage_type in storages.TYPES: if storage_type in storages.TYPES:
self.logger.debug( self.logger.debug(
"{}: initializing storage type '{}'".format( f"{self.name}: initializing storage "
self.name, f"type '{storage_type.upper()}'")
storage_type.upper()))
self.storage = storages.TYPES[storage_type]( self.storage = storages.TYPES[storage_type](
self.name, global_cfg, cfg, test) self.name, global_cfg, cfg, test)
elif storage_type == "none": elif storage_type == "none":
self.logger.debug("{}: storage is NONE".format(self.name)) self.logger.debug(f"{self.name}: storage is NONE")
self.storage = None self.storage = None
else: else:
raise RuntimeError( raise RuntimeError(
"{}: unknown storage type '{}'".format( f"{self.name}: unknown storage type '{storage_type}'")
self.name, storage_type))
# create whitelist instance # create whitelist instance
whitelist_type = cfg["whitelist_type"].lower() whitelist_type = cfg["whitelist_type"].lower()
if whitelist_type in whitelists.TYPES: if whitelist_type in whitelists.TYPES:
self.logger.debug( self.logger.debug(
"{}: initializing whitelist type '{}'".format( f"{self.name}: initializing whitelist "
self.name, f"type '{whitelist_type.upper()}'")
whitelist_type.upper()))
self.whitelist = whitelists.TYPES[whitelist_type]( self.whitelist = whitelists.TYPES[whitelist_type](
self.name, global_cfg, cfg, test) self.name, global_cfg, cfg, test)
elif whitelist_type == "none": elif whitelist_type == "none":
logger.debug("{}: whitelist is NONE".format(self.name)) self.logger.debug(f"{self.name}: whitelist is NONE")
self.whitelist = None self.whitelist = None
else: else:
raise RuntimeError( raise RuntimeError(
"{}: unknown whitelist type '{}'".format( f"{self.name}: unknown whitelist type '{whitelist_type}'")
self.name, whitelist_type))
# create notification instance # create notification instance
notification_type = cfg["notification_type"].lower() notification_type = cfg["notification_type"].lower()
if notification_type in notifications.TYPES: if notification_type in notifications.TYPES:
self.logger.debug( self.logger.debug(
"{}: initializing notification type '{}'".format( f"{self.name}: initializing notification "
self.name, f"type '{notification_type.upper()}'")
notification_type.upper()))
self.notification = notifications.TYPES[notification_type]( self.notification = notifications.TYPES[notification_type](
self.name, global_cfg, cfg, test) self.name, global_cfg, cfg, test)
elif notification_type == "none": elif notification_type == "none":
self.logger.debug("{}: notification is NONE".format(self.name)) self.logger.debug(f"{self.name}: notification is NONE")
self.notification = None self.notification = None
else: else:
raise RuntimeError( raise RuntimeError(
"{}: unknown notification type '{}'".format( f"{self.name}: unknown notification "
self.name, notification_type)) f"type '{notification_type}'")
# determining milter action for this quarantine # determining milter action for this quarantine
action = cfg["action"].upper() action = cfg["action"].upper()
if action in self._actions: if action in self._actions:
self.logger.debug("{}: action is {}".format(self.name, action)) self.logger.debug(f"{self.name}: action is {action}")
self.action = action self.action = action
self.milter_action = self._actions[action] self.milter_action = self._actions[action]
else: else:
raise RuntimeError( raise RuntimeError(
"{}: unknown action '{}'".format(self._name, action)) f"{self.name}: unknown action '{action}'")
self.reject_reason = cfg["reject_reason"] self.reject_reason = cfg["reject_reason"]
@@ -183,15 +202,14 @@ class Quarantine(object):
try: try:
net = IPNetwork(host) net = IPNetwork(host)
except AddrFormatError as e: except AddrFormatError as e:
raise RuntimeError("{}: error parsing host_whitelist: {}".format( raise RuntimeError(
self.name, e)) f"{self.name}: error parsing host_whitelist: {e}")
else: else:
self.host_whitelist.append(net) self.host_whitelist.append(net)
if self.host_whitelist: if self.host_whitelist:
whitelist = ", ".join([str(ip) for ip in host_whitelist])
self.logger.debug( self.logger.debug(
"{}: host whitelist: {}".format( f"{self.name}: host whitelist: {whitelist}")
self.name,
", ".join([str(ip) for ip in host_whitelist])))
def notify(self, storage_id, recipient=None, synchronous=True): def notify(self, storage_id, recipient=None, synchronous=True):
"Notify recipient about email in storage." "Notify recipient about email in storage."
@@ -201,13 +219,14 @@ class Quarantine(object):
if not self.notification: if not self.notification:
raise RuntimeError( raise RuntimeError(
"notification type is set to None, unable to send notification") "notification type is set to None, "
"unable to send notification")
fp, metadata = self.storage.get_mail(storage_id) fp, metadata = self.storage.get_mail(storage_id)
if recipient is not 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(f"invalid recipient '{recipient}'")
recipients = [recipient] recipients = [recipient]
else: else:
recipients = metadata["recipients"] recipients = metadata["recipients"]
@@ -230,7 +249,7 @@ class Quarantine(object):
mail = fp.read() mail = fp.read()
fp.close() fp.close()
except IOError as e: except IOError as e:
raise RuntimeError("unable to read data file: {}".format(e)) raise RuntimeError(f"unable to read data file: {e}")
if recipients and type(recipients) == str: if recipients and type(recipients) == str:
recipients = [recipients] recipients = [recipients]
@@ -239,7 +258,7 @@ class Quarantine(object):
for recipient in recipients: for recipient in recipients:
if recipient not in metadata["recipients"]: if recipient not in metadata["recipients"]:
raise RuntimeError("invalid recipient '{}'".format(recipient)) raise RuntimeError(f"invalid recipient '{recipient}'")
try: try:
mailer.smtp_send( mailer.smtp_send(
@@ -250,8 +269,7 @@ class Quarantine(object):
mail) mail)
except Exception as e: except Exception as e:
raise RuntimeError( raise RuntimeError(
"error while sending email to '{}': {}".format( f"error while sending email to '{recipient}': {e}")
recipient, e))
self.storage.delete(storage_id, recipient) self.storage.delete(storage_id, recipient)
def get_storage(self): def get_storage(self):
@@ -267,21 +285,22 @@ class Quarantine(object):
ip = IPAddress(hostaddr[0]) ip = IPAddress(hostaddr[0])
for entry in self.host_whitelist: for entry in self.host_whitelist:
if ip in entry: if ip in entry:
return true return True
return False return False
def match(self, header): def match(self, header):
return self.regex.search(header) return self.re.search(header)
class QuarantineMilter(Milter.Base): class QuarantineMilter(Milter.Base):
"""QuarantineMilter based on Milter.Base to implement milter communication """QuarantineMilter based on Milter.Base to implement milter communication
The class variable quarantines needs to be filled by runng the setup_milter function. The class variable quarantines needs to be filled by
runng the setup_milter function.
""" """
quarantines = [] quarantines = []
preferred_quarantine_action = "first" preferred_action = "first"
# list of default config files # list of default config files
_cfg_files = [ _cfg_files = [
@@ -296,8 +315,8 @@ class QuarantineMilter(Milter.Base):
def _get_preferred_quarantine(self): def _get_preferred_quarantine(self):
matching_quarantines = [ matching_quarantines = [
q for q in self.recipients_quarantines.values() if q] q for q in self.rcpts_quarantines.values() if q]
if self.preferred_quarantine_action == "first": if self.preferred_action == "first":
quarantine = sorted( quarantine = sorted(
matching_quarantines, matching_quarantines,
key=lambda q: q.index)[0] key=lambda q: q.index)[0]
@@ -319,19 +338,18 @@ class QuarantineMilter(Milter.Base):
def connect(self, hostname, family, hostaddr): def connect(self, hostname, family, hostaddr):
self.hostaddr = hostaddr self.hostaddr = hostaddr
self.logger.debug( self.logger.debug(
"accepted milter connection from {} port {}".format( f"accepted milter connection from {hostaddr[0]} "
*hostaddr)) f"port {hostaddr[1]}")
for quarantine in self.quarantines.copy(): for quarantine in self.quarantines.copy():
if quarantine.host_in_whitelist(hostaddr): if quarantine.host_in_whitelist(hostaddr):
self.logger.debug( self.logger.debug(
"host {} is in whitelist of quarantine {}".format( f"host {hostaddr[0]} is in whitelist of "
hostaddr[0], quarantine["name"])) f"quarantine {quarantine.name}")
self.quarantines.remove(quarantine) self.quarantines.remove(quarantine)
if not self.quarantines: if not self.quarantines:
self.logger.debug( self.logger.debug(
"host {} is in whitelist of all quarantines, " f"host {hostaddr[0]} is in whitelist of all "
"skip further processing", f"quarantines, skip further processing")
hostaddr[0])
return Milter.ACCEPT return Milter.ACCEPT
return Milter.CONTINUE return Milter.CONTINUE
@@ -348,135 +366,140 @@ class QuarantineMilter(Milter.Base):
@Milter.noreply @Milter.noreply
def data(self): def data(self):
self.queueid = self.getsymval('i') self.qid = self.getsymval('i')
self.logger.debug( self.logger.debug(
"{}: received queue-id from MTA".format(self.queueid)) f"{self.qid}: received queue-id from MTA")
self.recipients = list(self.recipients) self.recipients = list(self.recipients)
self.headers = []
self.logger.debug( self.logger.debug(
"{}: initializing memory buffer to save email data".format( f"{self.qid}: initializing memory buffer to save email data")
self.queueid))
# initialize memory buffer to save email data # initialize memory buffer to save email data
self.fp = BytesIO() self.fp = BytesIO()
self.headers = []
return Milter.CONTINUE return Milter.CONTINUE
@Milter.noreply
def header(self, name, value): def header(self, name, value):
try: try:
# remove surrogates from value
value = value.encode(
errors="surrogateescape").decode(errors="replace")
self.logger.debug(f"{self.qid}: received header: {name}: {value}")
# write email header to memory buffer # write email header to memory buffer
self.fp.write("{}: {}\r\n".format(name, value).encode( self.fp.write(f"{name}: {value}\r\n".encode(
encoding="ascii", errors="surrogateescape")) encoding="ascii", errors="replace"))
# keep copy of header without surrogates for later use header = make_header(decode_header(value), errors="replace")
self.headers.append(( value = str(header).replace("\x00", "")
name.encode(errors="surrogateescape").decode(errors="replace"), self.logger.debug(
value.encode(errors="surrogateescape").decode(errors="replace"))) f"{self.qid}: decoded header: {name}: {value}")
self.headers.append((name, value))
return Milter.CONTINUE
except Exception as e: except Exception as e:
self.logger.exception( self.logger.exception(
"an exception occured in header function: {}".format(e)) f"an exception occured in header function: {e}")
return Milter.TEMPFAIL return Milter.TEMPFAIL
return Milter.CONTINUE
def eoh(self): def eoh(self):
try: try:
self.fp.write("\r\n".encode(encoding="ascii")) self.fp.write("\r\n".encode(encoding="ascii"))
self.whitelist_cache = whitelists.WhitelistCache() self.wl_cache = whitelists.WhitelistCache()
# initialize dicts to set quaranines per recipient and keep matches # initialize dicts to set quaranines per recipient and keep matches
self.recipients_quarantines = {} self.rcpts_quarantines = {}
self.quarantines_matches = {} self.matches = {}
# iterate email headers # iterate email headers
recipients_to_check = self.recipients.copy() rcpts_to_check = self.recipients.copy()
for name, value in self.headers: for name, value in self.headers:
header = "{}: {}".format(name, value) header = f"{name}: {value}"
self.logger.debug( self.logger.debug(
"{}: checking header against configured quarantines: {}".format( f"{self.qid}: checking header against configured "
self.queueid, header)) f"quarantines: {header}")
# iterate quarantines # iterate quarantines
for quarantine in self.quarantines: for quarantine in self.quarantines:
if len(self.recipients_quarantines) == len( if len(self.rcpts_quarantines) == len(
self.recipients): self.recipients):
# every recipient matched a quarantine already # every recipient matched a quarantine already
if quarantine.index >= max( if quarantine.index >= max(
[q.index for q in self.recipients_quarantines.values()]): [q.index for q in
self.rcpts_quarantines.values()]):
# all recipients matched a quarantine with at least # all recipients matched a quarantine with at least
# the same precedence already, skip checks against # the same precedence already, skip checks against
# quarantines with lower precedence # quarantines with lower precedence
self.logger.debug( self.logger.debug(
"{}: {}: skip further checks of this header".format( f"{self.qid}: {quarantine.name}: skip further "
self.queueid, quarantine.name)) f"checks of this header")
break break
# check email header against quarantine regex # check email header against quarantine regex
self.logger.debug( self.logger.debug(
"{}: {}: checking header against regex '{}'".format( f"{self.qid}: {quarantine.name}: checking header "
self.queueid, quarantine.name, quarantine.regex)) f"against regex '{str(quarantine.regex)}'")
match = quarantine.match(header) match = quarantine.match(header)
if match: if match:
self.logger.debug( self.logger.debug(
"{}: {}: header matched regex".format( f"{self.qid}: {quarantine.name}: "
self.queueid, quarantine.name)) f"header matched regex")
# check for whitelisted recipients # check for whitelisted recipients
whitelist = quarantine.get_whitelist() whitelist = quarantine.get_whitelist()
if whitelist: if whitelist:
try: try:
whitelisted_recipients = self.whitelist_cache.get_whitelisted_recipients( wl_recipients = self.wl_cache.get_recipients(
whitelist, self.mailfrom, recipients_to_check) whitelist,
self.mailfrom,
rcpts_to_check)
except RuntimeError as e: except RuntimeError as e:
self.logger.error( self.logger.error(
"{}: {}: unable to query whitelist: {}".format( f"{self.qid}: {quarantine.name}: unable "
self.queueid, quarantine.name, e)) f"to query whitelist: {e}")
return Milter.TEMPFAIL return Milter.TEMPFAIL
else: else:
whitelisted_recipients = {} wl_recipients = {}
# iterate recipients # iterate recipients
for recipient in recipients_to_check.copy(): for rcpt in rcpts_to_check.copy():
if recipient in whitelisted_recipients: if rcpt in wl_recipients:
# recipient is whitelisted in this quarantine # recipient is whitelisted in this quarantine
self.logger.debug( self.logger.debug(
"{}: {}: recipient '{}' is whitelisted".format( f"{self.qid}: {quarantine.name}: "
self.queueid, quarantine.name, recipient)) f"recipient '{rcpt}' is whitelisted")
continue continue
if recipient not in self.recipients_quarantines.keys() or \ if rcpt not in self.rcpts_quarantines or \
self.recipients_quarantines[recipient].index > quarantine.index: self.rcpts_quarantines[rcpt].index > \
quarantine.index:
self.logger.debug( self.logger.debug(
"{}: {}: set quarantine for recipient '{}'".format( f"{self.qid}: {quarantine.name}: set "
self.queueid, quarantine.name, recipient)) f"quarantine for recipient '{rcpt}'")
# save match for later use as template # save match for later use as template
# variables # variables
self.quarantines_matches[quarantine.name] = match self.matches[quarantine.name] = match
self.recipients_quarantines[recipient] = quarantine self.rcpts_quarantines[rcpt] = quarantine
if quarantine.index == 0: if quarantine.index == 0:
# we do not need to check recipients which # we do not need to check recipients which
# matched the quarantine with the highest # matched the quarantine with the highest
# precedence already # precedence already
recipients_to_check.remove(recipient) rcpts_to_check.remove(rcpt)
else: else:
self.logger.debug( self.logger.debug(
"{}: {}: a quarantine with same or higher precedence " f"{self.qid}: {quarantine.name}: a "
"matched already for recipient '{}'".format( f"quarantine with same or higher "
self.queueid, quarantine.name, recipient)) f"precedence matched already for "
f"recipient '{rcpt}'")
if not recipients_to_check: if not rcpts_to_check:
self.logger.debug( self.logger.debug(
"{}: all recipients matched the first quarantine, " f"{self.qid}: all recipients matched the first "
"skipping all remaining header checks".format( f"quarantine, skipping all remaining header checks")
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.rcpts_quarantines:
# accept email # accept email
self.logger.info( self.logger.info(
"{}: passed clean for all recipients".format( f"{self.qid}: passed clean for all recipients")
self.queueid))
return Milter.ACCEPT return Milter.ACCEPT
# check if the mail body is needed # check if the mail body is needed
for recipient, quarantine in self.recipients_quarantines.items(): for recipient, quarantine in self.rcpts_quarantines.items():
if quarantine.get_storage() or quarantine.get_notification(): if quarantine.get_storage() or quarantine.get_notification():
# mail body is needed, continue processing # mail body is needed, continue processing
return Milter.CONTINUE return Milter.CONTINUE
@@ -485,18 +508,16 @@ class QuarantineMilter(Milter.Base):
# quarantines, just return configured action # quarantines, just return configured action
quarantine = self._get_preferred_quarantine() quarantine = self._get_preferred_quarantine()
self.logger.info( self.logger.info(
"{}: {} matching quarantine is '{}', performing milter action {}".format( f"{self.qid}: {self.preferred_action} "
self.queueid, f"matching quarantine is '{quarantine.name}', performing "
self.preferred_quarantine_action, f"milter action {quarantine.action}")
quarantine.name,
quarantine.action))
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( self.logger.exception(
"an exception occured in eoh function: {}".format(e)) f"an exception occured in eoh function: {e}")
return Milter.TEMPFAIL return Milter.TEMPFAIL
def body(self, chunk): def body(self, chunk):
@@ -505,7 +526,7 @@ class QuarantineMilter(Milter.Base):
self.fp.write(chunk) self.fp.write(chunk)
except Exception as e: except Exception as e:
self.logger.exception( self.logger.exception(
"an exception occured in body function: {}".format(e)) f"an exception occured in body function: {e}")
return Milter.TEMPFAIL return Milter.TEMPFAIL
return Milter.CONTINUE return Milter.CONTINUE
@@ -514,9 +535,9 @@ 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, sorted(self.rcpts_quarantines,
key=lambda x: self.recipients_quarantines[x].index), key=lambda x: self.rcpts_quarantines[x].index),
lambda x: self.recipients_quarantines[x]): lambda x: self.rcpts_quarantines[x]):
quarantines.append((quarantine, list(recipients))) quarantines.append((quarantine, list(recipients)))
# iterate quarantines sorted by index # iterate quarantines sorted by index
@@ -525,26 +546,29 @@ class QuarantineMilter(Milter.Base):
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( subgroups = self.matches[quarantine.name].groups(
default="") default="")
named_subgroups = self.quarantines_matches[quarantine.name].groupdict( named_subgroups = self.matches[quarantine.name].groupdict(
default="") default="")
rcpts = ", ".join(recipients)
# check if a storage is configured # check if a storage is configured
storage_id = "" storage_id = ""
storage = quarantine.get_storage() storage = quarantine.get_storage()
if storage: if storage:
# add email to quarantine # add email to quarantine
self.logger.info("{}: adding to quarantine '{}' for: {}".format( self.logger.info(
self.queueid, quarantine.name, ", ".join(recipients))) f"{self.qid}: adding to quarantine "
f"'{quarantine.name}' for: {rcpts}")
try: try:
storage_id = storage.add( storage_id = storage.add(
self.queueid, self.mailfrom, recipients, headers, self.fp, self.qid, self.mailfrom, recipients, headers,
subgroups, named_subgroups) self.fp, subgroups, named_subgroups)
except RuntimeError as e: except RuntimeError as e:
self.logger.error( self.logger.error(
"{}: unable to add to quarantine '{}': {}".format( f"{self.qid}: unable to add to quarantine "
self.queueid, quarantine.name, e)) f"'{quarantine.name}': {e}")
return Milter.TEMPFAIL return Milter.TEMPFAIL
# check if a notification is configured # check if a notification is configured
@@ -552,17 +576,15 @@ class QuarantineMilter(Milter.Base):
if notification: if notification:
# notify # notify
self.logger.info( self.logger.info(
"{}: sending notification for quarantine '{}' to: {}".format( f"{self.qid}: sending notification to: {rcpts}")
self.queueid, quarantine.name, ", ".join(recipients)))
try: try:
notification.notify( notification.notify(
self.queueid, storage_id, self.qid, storage_id,
self.mailfrom, recipients, headers, self.fp, self.mailfrom, recipients, headers, self.fp,
subgroups, named_subgroups) subgroups, named_subgroups)
except RuntimeError as e: except RuntimeError as e:
self.logger.error( self.logger.error(
"{}: unable to send notification for quarantine '{}': {}".format( f"{self.qid}: unable to send notification: {e}")
self.queueid, quarantine.name, e))
return Milter.TEMPFAIL return Milter.TEMPFAIL
# remove processed recipient # remove processed recipient
@@ -574,33 +596,29 @@ 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:
rcpts = ", ".join(recipients)
self.logger.info( self.logger.info(
"{}: passed clean for: {}".format( f"{self.qid}: passed clean for: {rcpts}")
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( self.logger.info(
"{}: {} matching quarantine is '{}', performing milter action {}".format( f"{self.qid}: {self.preferred_action} matching "
self.queueid, f"quarantine is '{quarantine.name}', performing milter "
self.preferred_quarantine_action, f"action {quarantine.action}")
quarantine.name,
quarantine.action))
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( self.logger.exception(
"an exception occured in eom function: {}".format(e)) f"an exception occured in eom function: {e}")
return Milter.TEMPFAIL return Milter.TEMPFAIL
def close(self): def close(self):
self.logger.debug( self.logger.debug(
"disconnect from {} port {}".format( f"disconnect from {self.hostaddr[0]} port {self.hostaddr[1]}")
*self.hostaddr))
return Milter.CONTINUE return Milter.CONTINUE
@@ -627,12 +645,13 @@ def setup_milter(test=False, cfg_files=[]):
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( raise RuntimeError(
"mandatory option '{}' not present in config section 'global'".format(option)) f"mandatory option '{option}' not present in config "
f"section 'global'")
# read global config section # read global config section
global_cfg = dict(parser.items("global")) global_cfg = dict(parser.items("global"))
preferred_quarantine_action = global_cfg["preferred_quarantine_action"].lower() preferred_action = global_cfg["preferred_quarantine_action"].lower()
if preferred_quarantine_action not in ["first", "last"]: if preferred_action not in ["first", "last"]:
raise RuntimeError( raise RuntimeError(
"option preferred_quarantine_action has illegal value") "option preferred_quarantine_action has illegal value")
@@ -641,11 +660,13 @@ def setup_milter(test=False, cfg_files=[]):
q.strip() for q in global_cfg["quarantines"].split(",")] q.strip() for q in global_cfg["quarantines"].split(",")]
if len(quarantines) != len(set(quarantines)): if len(quarantines) != len(set(quarantines)):
raise RuntimeError( raise RuntimeError(
"at least one quarantine is specified multiple times in quarantines option") "at least one quarantine is specified multiple times "
"in quarantines option")
if "global" in quarantines: if "global" in quarantines:
quarantines.remove("global") quarantines.remove("global")
logger.warning( logger.warning(
"removed illegal quarantine name 'global' from list of active quarantines") "removed illegal quarantine name 'global' from list of "
"active quarantines")
if not quarantines: if not quarantines:
raise RuntimeError("no quarantines configured") raise RuntimeError("no quarantines configured")
@@ -656,14 +677,14 @@ def setup_milter(test=False, cfg_files=[]):
# check if config section for current quarantine exists # check if config section for current quarantine exists
if name not in parser.sections(): if name not in parser.sections():
raise RuntimeError( raise RuntimeError(
"config section '{}' does not exist".format(name)) f"config section '{name}' does not exist")
cfg = dict(parser.items(name)) cfg = dict(parser.items(name))
quarantine = Quarantine(name, index) quarantine = Quarantine(name, index)
quarantine.setup_from_cfg(global_cfg, cfg, test) quarantine.setup_from_cfg(global_cfg, cfg, test)
milter_quarantines.append(quarantine) milter_quarantines.append(quarantine)
QuarantineMilter.preferred_quarantine_action = preferred_quarantine_action QuarantineMilter.preferred_action = preferred_action
QuarantineMilter.quarantines = milter_quarantines QuarantineMilter.quarantines = milter_quarantines

View File

@@ -20,19 +20,18 @@ import logging.handlers
import sys import sys
import time import time
from email.header import decode_header, make_header from pyquarantine import QuarantineMilter, setup_milter
from pyquarantine import __version__ as version
import pyquarantine
from pyquarantine.version import __version__ as version
def _get_quarantine(quarantines, name): def _get_quarantine(quarantines, name):
try: try:
quarantine = next((q for q in quarantines if q.name == name)) quarantine = next((q for q in quarantines if q.name == name))
except StopIteration: except StopIteration:
raise RuntimeError("invalid quarantine '{}'".format(name)) raise RuntimeError("invalid quarantine 'name'")
return quarantine return quarantine
def _get_storage(quarantines, name): def _get_storage(quarantines, name):
quarantine = _get_quarantine(quarantines, name) quarantine = _get_quarantine(quarantines, name)
storage = quarantine.get_storage() storage = quarantine.get_storage()
@@ -41,6 +40,7 @@ def _get_storage(quarantines, name):
"storage type is set to NONE") "storage type is set to NONE")
return storage return storage
def _get_notification(quarantines, name): def _get_notification(quarantines, name):
quarantine = _get_quarantine(quarantines, name) quarantine = _get_quarantine(quarantines, name)
notification = quarantine.get_notification() notification = quarantine.get_notification()
@@ -49,6 +49,7 @@ def _get_notification(quarantines, name):
"notification type is set to NONE") "notification type is set to NONE")
return notification return notification
def _get_whitelist(quarantines, name): def _get_whitelist(quarantines, name):
quarantine = _get_quarantine(quarantines, name) quarantine = _get_quarantine(quarantines, name)
whitelist = quarantine.get_whitelist() whitelist = quarantine.get_whitelist()
@@ -57,6 +58,7 @@ def _get_whitelist(quarantines, name):
"whitelist type is set to NONE") "whitelist type is set to NONE")
return whitelist return whitelist
def print_table(columns, rows): def print_table(columns, rows):
if not rows: if not rows:
return return
@@ -74,7 +76,7 @@ def print_table(columns, rows):
# use the longer one # use the longer one
length = max(lengths) length = max(lengths)
column_lengths.append(length) column_lengths.append(length)
column_formats.append("{{:<{}}}".format(length)) column_formats.append(f"{{:<{length}}}")
# define row format # define row format
row_format = " | ".join(column_formats) row_format = " | ".join(column_formats)
@@ -156,9 +158,7 @@ def list_quarantine_emails(quarantines, args):
row["recipient"] = metadata["recipients"].pop(0) row["recipient"] = metadata["recipients"].pop(0)
if "subject" not in emails[storage_id]["headers"].keys(): if "subject" not in emails[storage_id]["headers"].keys():
emails[storage_id]["headers"]["subject"] = "" emails[storage_id]["headers"]["subject"] = ""
row["subject"] = str(make_header(decode_header( row["subject"] = emails[storage_id]["headers"]["subject"][:60].strip()
emails[storage_id]["headers"]["subject"])))[:60].replace(
"\r", "").replace("\n", "").strip()
rows.append(row) rows.append(row)
if metadata["recipients"]: if metadata["recipients"]:
@@ -177,7 +177,7 @@ def list_quarantine_emails(quarantines, args):
return return
if not emails: if not emails:
logger.info("quarantine '{}' is empty".format(args.quarantine)) logger.info(f"quarantine '{args.quarantine}' is empty")
print_table( print_table(
[("Quarantine-ID", "storage_id"), ("Date", "date"), [("Quarantine-ID", "storage_id"), ("Date", "date"),
("From", "mailfrom"), ("Recipient(s)", "recipient"), ("From", "mailfrom"), ("Recipient(s)", "recipient"),
@@ -197,8 +197,7 @@ def list_whitelist(quarantines, args):
older_than=args.older_than) older_than=args.older_than)
if not entries: if not entries:
logger.info( logger.info(
"whitelist of quarantine '{}' is empty".format( f"whitelist of quarantine '{args.quarantine}' is empty")
args.quarantine))
return return
# transform some values to strings # transform some values to strings
@@ -309,7 +308,7 @@ def main():
"-c", "--config", "-c", "--config",
help="Config files to read.", help="Config files to read.",
nargs="+", metavar="CFG", nargs="+", metavar="CFG",
default=pyquarantine.QuarantineMilter.get_cfg_files()) default=QuarantineMilter.get_cfg_files())
parser.add_argument( parser.add_argument(
"-d", "--debug", "-d", "--debug",
help="Log debugging messages.", help="Log debugging messages.",
@@ -318,7 +317,7 @@ def main():
"-v", "--version", "-v", "--version",
help="Print version.", help="Print version.",
action="version", action="version",
version="%(prog)s ({})".format(version)) version=f"%(prog)s ({version})")
parser.set_defaults(syslog=False) parser.set_defaults(syslog=False)
subparsers = parser.add_subparsers( subparsers = parser.add_subparsers(
dest="command", dest="command",
@@ -563,7 +562,7 @@ def main():
# try to generate milter configs # try to generate milter configs
try: try:
pyquarantine.setup_milter( setup_milter(
cfg_files=args.config, test=True) cfg_files=args.config, test=True)
except RuntimeError as e: except RuntimeError as e:
logger.error(e) logger.error(e)
@@ -585,7 +584,7 @@ def main():
# call the commands function # call the commands function
try: try:
args.func(pyquarantine.QuarantineMilter.quarantines, args) args.func(QuarantineMilter.quarantines, args)
except RuntimeError as e: except RuntimeError as e:
logger.error(e) logger.error(e)
sys.exit(1) sys.exit(1)

View File

@@ -14,7 +14,6 @@
import logging import logging
import smtplib import smtplib
import sys
from multiprocessing import Process, Queue from multiprocessing import Process, Queue
@@ -45,23 +44,22 @@ def mailprocess():
if not m: if not m:
break break
smtp_host, smtp_port, queueid, mailfrom, recipient, mail, emailtype = m smtp_host, smtp_port, qid, 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( logger.error(
"{}: error while sending {} to '{}': {}".format( f"{qid}: error while sending {emailtype} "
queueid, emailtype, recipient, e)) f"to '{recipient}': {e}")
else: else:
logger.info( logger.info(
"{}: successfully sent {} to: {}".format( f"{qid}: successfully sent {emailtype} to: {recipient}")
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, def sendmail(smtp_host, smtp_port, qid, mailfrom, recipients, mail,
emailtype="email"): emailtype="email"):
"Send an email." "Send an email."
global logger global logger
@@ -81,8 +79,8 @@ def sendmail(smtp_host, smtp_port, queueid, mailfrom, recipients, mail,
for recipient in recipients: for recipient in recipients:
try: try:
queue.put( queue.put(
(smtp_host, smtp_port, queueid, mailfrom, recipient, mail, (smtp_host, smtp_port, qid, mailfrom, recipient, mail,
emailtype), emailtype),
timeout=30) timeout=30)
except Queue.Full as e: except Queue.Full:
raise RuntimeError("email queue is full") raise RuntimeError("email queue is full")

View File

@@ -19,8 +19,7 @@ import re
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from cgi import escape from cgi import escape
from collections import defaultdict from collections import defaultdict
from email import policy from email.policy import default as default_policy
from email.header import decode_header, make_header
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.mime.image import MIMEImage from email.mime.image import MIMEImage
@@ -38,7 +37,7 @@ class BaseNotification(object):
self.name = name self.name = name
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
def notify(self, queueid, storage_id, mailfrom, recipients, headers, def notify(self, qid, storage_id, mailfrom, recipients, headers,
fp, subgroups=None, named_subgroups=None, synchronous=False): fp, subgroups=None, named_subgroups=None, synchronous=False):
fp.seek(0) fp.seek(0)
pass pass
@@ -136,8 +135,8 @@ class EMailNotification(BaseNotification):
cfg[opt] = defaults[opt] cfg[opt] = defaults[opt]
else: else:
raise RuntimeError( raise RuntimeError(
"mandatory option '{}' not present in config section '{}' or 'global'".format( f"mandatory option '{opt}' not present in config "
opt, self.name)) f"section '{self.name}' or 'global'")
self.smtp_host = cfg["notification_email_smtp_host"] self.smtp_host = cfg["notification_email_smtp_host"]
self.smtp_port = cfg["notification_email_smtp_port"] self.smtp_port = cfg["notification_email_smtp_port"]
@@ -152,14 +151,14 @@ class EMailNotification(BaseNotification):
self.from_header.format_map(testvars) self.from_header.format_map(testvars)
except ValueError as e: except ValueError as e:
raise RuntimeError( raise RuntimeError(
"error parsing notification_email_from: {}".format(e)) f"error parsing notification_email_from: {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( raise RuntimeError(
"error parsing notification_email_subject: {}".format(e)) f"error parsing notification_email_subject: {e}")
# read and parse email notification template # read and parse email notification template
try: try:
@@ -167,9 +166,9 @@ class EMailNotification(BaseNotification):
cfg["notification_email_template"], "r").read() cfg["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(f"error reading template: {e}")
except ValueError as e: except ValueError as e:
raise RuntimeError("error parsing template: {}".format(e)) raise RuntimeError(f"error parsing template: {e}")
strip_images = cfg["notification_email_strip_images"].strip().upper() strip_images = cfg["notification_email_strip_images"].strip().upper()
if strip_images in ["TRUE", "ON", "YES"]: if strip_images in ["TRUE", "ON", "YES"]:
@@ -177,11 +176,13 @@ class EMailNotification(BaseNotification):
elif strip_images in ["FALSE", "OFF", "NO"]: elif strip_images in ["FALSE", "OFF", "NO"]:
self.strip_images = False self.strip_images = False
else: else:
raise RuntimeError("error parsing notification_email_strip_images: unknown value") raise RuntimeError(
"error parsing notification_email_strip_images: unknown value")
self.parser_lib = cfg["notification_email_parser_lib"].strip() self.parser_lib = cfg["notification_email_parser_lib"].strip()
if self.parser_lib not in ["lxml", "html.parser"]: if self.parser_lib not in ["lxml", "html.parser"]:
raise RuntimeError("error parsing notification_email_parser_lib: unknown value") raise RuntimeError(
"error parsing notification_email_parser_lib: unknown value")
# read email replacement image if specified # read email replacement image if specified
replacement_img = cfg["notification_email_replacement_img"].strip() replacement_img = cfg["notification_email_replacement_img"].strip()
@@ -191,7 +192,7 @@ class EMailNotification(BaseNotification):
open(replacement_img, "rb").read()) open(replacement_img, "rb").read())
except IOError as e: except IOError as e:
raise RuntimeError( raise RuntimeError(
"error reading replacement image: {}".format(e)) f"error reading replacement image: {e}")
else: else:
self.replacement_img.add_header( self.replacement_img.add_header(
"Content-ID", "<removed_for_security_reasons>") "Content-ID", "<removed_for_security_reasons>")
@@ -200,27 +201,30 @@ class EMailNotification(BaseNotification):
# read images to embed if specified # read images to embed if specified
embedded_img_paths = [ embedded_img_paths = [
p.strip() for p in cfg["notification_email_embedded_imgs"].split(",") if p] p.strip() for p in cfg["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
try: try:
img = MIMEImage(open(img_path, "rb").read()) img = MIMEImage(open(img_path, "rb").read())
except IOError as e: except IOError as e:
raise RuntimeError("error reading image: {}".format(e)) raise RuntimeError(f"error reading image: {e}")
else: else:
img.add_header("Content-ID", "<{}>".format(basename(img_path))) filename = basename(img_path)
img.add_header("Content-ID", f"<{filename}>")
self.embedded_imgs.append(img) self.embedded_imgs.append(img)
def get_email_body_soup(self, queueid, msg): def get_email_body_soup(self, qid, msg):
"Extract and decode email body and return it as BeautifulSoup object." "Extract and decode email body and return it as BeautifulSoup object."
# try to find the body part # try to find the body part
self.logger.debug("{}: trying to find email body".format(queueid)) self.logger.debug(f"{qid}: trying to find email body")
try: try:
body = msg.get_body(preferencelist=("html", "plain")) body = msg.get_body(preferencelist=("html", "plain"))
except Exception as e: except Exception as e:
self.logger.error("{}: an error occured in email.message.EmailMessage.get_body: {}".format( self.logger.error(
queueid, e)) f"{qid}: an error occured in "
f"email.message.EmailMessage.get_body: {e}")
body = None body = None
if body: if body:
@@ -229,54 +233,54 @@ class EMailNotification(BaseNotification):
try: try:
content = content.decode(encoding=charset, errors="replace") content = content.decode(encoding=charset, errors="replace")
except LookupError: except LookupError:
self.logger.info("{}: unknown encoding '{}', falling back to UTF-8".format( self.logger.info(
queueid, charset)) f"{qid}: unknown encoding '{charset}', "
f"falling back to UTF-8")
content = content.decode("utf-8", errors="replace") content = content.decode("utf-8", errors="replace")
content_type = body.get_content_type() content_type = body.get_content_type()
if content_type == "text/plain": if content_type == "text/plain":
# convert text/plain to text/html # convert text/plain to text/html
self.logger.debug( self.logger.debug(
"{}: content type is {}, converting to text/html".format( f"{qid}: content type is {content_type}, "
queueid, content_type)) f"converting to text/html")
content = re.sub(r"^(.*)$", r"\1<br/>", content = re.sub(r"^(.*)$", r"\1<br/>",
escape(content), flags=re.MULTILINE) escape(content), flags=re.MULTILINE)
else: else:
self.logger.debug( self.logger.debug(
"{}: content type is {}".format( f"{qid}: content type is {content_type}")
queueid, content_type))
else: else:
self.logger.error( self.logger.error(
"{}: unable to find email body".format(queueid)) f"{qid}: unable to find email body")
content = "ERROR: unable to find email body" content = "ERROR: unable to find email body"
# create BeautifulSoup object # create BeautifulSoup object
length = len(content)
self.logger.debug( self.logger.debug(
"{}: trying to create BeatufilSoup object with parser lib {}, " f"{qid}: trying to create BeatufilSoup object with "
"text length is {} bytes".format( f"parser lib {self.parser_lib}, "
queueid, self.parser_lib, len(content))) f"text length is {length} bytes")
soup = BeautifulSoup(content, self.parser_lib) soup = BeautifulSoup(content, self.parser_lib)
self.logger.debug( self.logger.debug(
"{}: sucessfully created BeautifulSoup object".format(queueid)) f"{qid}: sucessfully created BeautifulSoup object")
return soup return soup
def sanitize(self, queueid, soup): def sanitize(self, qid, soup):
"Sanitize mail html text." "Sanitize mail html text."
self.logger.debug("{}: sanitizing email text".format(queueid)) self.logger.debug(f"{qid}: sanitizing email text")
# 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( self.logger.debug(
"{}: removing dangerous tag '{}' and its content".format( f"{qid}: removing dangerous tag '{element.name}' "
queueid, element.name)) f"and its content")
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( self.logger.debug(
"{}: removing tag '{}', keep its content".format( f"{qid}: removing tag '{element.name}', keep its content")
queueid, element.name))
element.replaceWithChildren() element.replaceWithChildren()
# remove not whitelisted attributes # remove not whitelisted attributes
@@ -285,23 +289,23 @@ class EMailNotification(BaseNotification):
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( self.logger.debug(
"{}: setting attribute href to '#' on tag '{}'".format( f"{qid}: setting attribute href to '#' "
queueid, element.name)) f"on tag '{element.name}'")
element["href"] = "#" element["href"] = "#"
else: else:
self.logger.debug( self.logger.debug(
"{}: removing attribute '{}' from tag '{}'".format( f"{qid}: removing attribute '{attribute}' "
queueid, attribute, element.name)) f"from tag '{element.name}'")
del(element.attrs[attribute]) del(element.attrs[attribute])
return soup return soup
def notify(self, queueid, storage_id, mailfrom, recipients, headers, fp, def notify(self, qid, storage_id, mailfrom, recipients, headers, fp,
subgroups=None, named_subgroups=None, synchronous=False): subgroups=None, named_subgroups=None, synchronous=False):
"Notify recipients via email." "Notify recipients via email."
super( super(
EMailNotification, EMailNotification,
self).notify( self).notify(
queueid, qid,
storage_id, storage_id,
mailfrom, mailfrom,
recipients, recipients,
@@ -313,66 +317,55 @@ class EMailNotification(BaseNotification):
# extract body from email # extract body from email
soup = self.get_email_body_soup( soup = self.get_email_body_soup(
queueid, email.message_from_binary_file(fp, policy=policy.default)) qid, email.message_from_binary_file(fp, policy=default_policy))
# replace picture sources # replace picture sources
image_replaced = False image_replaced = False
if self.strip_images: if self.strip_images:
self.logger.debug( self.logger.debug(
"{}: looking for images to strip".format(queueid)) f"{qid}: looking for images to strip")
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( self.logger.debug(
"{}: strip image: {}".format( f"{qid}: strip image: {element['src']}")
queueid, element["src"]))
element.extract() element.extract()
elif self.replacement_img: elif self.replacement_img:
self.logger.debug( self.logger.debug(
"{}: looking for images to replace".format(queueid)) f"{qid}: looking for images to replace")
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( self.logger.debug(
"{}: replacing image: {}".format( f"{qid}: replacing image: {element['src']}")
queueid, element["src"]))
element["src"] = "cid:removed_for_security_reasons" element["src"] = "cid:removed_for_security_reasons"
image_replaced = True image_replaced = True
# sanitizing email text of original email # sanitizing email text of original email
sanitized_text = self.sanitize(queueid, soup) sanitized_text = self.sanitize(qid, soup)
del soup del soup
# sending email notifications # sending email notifications
for recipient in recipients: for recipient in recipients:
self.logger.debug( self.logger.debug(
"{}: generating notification email for '{}'".format( f"{qid}: generating notification email for '{recipient}'")
queueid, recipient)) self.logger.debug(f"{qid}: parsing email template")
self.logger.debug("{}: parsing email template".format(queueid))
# decode some headers
decoded_headers = {}
for header in ["from", "to", "subject"]:
if header in headers:
decoded_headers[header] = str(
make_header(decode_header(headers[header])))
else:
headers[header] = ""
decoded_headers[header] = ""
# 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(decoded_headers["from"]), EMAIL_FROM=escape(headers["from"]),
EMAIL_ENVELOPE_FROM=escape(mailfrom), EMAIL_ENVELOPE_FROM=escape(mailfrom),
EMAIL_ENVELOPE_FROM_URL=escape(quote(mailfrom)), EMAIL_ENVELOPE_FROM_URL=escape(quote(mailfrom)),
EMAIL_TO=escape(decoded_headers["to"]), EMAIL_TO=escape(headers["to"]),
EMAIL_ENVELOPE_TO=escape(recipient), EMAIL_ENVELOPE_TO=escape(recipient),
EMAIL_ENVELOPE_TO_URL=escape(quote(recipient)), EMAIL_ENVELOPE_TO_URL=escape(quote(recipient)),
EMAIL_SUBJECT=escape(decoded_headers["subject"]), EMAIL_SUBJECT=escape(headers["subject"]),
EMAIL_QUARANTINE_ID=storage_id) EMAIL_QUARANTINE_ID=storage_id)
if subgroups: if subgroups:
number = 0 number = 0
for subgroup in subgroups: for subgroup in subgroups:
variables["SUBGROUP_{}".format(number)] = escape(subgroup) variables[f"SUBGROUP_{number}"] = escape(subgroup)
if named_subgroups: if named_subgroups:
for key, value in named_subgroups.items(): for key, value in named_subgroups.items():
named_subgroups[key] = escape(value) named_subgroups[key] = escape(value)
@@ -392,26 +385,24 @@ class EMailNotification(BaseNotification):
if image_replaced: if image_replaced:
self.logger.debug( self.logger.debug(
"{}: attaching notification_replacement_img".format(queueid)) f"{qid}: attaching notification_replacement_img")
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(f"{qid}: attaching imgage")
msg.attach(img) msg.attach(img)
self.logger.debug( self.logger.debug(
"{}: sending notification email to: {}".format( f"{qid}: sending notification email to: {recipient}")
queueid, recipient))
if synchronous: if synchronous:
try: try:
mailer.smtp_send(self.smtp_host, self.smtp_port, mailer.smtp_send(self.smtp_host, self.smtp_port,
self.mailfrom, recipient, msg.as_string()) self.mailfrom, recipient, msg.as_string())
except Exception as e: except Exception as e:
raise RuntimeError( raise RuntimeError(
"error while sending email to '{}': {}".format( f"error while sending email to '{recipient}': {e}")
recipient, e))
else: else:
mailer.sendmail(self.smtp_host, self.smtp_port, queueid, mailer.sendmail(self.smtp_host, self.smtp_port, qid,
self.mailfrom, recipient, msg.as_string(), self.mailfrom, recipient, msg.as_string(),
"notification email") "notification email")

View File

@@ -22,7 +22,8 @@ import sys
import pyquarantine import pyquarantine
from pyquarantine.version import __version__ as version from pyquarantine import __version__ as version
def main(): def main():
"Run PyQuarantine-Milter." "Run PyQuarantine-Milter."
@@ -52,7 +53,7 @@ def main():
"-v", "--version", "-v", "--version",
help="Print version.", help="Print version.",
action="version", action="version",
version="%(prog)s ({})".format(version)) version=f"%(prog)s ({version})")
args = parser.parse_args() args = parser.parse_args()
# setup logging # setup logging
@@ -61,8 +62,8 @@ def main():
syslog_name = logname syslog_name = logname
if args.debug: if args.debug:
loglevel = logging.DEBUG loglevel = logging.DEBUG
logname = "{}[%(name)s]".format(logname) logname = f"{logname}[%(name)s]"
syslog_name = "{}: [%(name)s] %(levelname)s".format(syslog_name) syslog_name = f"{syslog_name}: [%(name)s] %(levelname)s"
# set config files for milter class # set config files for milter class
pyquarantine.QuarantineMilter.set_cfg_files(args.config) pyquarantine.QuarantineMilter.set_cfg_files(args.config)
@@ -72,7 +73,7 @@ def main():
# setup console log # setup console log
stdouthandler = logging.StreamHandler(sys.stdout) stdouthandler = logging.StreamHandler(sys.stdout)
stdouthandler.setLevel(logging.DEBUG) stdouthandler.setLevel(logging.DEBUG)
formatter = logging.Formatter("%(message)s".format(logname)) formatter = logging.Formatter("%(message)s")
stdouthandler.setFormatter(formatter) stdouthandler.setFormatter(formatter)
root_logger.addHandler(stdouthandler) root_logger.addHandler(stdouthandler)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -86,7 +87,7 @@ def main():
else: else:
sys.exit(0) sys.exit(0)
formatter = logging.Formatter( formatter = logging.Formatter(
"%(asctime)s {}: [%(levelname)s] %(message)s".format(logname), f"%(asctime)s {logname}: [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S") datefmt="%Y-%m-%d %H:%M:%S")
stdouthandler.setFormatter(formatter) stdouthandler.setFormatter(formatter)
@@ -94,7 +95,7 @@ def main():
sysloghandler = logging.handlers.SysLogHandler( sysloghandler = logging.handlers.SysLogHandler(
address="/dev/log", facility=logging.handlers.SysLogHandler.LOG_MAIL) 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(f"{syslog_name}: %(message)s")
sysloghandler.setFormatter(formatter) sysloghandler.setFormatter(formatter)
root_logger.addHandler(sysloghandler) root_logger.addHandler(sysloghandler)

View File

@@ -31,7 +31,7 @@ class BaseMailStorage(object):
self.name = name self.name = name
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
def add(self, queueid, mailfrom, recipients, headers, def add(self, qid, mailfrom, recipients, headers,
fp, subgroups=None, named_subgroups=None): fp, subgroups=None, named_subgroups=None):
"Add email to storage." "Add email to storage."
fp.seek(0) fp.seek(0)
@@ -73,16 +73,16 @@ class FileMailStorage(BaseMailStorage):
cfg[opt] = defaults[opt] cfg[opt] = defaults[opt]
else: else:
raise RuntimeError( raise RuntimeError(
"mandatory option '{}' not present in config section '{}' or 'global'".format( f"mandatory option '{opt}' not present in config "
opt, self.name)) f"section '{self.name}' or 'global'")
self.directory = cfg["storage_directory"] self.directory = cfg["storage_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( if not os.path.isdir(self.directory) or not os.access(
self.directory, os.W_OK): self.directory, os.W_OK):
raise RuntimeError( raise RuntimeError(
"file quarantine directory '{}' does not exist or is not writable".format( f"file quarantine directory '{self.directory}' does "
self.directory)) f"not exist or is not writable")
self._metadata_suffix = ".metadata" self._metadata_suffix = ".metadata"
def _save_datafile(self, storage_id, fp): def _save_datafile(self, storage_id, fp):
@@ -91,47 +91,46 @@ class FileMailStorage(BaseMailStorage):
with open(datafile, "wb") as f: with open(datafile, "wb") as f:
copyfileobj(fp, f) copyfileobj(fp, f)
except IOError as e: except IOError as e:
raise RuntimeError("unable save data file: {}".format(e)) raise RuntimeError(f"unable save data file: {e}")
def _save_metafile(self, storage_id, metadata): def _save_metafile(self, storage_id, metadata):
metafile = os.path.join( metafile = os.path.join(
self.directory, "{}{}".format( self.directory, f"{storage_id}{self._metadata_suffix}")
storage_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)
except IOError as e: except IOError as e:
raise RuntimeError("unable to save metadata file: {}".format(e)) raise RuntimeError(f"unable to save metadata file: {e}")
def _remove(self, storage_id): def _remove(self, storage_id):
datafile = os.path.join(self.directory, storage_id) datafile = os.path.join(self.directory, storage_id)
metafile = "{}{}".format(datafile, self._metadata_suffix) metafile = f"{datafile}{self._metadata_suffix}"
try: try:
os.remove(metafile) os.remove(metafile)
except IOError as e: except IOError as e:
raise RuntimeError("unable to remove metadata file: {}".format(e)) raise RuntimeError(f"unable to remove metadata file: {e}")
try: try:
os.remove(datafile) os.remove(datafile)
except IOError as e: except IOError as e:
raise RuntimeError("unable to remove data file: {}".format(e)) raise RuntimeError(f"unable to remove data file: {e}")
def add(self, queueid, mailfrom, recipients, headers, def add(self, qid, mailfrom, recipients, headers,
fp, subgroups=None, named_subgroups=None): fp, subgroups=None, named_subgroups=None):
"Add email to file storage and return storage id." "Add email to file storage and return storage id."
super( super(
FileMailStorage, FileMailStorage,
self).add( self).add(
queueid, qid,
mailfrom, mailfrom,
recipients, recipients,
headers, headers,
fp, fp,
subgroups, subgroups,
named_subgroups) named_subgroups)
storage_id = "{}_{}".format( timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
datetime.now().strftime("%Y%m%d%H%M%S"), queueid) storage_id = f"{timestamp}_{qid}"
# save mail # save mail
self._save_datafile(storage_id, fp) self._save_datafile(storage_id, fp)
@@ -142,7 +141,7 @@ class FileMailStorage(BaseMailStorage):
"recipients": recipients, "recipients": recipients,
"headers": headers, "headers": headers,
"date": timegm(gmtime()), "date": timegm(gmtime()),
"queue_id": queueid, "queue_id": qid,
"subgroups": subgroups, "subgroups": subgroups,
"named_subgroups": named_subgroups "named_subgroups": named_subgroups
} }
@@ -160,21 +159,19 @@ class FileMailStorage(BaseMailStorage):
super(FileMailStorage, self).get_metadata(storage_id) super(FileMailStorage, self).get_metadata(storage_id)
metafile = os.path.join( metafile = os.path.join(
self.directory, "{}{}".format( self.directory, f"{storage_id}{self._metadata_suffix}")
storage_id, self._metadata_suffix))
if not os.path.isfile(metafile): if not os.path.isfile(metafile):
raise RuntimeError( raise RuntimeError(
"invalid storage id '{}'".format(storage_id)) f"invalid storage id '{storage_id}'")
try: try:
with open(metafile, "r") as f: with open(metafile, "r") as f:
metadata = json.load(f) metadata = json.load(f)
except IOError as e: except IOError as e:
raise RuntimeError("unable to read metadata file: {}".format(e)) raise RuntimeError(f"unable to read metadata file: {e}")
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
raise RuntimeError( raise RuntimeError(
"invalid meta file '{}': {}".format( f"invalid metafile '{metafile}': {e}")
metafile, e))
return metadata return metadata
@@ -188,7 +185,7 @@ class FileMailStorage(BaseMailStorage):
emails = {} emails = {}
metafiles = glob(os.path.join( metafiles = glob(os.path.join(
self.directory, "*{}".format(self._metadata_suffix))) self.directory, f"*{self._metadata_suffix}"))
for metafile in metafiles: for metafile in metafiles:
if not os.path.isfile(metafile): if not os.path.isfile(metafile):
continue continue
@@ -208,7 +205,8 @@ class FileMailStorage(BaseMailStorage):
if len(recipients) == 1 and \ if len(recipients) == 1 and \
recipients[0] not in metadata["recipients"]: 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
emails[storage_id] = metadata emails[storage_id] = metadata
@@ -222,7 +220,7 @@ class FileMailStorage(BaseMailStorage):
try: try:
metadata = self.get_metadata(storage_id) metadata = self.get_metadata(storage_id)
except RuntimeError as e: except RuntimeError as e:
raise RuntimeError("unable to delete email: {}".format(e)) raise RuntimeError(f"unable to delete email: {e}")
if not recipients: if not recipients:
self._remove(storage_id) self._remove(storage_id)
@@ -231,7 +229,7 @@ class FileMailStorage(BaseMailStorage):
recipients = [recipients] recipients = [recipients]
for recipient in recipients: for recipient in recipients:
if recipient not in metadata["recipients"]: if recipient not in metadata["recipients"]:
raise RuntimeError("invalid recipient '{}'".format(recipient)) raise RuntimeError(f"invalid recipient '{recipient}'")
metadata["recipients"].remove(recipient) metadata["recipients"].remove(recipient)
if not metadata["recipients"]: if not metadata["recipients"]:
self._remove(storage_id) self._remove(storage_id)
@@ -246,7 +244,7 @@ class FileMailStorage(BaseMailStorage):
try: try:
fp = open(datafile, "rb") fp = open(datafile, "rb")
except IOError as e: except IOError as e:
raise RuntimeError("unable to open email data file: {}".format(e)) raise RuntimeError(f"unable to open email data file: {e}")
return (fp, metadata) return (fp, metadata)

View File

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

View File

@@ -15,7 +15,6 @@
import logging import logging
import peewee import peewee
import re import re
import sys
from datetime import datetime from datetime import datetime
from playhouse.db_url import connect from playhouse.db_url import connect
@@ -88,7 +87,8 @@ class DatabaseWhitelist(WhitelistBase):
defaults = {} defaults = {}
# check config # check config
for opt in ["whitelist_db_connection", "whitelist_db_table"] + list(defaults.keys()): for opt in ["whitelist_db_connection",
"whitelist_db_table"] + list(defaults.keys()):
if opt in cfg: if opt in cfg:
continue continue
if opt in global_cfg: if opt in global_cfg:
@@ -97,8 +97,8 @@ class DatabaseWhitelist(WhitelistBase):
cfg[opt] = defaults[opt] cfg[opt] = defaults[opt]
else: else:
raise RuntimeError( raise RuntimeError(
"mandatory option '{}' not present in config section '{}' or 'global'".format( f"mandatory option '{opt}' not present in config "
opt, self.name)) f"section '{self.name}' or 'global'")
tablename = cfg["whitelist_db_table"] tablename = cfg["whitelist_db_table"]
connection_string = cfg["whitelist_db_connection"] connection_string = cfg["whitelist_db_connection"]
@@ -108,16 +108,16 @@ class DatabaseWhitelist(WhitelistBase):
else: else:
try: try:
# connect to database # connect to database
self.logger.debug( conn = re.sub(
"connecting to database '{}'".format(
re.sub(
r"(.*?://.*?):.*?(@.*)", r"(.*?://.*?):.*?(@.*)",
r"\1:<PASSWORD>\2", r"\1:<PASSWORD>\2",
connection_string))) connection_string)
self.logger.debug(
f"connecting to database '{conn}'")
db = connect(connection_string) db = connect(connection_string)
except Exception as e: except Exception as e:
raise RuntimeError( raise RuntimeError(
"unable to connect to database: {}".format(e)) f"unable to connect to database: {e}")
DatabaseWhitelist._db_connections[connection_string] = db DatabaseWhitelist._db_connections[connection_string] = db
@@ -125,7 +125,7 @@ class DatabaseWhitelist(WhitelistBase):
self.meta = Meta self.meta = Meta
self.meta.database = db self.meta.database = db
self.meta.table_name = tablename self.meta.table_name = tablename
self.model = type("WhitelistModel_{}".format(self.name), (WhitelistModel,), { self.model = type(f"WhitelistModel_{self.name}", (WhitelistModel,), {
"Meta": self.meta "Meta": self.meta
}) })
@@ -139,8 +139,7 @@ class DatabaseWhitelist(WhitelistBase):
db.create_tables([self.model]) db.create_tables([self.model])
except Exception as e: except Exception as e:
raise RuntimeError( raise RuntimeError(
"unable to initialize table '{}': {}".format( f"unable to initialize table '{tablename}': {e}")
tablename, e))
def _entry_to_dict(self, entry): def _entry_to_dict(self, entry):
result = {} result = {}
@@ -170,17 +169,19 @@ class DatabaseWhitelist(WhitelistBase):
# generate list of possible mailfroms # generate list of possible mailfroms
self.logger.debug( self.logger.debug(
"query database for whitelist entries from <{}> to <{}>".format( f"query database for whitelist entries from <{mailfrom}> "
mailfrom, recipient)) f"to <{recipient}>")
mailfroms = [""] mailfroms = [""]
if "@" in mailfrom and not mailfrom.startswith("@"): if "@" in mailfrom and not mailfrom.startswith("@"):
mailfroms.append("@{}".format(mailfrom.split("@")[1])) domain = mailfrom.split("@")[1]
mailfroms.append(f"@{domain}")
mailfroms.append(mailfrom) mailfroms.append(mailfrom)
# generate list of possible recipients # generate list of possible recipients
recipients = [""] recipients = [""]
if "@" in recipient and not recipient.startswith("@"): if "@" in recipient and not recipient.startswith("@"):
recipients.append("@{}".format(recipient.split("@")[1])) domain = recipient.split("@")[1]
recipients.append(f"@{domain}")
recipients.append(recipient) recipients.append(recipient)
# query the database # query the database
@@ -190,7 +191,7 @@ class DatabaseWhitelist(WhitelistBase):
self.model.mailfrom.in_(mailfroms), self.model.mailfrom.in_(mailfroms),
self.model.recipient.in_(recipients))) self.model.recipient.in_(recipients)))
except Exception as e: except Exception as e:
raise RuntimeError("unable to query database: {}".format(e)) raise RuntimeError(f"unable to query database: {e}")
if not entries: if not entries:
# no whitelist entry found # no whitelist entry found
@@ -222,7 +223,8 @@ class DatabaseWhitelist(WhitelistBase):
try: try:
for entry in list(self.model.select()): for entry in list(self.model.select()):
if older_than is not None: if older_than is not None:
if (datetime.now() - entry.last_used).total_seconds() < (older_than * 86400): delta = (datetime.now() - entry.last_used).total_seconds()
if delta < (older_than * 86400):
continue continue
if mailfrom is not None: if mailfrom is not None:
@@ -235,7 +237,7 @@ class DatabaseWhitelist(WhitelistBase):
entries.update(self._entry_to_dict(entry)) entries.update(self._entry_to_dict(entry))
except Exception as e: except Exception as e:
raise RuntimeError("unable to query database: {}".format(e)) raise RuntimeError(f"unable to query database: {e}")
return entries return entries
@@ -256,7 +258,7 @@ class DatabaseWhitelist(WhitelistBase):
comment=comment, comment=comment,
permanent=permanent) permanent=permanent)
except Exception as e: except Exception as e:
raise RuntimeError("unable to add entry to database: {}".format(e)) raise RuntimeError(f"unable to add entry to database: {e}")
def delete(self, whitelist_id): def delete(self, whitelist_id):
"Delete entry from whitelist." "Delete entry from whitelist."
@@ -267,7 +269,7 @@ class DatabaseWhitelist(WhitelistBase):
deleted = query.execute() deleted = query.execute()
except Exception as e: except Exception as e:
raise RuntimeError( raise RuntimeError(
"unable to delete entry from database: {}".format(e)) f"unable to delete entry from database: {e}")
if deleted == 0: if deleted == 0:
raise RuntimeError("invalid whitelist id") raise RuntimeError("invalid whitelist id")
@@ -291,10 +293,11 @@ class WhitelistCache(object):
mailfrom, recipient) mailfrom, recipient)
return self.cache[whitelist][recipient] return self.cache[whitelist][recipient]
def get_whitelisted_recipients(self, whitelist, mailfrom, recipients): def get_recipients(self, whitelist, mailfrom, recipients):
self.load(whitelist, mailfrom, recipients) self.load(whitelist, mailfrom, recipients)
return list( return list(filter(
filter(lambda x: self.cache[whitelist][x], self.cache[whitelist].keys())) 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

2
setup.cfg Normal file
View File

@@ -0,0 +1,2 @@
[metadata]
version = attr: pyquarantine.__version__

View File

@@ -4,11 +4,8 @@ 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 = 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.",