16 Commits
0.0.2 ... 0.0.9

8 changed files with 151 additions and 109 deletions

View File

@@ -45,7 +45,7 @@ The following configuration options are mandatory in each quarantine section:
* **whitelist_type**
One of the whitelist types described below.
* **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
@@ -77,11 +77,17 @@ The following configuration options are optional in each quarantine section:
The following template variables are available:
* **{EMAIL_ENVELOPE_FROM}**
E-mail from-address received by the milter.
E-mail sender address received by the milter.
* **{EMAIL_ENVELOPE_FROM_URL}**
Like EMAIL_ENVELOPE_FROM, but URL encoded
* **{EMAIL_FROM}**
Value of the from header of the original e-mail.
* **{EMAIL_TO}**
Value of the FROM header of the original e-mail.
* **{EMAIL_ENVELOPE_TO}**
E-mail recipient address of this notification.
* **{EMAIL_ENVELOPE_TO_URL}**
Like EMAIL_ENVELOPE_TO, but URL encoded
* **{EMAIL_TO}**
Value of the TO header of the original e-mail.
* **{EMAIL_SUBJECT}**
Configured e-mail subject.
* **{EMAIL_QUARANTINE_ID}**
@@ -97,6 +103,10 @@ The following configuration options are optional in each quarantine section:
Content of a named subgroup, 'subgroup_name' will be replaced by its name.
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 e-mail envelope from-address.
* **notification_email_from**

View File

@@ -88,6 +88,18 @@ reject_reason = Message rejected
#
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
# Notes: Set the envelope-from address used when sending notification emails.
# This option is needed by notification type 'email'.

View File

@@ -10,6 +10,10 @@
<td><b>From:</b></td>
<td>{EMAIL_FROM}</td>
</tr>
<tr>
<td><b>Envelope-To:</b></td>
<td>{EMAIL_ENVELOPE_TO}</td>
</tr>
<tr>
<td><b>To:</b></td>
<td>{EMAIL_TO}</td>

View File

@@ -95,7 +95,8 @@ class QuarantineMilter(Milter.Base):
def set_configfiles(config_files):
QuarantineMilter._config_files = config_files
def connect(self, IPname, family, hostaddr):
def connect(self, hostname, family, hostaddr):
self.hostaddr = hostaddr
self.logger.debug(
"accepted milter connection from {} port {}".format(
*hostaddr))
@@ -134,15 +135,33 @@ class QuarantineMilter(Milter.Base):
"{}: received queue-id from MTA".format(self.queueid))
self.recipients = list(self.recipients)
self.headers = []
self.logger.debug(
"{}: initializing memory buffer to save email data".format(
self.queueid))
# initialize memory buffer to save email data
self.fp = BytesIO()
return Milter.CONTINUE
@Milter.noreply
def header(self, name, value):
self.headers.append((name, value))
try:
# write email header to memory buffer
self.fp.write("{}: {}\r\n".format(name, value).encode(
encoding="ascii", errors="surrogateescape"))
# keep copy of header without surrogates for later use
self.headers.append((
name.encode(errors="surrogateescape").decode(errors="replace"),
value.encode(errors="surrogateescape").decode(errors="replace")))
except Exception as e:
self.logger.exception(
"an exception occured in header function: {}".format(e))
return Milter.TEMPFAIL
return Milter.CONTINUE
def eoh(self):
try:
self.fp.write("\r\n".encode(encoding="ascii"))
self.whitelist_cache = whitelists.WhitelistCache()
# initialize dicts to set quaranines per recipient and keep matches
@@ -238,26 +257,14 @@ class QuarantineMilter(Milter.Base):
self.queueid))
return Milter.ACCEPT
# check if the email body is needed
keep_body = False
# check if the mail body is needed
for recipient, quarantine in self.recipients_quarantines.items():
if quarantine["quarantine_obj"] or quarantine["notification_obj"]:
keep_body = True
break
# mail body is needed, continue processing
return Milter.CONTINUE
if keep_body:
self.logger.debug(
"{}: initializing memory buffer to save email data".format(
self.queueid))
# initialize memory buffer to save email data
self.fp = BytesIO()
# write email headers to memory buffer
for name, value in self.headers:
self.fp.write("{}: {}\n".format(name, value).encode())
self.fp.write("\n".encode())
else:
# quarantine and notification are disabled on all matching
# quarantines, return configured action
# quarantines, just return configured action
quarantine = self._get_preferred_quarantine()
self.logger.info(
"{}: {} matching quarantine is '{}', performing milter action {}".format(
@@ -269,8 +276,6 @@ class QuarantineMilter(Milter.Base):
self.setreply("554", "5.7.0", quarantine["reject_reason"])
return quarantine["milter_action"]
return Milter.CONTINUE
except Exception as e:
self.logger.exception(
"an exception occured in eoh function: {}".format(e))
@@ -372,6 +377,11 @@ class QuarantineMilter(Milter.Base):
"an exception occured in eom function: {}".format(e))
return Milter.TEMPFAIL
def close(self):
self.logger.debug(
"disconnect from {} port {}".format(
*self.hostaddr))
def generate_milter_config(configtest=False, config_files=[]):
"Generate the configuration for QuarantineMilter class."

View File

@@ -20,6 +20,8 @@ import logging.handlers
import sys
import time
from email.header import decode_header, make_header
import pyquarantine
from pyquarantine.version import __version__ as version
@@ -56,7 +58,7 @@ def print_table(columns, rows):
# get the length of the longest value
lengths.append(
len(str(max(rows, key=lambda x: len(str(x[key])))[key])))
# use the the longer one
# use the longer one
length = max(lengths)
column_lengths.append(length)
column_formats.append("{{:<{}}}".format(length))
@@ -118,7 +120,11 @@ def list_quarantine_emails(config, args):
metadata["date"]))
row["mailfrom"] = metadata["mailfrom"]
row["recipient"] = metadata["recipients"].pop(0)
row["subject"] = emails[quarantine_id]["headers"]["subject"][:60]
if "subject" not in emails[quarantine_id]["headers"].keys():
emails[quarantine_id]["headers"]["subject"] = ""
row["subject"] = str(make_header(decode_header(
emails[quarantine_id]["headers"]["subject"])))[:60].replace(
"\r", "").replace("\n", "").strip()
rows.append(row)
if metadata["recipients"]:

