13 Commits
0.0.4 ... 0.0.9

6 changed files with 126 additions and 104 deletions

View File

@@ -77,11 +77,17 @@ The following configuration options are optional in each quarantine section:
The following template variables are available: The following template variables are available:
* **{EMAIL_ENVELOPE_FROM}** * **{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}** * **{EMAIL_FROM}**
Value of the from header of the original e-mail. Value of the FROM header of the original e-mail.
* **{EMAIL_TO}** * **{EMAIL_ENVELOPE_TO}**
E-mail recipient address of this notification. 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}** * **{EMAIL_SUBJECT}**
Configured e-mail subject. Configured e-mail subject.
* **{EMAIL_QUARANTINE_ID}** * **{EMAIL_QUARANTINE_ID}**

View File

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

View File

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

View File

@@ -20,6 +20,8 @@ import logging.handlers
import sys import sys
import time import time
from email.header import decode_header, make_header
import pyquarantine import pyquarantine
from pyquarantine.version import __version__ as version from pyquarantine.version import __version__ as version
@@ -56,7 +58,7 @@ def print_table(columns, rows):
# get the length of the longest value # get the length of the longest value
lengths.append( lengths.append(
len(str(max(rows, key=lambda x: len(str(x[key])))[key]))) len(str(max(rows, key=lambda x: len(str(x[key])))[key])))
# use the 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("{{:<{}}}".format(length))
@@ -118,7 +120,11 @@ def list_quarantine_emails(config, args):
metadata["date"])) 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" 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) rows.append(row)
if metadata["recipients"]: if metadata["recipients"]:

View File

@@ -19,10 +19,12 @@ 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.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
from os.path import basename from os.path import basename
from urllib.parse import quote
from pyquarantine import mailer from pyquarantine import mailer
@@ -215,48 +217,44 @@ 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_text(self, queueid, part): def get_decoded_email_body(self, queueid, msg, preferred=_html_text):
"Get the mail text in html form from email part." "Find and decode email body."
mimetype = part.get_content_type() # 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
self.logger.debug( if body is not None:
"{}: extracting content of email text part".format(queueid)) charset = body.get_content_charset() or "utf-8"
text = part.get_payload(decode=True) content = body.get_payload(decode=True)
try:
if mimetype == EMailNotification._plain_text: content = content.decode(encoding=charset, errors="replace")
self.logger.debug( except LookupError:
"{}: content mimetype is {}, converting to {}".format( content = content.decode("utf-8", errors="replace")
queueid, mimetype, self._html_text)) content_type = body.get_content_type()
text = re.sub(r"^(.*)$", r"\1<br/>", if content_type == EMailNotification._plain_text:
escape(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( self.logger.error(
"{}: content mimetype is {}".format( "{}: unable to find email body".format(queueid))
queueid, mimetype)) content = "ERROR: unable to find email body"
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
def get_text_multipart(self, queueid, msg, preferred=_html_text): return content
"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
def sanitize(self, queueid, soup): def sanitize(self, queueid, soup):
"Sanitize mail html text." "Sanitize mail html text."
@@ -293,27 +291,6 @@ class EMailNotification(BaseNotification):
del(element.attrs[attribute]) del(element.attrs[attribute])
return soup 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, def notify(self, queueid, quarantine_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."
@@ -330,12 +307,19 @@ class EMailNotification(BaseNotification):
named_subgroups, named_subgroups,
synchronous) synchronous)
# extract html text from email # extract body from email
self.logger.debug( content = self.get_decoded_email_body(
"{}: extraction email text from original email".format(queueid))
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.strip_images: if self.strip_images:
@@ -368,14 +352,26 @@ class EMailNotification(BaseNotification):
"{}: generating notification email for '{}'".format( "{}: generating notification email for '{}'".format(
queueid, recipient)) queueid, recipient))
self.logger.debug("{}: parsing email template".format(queueid)) 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(headers["from"]), EMAIL_FROM=escape(decoded_headers["from"]),
EMAIL_ENVELOPE_FROM=escape(mailfrom), EMAIL_ENVELOPE_FROM=escape(mailfrom),
EMAIL_TO=escape(recipient), EMAIL_ENVELOPE_FROM_URL=escape(quote(mailfrom)),
EMAIL_SUBJECT=escape(headers["subject"]), 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) EMAIL_QUARANTINE_ID=quarantine_id)
if subgroups: if subgroups:

View File

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