View File

@@ -26,6 +26,10 @@ process = None
def smtp_send(smtp_host, smtp_port, mailfrom, recipient, mail):
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.quit()

View File

@@ -19,10 +19,12 @@ import re
from bs4 import BeautifulSoup
from cgi import escape
from collections import defaultdict
from email.header import decode_header, make_header
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.image import MIMEImage
from os.path import basename
from urllib.parse import quote
from pyquarantine import mailer
@@ -115,8 +117,8 @@ class EMailNotification(BaseNotification):
# check if mandatory options are present in config
for option in [
"smtp_host",
"smtp_port",
"notification_email_smtp_host",
"notification_email_smtp_port",
"notification_email_envelope_from",
"notification_email_from",
"notification_email_subject",
@@ -142,8 +144,8 @@ class EMailNotification(BaseNotification):
if option not in config.keys():
config[option] = defaults[option]
self.smtp_host = self.config["smtp_host"]
self.smtp_port = self.config["smtp_port"]
self.smtp_host = self.config["notification_email_smtp_host"]
self.smtp_port = self.config["notification_email_smtp_port"]
self.mailfrom = self.config["notification_email_envelope_from"]
self.from_header = self.config["notification_email_from"]
self.subject = self.config["notification_email_subject"]
@@ -215,48 +217,44 @@ class EMailNotification(BaseNotification):
img.add_header("Content-ID", "<{}>".format(basename(img_path)))
self.embedded_imgs.append(img)
def get_text(self, queueid, part):
"Get the mail text in html form from email part."
mimetype = part.get_content_type()
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
if body is not None:
charset = body.get_content_charset() or "utf-8"
content = body.get_payload(decode=True)
try:
content = content.decode(encoding=charset, errors="replace")
except LookupError:
content = content.decode("utf-8", errors="replace")
content_type = body.get_content_type()
if content_type == EMailNotification._plain_text:
# convert text/plain to text/html
self.logger.debug(
"{}: extracting content of email text part".format(queueid))
text = part.get_payload(decode=True)
if mimetype == EMailNotification._plain_text:
self.logger.debug(
"{}: content mimetype is {}, converting to {}".format(
queueid, mimetype, self._html_text))
text = re.sub(r"^(.*)$", r"\1<br/>",
escape(text.decode()), flags=re.MULTILINE)
"{}: 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 mimetype is {}".format(
queueid, mimetype))
self.logger.debug(
"{}: trying to create BeatufilSoup object with parser lib {}, "
"text length is {} bytes".format(
queueid, self.parser_lib, len(text)))
soup = BeautifulSoup(text, self.parser_lib)
self.logger.debug(
"{}: sucessfully created BeautifulSoup object".format(queueid))
return soup
"{}: content type is {}".format(
queueid, content_type))
else:
self.logger.error(
"{}: unable to find email body".format(queueid))
content = "ERROR: unable to find email body"
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 is not None and mimetype == preferred:
break
return soup
return content
def sanitize(self, queueid, soup):
"Sanitize mail html text."
@@ -293,27 +291,6 @@ class EMailNotification(BaseNotification):
del(element.attrs[attribute])
return soup
def get_html_text_part(self, queueid, msg):
"Get the mail text of an email in html form."
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 is 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."
@@ -330,12 +307,19 @@ class EMailNotification(BaseNotification):
named_subgroups,
synchronous)
# extract html text from email
self.logger.debug(
"{}: extraction email text from original email".format(queueid))
soup = self.get_html_text_part(
# extract body from email
content = self.get_decoded_email_body(
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
image_replaced = False
if self.strip_images:
@@ -368,14 +352,26 @@ class EMailNotification(BaseNotification):
"{}: generating notification email for '{}'".format(
queueid, recipient))
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
variables = defaultdict(str,
EMAIL_HTML_TEXT=sanitized_text,
EMAIL_FROM=escape(headers["from"]),
EMAIL_FROM=escape(decoded_headers["from"]),
EMAIL_ENVELOPE_FROM=escape(mailfrom),
EMAIL_TO=escape(recipient),
EMAIL_SUBJECT=escape(headers["subject"]),
EMAIL_ENVELOPE_FROM_URL=escape(quote(mailfrom)),
EMAIL_TO=escape(decoded_headers["to"]),
EMAIL_ENVELOPE_TO=escape(recipient),
EMAIL_ENVELOPE_TO_URL=escape(quote(recipient)),
EMAIL_SUBJECT=escape(decoded_headers["subject"]),
EMAIL_QUARANTINE_ID=quarantine_id)
if subgroups:

View File

@@ -1 +1 @@
__version__ = "0.0.2"
__version__ = "0.0.9"