18 Commits
2.0.0 ... 2.0.5

Author SHA1 Message Date
1130ec8e95 add dependency for pymilter version 1.5 or newer 2022-09-08 17:17:53 +02:00
5dd76e327c make use of pymilters new decode strategies 2022-09-08 17:11:04 +02:00
d5f030151f add PID to syslog messages 2022-09-08 16:58:57 +02:00
d7f8f40e03 python issue30681 is fixed with Python 3.10 2022-09-08 16:23:45 +02:00
ed5575bd2d change version to 2.0.5 2022-08-12 15:21:05 +02:00
0f4da248e7 fix installation of systemd service file
Signed-off-by: Thomas Oettli <spacefreak@noop.ch>
2022-08-12 15:18:34 +02:00
9e7106ff0b set stable and adjust gentoo ebuild 2022-08-12 15:18:34 +02:00
91144643f3 fix html code in text body when html part is injected 2022-08-12 15:09:30 +02:00
a4c2ec3952 change version to 2.0.4 2022-04-25 16:14:45 +02:00
375728e452 change version to 2.0.3 2022-04-25 16:05:18 +02:00
7c2bfda126 fix bug in quarantine whitelist 2022-04-25 14:21:35 +02:00
0b6724e656 change version to 2.0.2 2022-03-15 13:00:48 +01:00
3bedae77e1 further increase max recursion level 2022-03-15 11:15:15 +01:00
1e33e57cb3 remove unneeded comments 2022-03-14 11:50:32 +01:00
023a8412e8 fix body injection and exit if no recipients left 2022-03-14 11:49:32 +01:00
018e87f51f fix and improve injection of body parts 2022-03-04 15:46:37 +01:00
6146d377a1 change README.md 2022-01-11 22:56:55 +01:00
c4d4d2c5e7 change version to 2.0.1 2022-01-11 22:34:23 +01:00
11 changed files with 234 additions and 119 deletions

View File

@@ -120,7 +120,7 @@ Config options for conditions objects:
* **headers** (optional) * **headers** (optional)
Matches e-mails for which all regular expressions in the given list are matching at least one e-mail header. Matches e-mails for which all regular expressions in the given list are matching at least one e-mail header.
* **whitelist** (optional) * **whitelist** (optional)
Matches e-mails for which the whitelist has no entry for the envelope-from and envelope-to address combination, see section [Whitelist](#Whitelist). Matches e-mails for which the whitelist has no entry for the envelope-from and envelope-to address combination, see section [Whitelist](#Whitelist) for details.
* **var** (optional) * **var** (optional)
Matches e-mails for which a previous action or condition has set the given metavariable. Matches e-mails for which a previous action or condition has set the given metavariable.
* **metavar** (optional) * **metavar** (optional)
@@ -210,14 +210,14 @@ Options:
Quarantine e-mail. Quarantine e-mail.
Options: Options:
* **store** * **store**
Options for e-mail storage, see action **store** in section [Action types](#Action-types). Options for e-mail storage, see action [store](#store).
If the option **metadata** is not specificall set for this storage, it will be set to true. If the option **metadata** is not specificall set for this storage, it will be set to true.
* **smtp_host** * **smtp_host**
SMTP host used to release e-mails from quarantine. SMTP host used to release e-mails from quarantine.
* **smtp_port** * **smtp_port**
SMTP port used to release e-mails from quarantine. SMTP port used to release e-mails from quarantine.
* **notify** (optional) * **notify** (optional)
Options for e-mail notifications, see action **notify** in section [Action types](#Action-types). Options for e-mail notifications, see action [notify](#notify).
* **milter_action** (optional) * **milter_action** (optional)
Milter action to perform. If set, no further rules or actions will be processed. Milter action to perform. If set, no further rules or actions will be processed.
Please think carefully what you set here or your MTA may do something you do not want it to do. Please think carefully what you set here or your MTA may do something you do not want it to do.
@@ -231,7 +231,7 @@ Options:
* **reject_reason** (optional, default: "Message rejected") * **reject_reason** (optional, default: "Message rejected")
Reject message sent to MTA if milter_action is set to reject. Reject message sent to MTA if milter_action is set to reject.
* **whitelist** (optional) * **whitelist** (optional)
Options for a whitelist, see **whitelist** in section [Conditions](#Conditions). Options for a whitelist object, see section [Whitelist](#Whitelist).
### Storage types ### Storage types
Available storage types: Available storage types:

View File

@@ -2,7 +2,7 @@
# Distributed under the terms of the GNU General Public License v2 # Distributed under the terms of the GNU General Public License v2
EAPI=7 EAPI=7
PYTHON_COMPAT=( python3_{9,10} ) PYTHON_COMPAT=( python3_{9..10} )
DISTUTILS_USE_SETUPTOOLS=rdepend DISTUTILS_USE_SETUPTOOLS=rdepend
SCM="" SCM=""
@@ -37,7 +37,7 @@ RDEPEND="
lxml? ( dev-python/lxml[${PYTHON_USEDEP}] ) lxml? ( dev-python/lxml[${PYTHON_USEDEP}] )
dev-python/netaddr[${PYTHON_USEDEP}] dev-python/netaddr[${PYTHON_USEDEP}]
dev-python/peewee[${PYTHON_USEDEP}] dev-python/peewee[${PYTHON_USEDEP}]
dev-python/pymilter[${PYTHON_USEDEP}]" >=dev-python/pymilter-1.5[${PYTHON_USEDEP}]"
python_install_all() { python_install_all() {
distutils-r1_python_install_all distutils-r1_python_install_all

View File

@@ -1,6 +1,6 @@
#!/bin/bash #!/bin/bash
set -e set -e
set -x
PYTHON=$(which python) PYTHON=$(which python)
script_dir=$(dirname "$(readlink -f -- "$BASH_SOURCE")") script_dir=$(dirname "$(readlink -f -- "$BASH_SOURCE")")

View File

@@ -27,7 +27,7 @@ __all__ = [
"whitelist", "whitelist",
"QuarantineMilter"] "QuarantineMilter"]
__version__ = "2.0.0" __version__ = "2.0.5"
from pyquarantine import _runtime_patches from pyquarantine import _runtime_patches
@@ -100,7 +100,7 @@ class QuarantineMilter(Milter.Base):
self.logger.warning(f"unable to serialize message as bytes: {e}") self.logger.warning(f"unable to serialize message as bytes: {e}")
try: try:
self.logger.warning("try to serialize as str and encode") self.logger.warning("try to serialize as str and encode")
data = self.msg.as_string().encode("ascii", errors="replace") data = self.msg.as_string().encode(errors="replace")
except Exception as e: except Exception as e:
self.logger.error( self.logger.error(
f"unable to serialize message, giving up: {e}") f"unable to serialize message, giving up: {e}")
@@ -208,6 +208,7 @@ class QuarantineMilter(Milter.Base):
return Milter.CONTINUE return Milter.CONTINUE
@Milter.decode("replace")
def envfrom(self, mailfrom, *str): def envfrom(self, mailfrom, *str):
try: try:
self.mailfrom = "@".join(parse_addr(mailfrom)).lower() self.mailfrom = "@".join(parse_addr(mailfrom)).lower()
@@ -219,6 +220,7 @@ class QuarantineMilter(Milter.Base):
return Milter.CONTINUE return Milter.CONTINUE
@Milter.decode("replace")
def envrcpt(self, to, *str): def envrcpt(self, to, *str):
try: try:
self.rcpts.add("@".join(parse_addr(to)).lower()) self.rcpts.add("@".join(parse_addr(to)).lower())
@@ -243,6 +245,7 @@ class QuarantineMilter(Milter.Base):
return Milter.CONTINUE return Milter.CONTINUE
@Milter.decode("replace")
def header(self, field, value): def header(self, field, value):
try: try:
# remove CR and LF from address fields, otherwise pythons # remove CR and LF from address fields, otherwise pythons
@@ -258,11 +261,7 @@ class QuarantineMilter(Milter.Base):
v = v.replace("\r", "").replace("\n", "") v = v.replace("\r", "").replace("\n", "")
value = Header(s=v).encode() value = Header(s=v).encode()
# remove surrogates self.fp.write(field.encode() + b": " + value.encode() + b"\r\n")
field = field.encode("ascii", errors="replace")
value = value.encode("ascii", errors="replace")
self.fp.write(field + b": " + value + b"\r\n")
except Exception as e: except Exception as e:
self.logger.exception( self.logger.exception(
f"an exception occured in header method: {e}") f"an exception occured in header method: {e}")

View File

@@ -21,14 +21,18 @@ import shutil
import sys import sys
SYSTEMD_PATH = "/lib/systemd/system" SYSTEMD_PATHS = ["/lib/systemd/system", "/usr/lib/systemd/system"]
OPENRC = "/sbin/openrc" OPENRC = "/sbin/openrc"
def _systemd_files(pkg_dir, name): def _systemd_files(pkg_dir, name):
for path in SYSTEMD_PATHS:
if os.path.isdir(path):
break
return [ return [
(f"{pkg_dir}/misc/systemd/{name}-milter.service", (f"{pkg_dir}/misc/systemd/{name}-milter.service",
f"{SYSTEMD_PATH}/{name}-milter.service", True)] f"{path}/{name}-milter.service", True)]
def _openrc_files(pkg_dir, name): def _openrc_files(pkg_dir, name):
@@ -117,7 +121,11 @@ def _check_root():
def _check_systemd(): def _check_systemd():
systemd = os.path.isdir(SYSTEMD_PATH) for path in SYSTEMD_PATHS:
systemd = os.path.isdir(path)
if systemd:
break
if systemd: if systemd:
logging.info("systemd detected") logging.info("systemd detected")

View File

@@ -12,6 +12,7 @@
# along with pyquarantine. If not, see <http://www.gnu.org/licenses/>. # along with pyquarantine. If not, see <http://www.gnu.org/licenses/>.
# #
from sys import version_info
import encodings import encodings
@@ -150,27 +151,45 @@ def get_obs_local_part(value):
setattr(email._header_value_parser, "get_obs_local_part", get_obs_local_part) setattr(email._header_value_parser, "get_obs_local_part", get_obs_local_part)
# https://bugs.python.org/issue30681 #######################################
# add charset alias for windows-874 #
#######################################
# #
# fix: https://github.com/python/cpython/pull/22090 # https://bugs.python.org/issue17254
#
# fix: https://github.com/python/cpython/pull/10237
import email.errors aliases = encodings.aliases.aliases
from email.errors import HeaderDefect
for alias in ["windows-874", "windows_874"]:
if alias not in aliases:
aliases[alias] = "cp874"
setattr(encodings.aliases, "aliases", aliases)
class InvalidDateDefect(HeaderDefect): if version_info.major == 3 and version_info.minor < 10:
# https://bugs.python.org/issue30681
#
# fix: https://github.com/python/cpython/pull/22090
import email.errors
from email.errors import HeaderDefect
class InvalidDateDefect(HeaderDefect):
"""Header has unparseable or invalid date""" """Header has unparseable or invalid date"""
setattr(email.errors, "InvalidDateDefect", InvalidDateDefect) setattr(email.errors, "InvalidDateDefect", InvalidDateDefect)
import email.utils import email.utils
from email.utils import _parsedate_tz from email.utils import _parsedate_tz
import datetime import datetime
def parsedate_to_datetime(data): def parsedate_to_datetime(data):
parsed_date_tz = _parsedate_tz(data) parsed_date_tz = _parsedate_tz(data)
if parsed_date_tz is None: if parsed_date_tz is None:
raise ValueError('Invalid date value or format "%s"' % str(data)) raise ValueError('Invalid date value or format "%s"' % str(data))
@@ -181,14 +200,14 @@ def parsedate_to_datetime(data):
tzinfo=datetime.timezone(datetime.timedelta(seconds=tz))) tzinfo=datetime.timezone(datetime.timedelta(seconds=tz)))
setattr(email.utils, "parsedate_to_datetime", parsedate_to_datetime) setattr(email.utils, "parsedate_to_datetime", parsedate_to_datetime)
import email.headerregistry import email.headerregistry
from email import utils, _header_value_parser as parser from email import utils, _header_value_parser as parser
@classmethod @classmethod
def parse(cls, value, kwds): def parse(cls, value, kwds):
if not value: if not value:
kwds['defects'].append(errors.HeaderMissingRequiredValue()) kwds['defects'].append(errors.HeaderMissingRequiredValue())
kwds['datetime'] = None kwds['datetime'] = None
@@ -209,21 +228,4 @@ def parse(cls, value, kwds):
kwds['parse_tree'] = cls.value_parser(kwds['decoded']) kwds['parse_tree'] = cls.value_parser(kwds['decoded'])
setattr(email.headerregistry.DateHeader, "parse", parse) setattr(email.headerregistry.DateHeader, "parse", parse)
#######################################
# add charset alias for windows-874 #
#######################################
#
# https://bugs.python.org/issue17254
#
# fix: https://github.com/python/cpython/pull/10237
aliases = encodings.aliases.aliases
for alias in ["windows-874", "windows_874"]:
if alias not in aliases:
aliases[alias] = "cp874"
setattr(encodings.aliases, "aliases", aliases)

View File

@@ -65,6 +65,134 @@ class MilterMessage(MIMEPart):
self._headers = newheaders self._headers = newheaders
def _find_body_parent(self, part, preferencelist, parent=None):
if part.is_attachment():
return
maintype, subtype = part.get_content_type().split("/")
if maintype == "text":
if subtype in preferencelist:
yield(preferencelist.index(subtype), parent)
return
if maintype != "multipart" or not self.is_multipart():
return
if subtype != "related":
for subpart in part.iter_parts():
yield from self._find_body_parent(
subpart, preferencelist, part)
return
if 'related' in preferencelist:
yield(preferencelist.index('related'), parent)
candidate = None
start = part.get_param('start')
if start:
for subpart in part.iter_parts():
if subpart['content-id'] == start:
candidate = subpart
break
if candidate is None:
subparts = part.get_payload()
candidate = subparts[0] if subparts else None
if candidate is not None:
yield from self._find_body_parent(candidate, preferencelist, part)
def get_body_parent(self, preferencelist=("related", "html", "plain")):
best_prio = len(preferencelist)
body_parent = None
for prio, parent in self._find_body_parent(self, preferencelist):
if prio < best_prio:
best_prio = prio
body_parent = parent
if prio == 0:
break
return body_parent
def get_body_content(self, pref):
part = None
content = None
if not self.is_multipart() and \
self.get_content_type() == f"text/{pref}":
part = self
else:
part = self.get_body(preferencelist=(pref))
if part is not None:
content = part.get_content()
return (part, content)
def set_body(self, text_content=None, html_content=None):
parent = self.get_body_parent() or self
if "Content-Type" not in parent:
# set Content-Type header if not present, otherwise
# make_alternative and make_mixed skip the payload
parent["Content-Type"] = parent.get_content_type()
maintype, subtype = parent.get_content_type().split("/")
if not parent.is_multipart() or maintype != "multipart":
if maintype == "text" and subtype in ("html", "plain"):
parent.make_alternative()
maintype, subtype = ("multipart", "alternative")
else:
parent.make_mixed()
maintype, subtype = ("multipart", "mixed")
text_body = parent.get_body(preferencelist=("plain"))
html_body = parent.get_body(preferencelist=("html"))
if text_content is not None:
if text_body:
text_body.set_content(text_content)
else:
if not html_body or subtype == "alternative":
inject_body_part(parent, text_content)
else:
html_body.add_alternative(text_content)
text_body = parent.get_body(preferencelist=("plain"))
if html_content is not None:
if html_body:
html_body.set_content(html_content, subtype="html")
else:
if not text_body or subtype == "alternative":
inject_body_part(parent, html_content, subtype="html")
else:
text_body.add_alternative(html_content, subtype="html")
def inject_body_part(part, content, subtype="plain"):
parts = []
text_body = None
text_content = None
if subtype == "html":
text_body, text_content = part.get_body_content("plain")
for p in part.iter_parts():
if text_body and p == text_body:
continue
parts.append(p)
boundary = part.get_boundary()
p_subtype = part.get_content_subtype()
part.clear_content()
if text_content != None:
part.set_content(text_content)
part.add_alternative(content, subtype=subtype)
else:
part.set_content(content, subtype=subtype)
if part.get_content_subtype() != p_subtype:
if p_subtype == "alternative":
part.make_alternative()
elif p_subtype == "related":
part.make_related()
else:
part.make_mixed()
if boundary:
part.set_boundary(boundary)
for p in parts:
part.attach(p)
def replace_illegal_chars(string): def replace_illegal_chars(string):
"""Remove illegal characters from header values.""" """Remove illegal characters from header values."""

View File

@@ -183,26 +183,6 @@ def _has_content_before_body_tag(soup):
return False return False
def _inject_body(milter, text_content="", html_content=None):
if not milter.msg.is_multipart():
milter.msg.make_mixed()
attachments = []
for attachment in milter.msg.iter_attachments():
if "content-disposition" not in attachment:
attachment["Content-Disposition"] = "attachment"
attachments.append(attachment)
milter.msg.clear_content()
milter.msg.set_content(text_content)
if html_content is not None:
milter.msg.add_alternative(html_content, subtype="html")
milter.msg.make_mixed()
for attachment in attachments:
milter.msg.attach(attachment)
def _wrap_message(milter): def _wrap_message(milter):
attachment = MIMEPart(policy=SMTP) attachment = MIMEPart(policy=SMTP)
attachment.set_content(milter.msg_as_bytes(), attachment.set_content(milter.msg_as_bytes(),
@@ -248,29 +228,28 @@ class AddDisclaimer:
self.pretend = pretend self.pretend = pretend
def patch_message_body(self, milter, logger): def patch_message_body(self, milter, logger):
text_body, text_content = _get_body_content(milter.msg, "plain") text_body, text_content = milter.msg.get_body_content("plain")
html_body, html_content = _get_body_content(milter.msg, "html") html_body, html_content = milter.msg.get_body_content("html")
if text_content is None and html_content is None: if text_content is None and html_content is None:
logger.info("message contains only attachment(s), " logger.info("message contains no body, inject it")
"inject empty body")
if self.add_html_body: if self.add_html_body:
_inject_body(milter, "", "") milter.msg.set_body("", "")
html_body, html_content = _get_body_content(milter.msg, "html") html_body, html_content = milter.msg.get_body_content("html")
else: else:
_inject_body(milter, "") milter.msg.set_body("")
text_body, text_content = _get_body_content(milter.msg, "plain") text_body, text_content = milter.msg.get_body_content("plain")
if html_content is None and self.add_html_body: if html_content is None and self.add_html_body:
logger.info("inject html body part generated from plain body") logger.info("inject html body based on plain body")
header = '<meta http-equiv="Content-Type" content="text/html; ' \ header = '<meta http-equiv="Content-Type" content="text/html; ' \
'charset=utf-8">' 'charset=utf-8">'
html_text = re.sub(r"^(.*)$", r"\1<br/>", html_text = re.sub(r"^(.*)$", r"\1<br/>",
escape(text_content, quote=False), escape(text_content, quote=False),
flags=re.MULTILINE) flags=re.MULTILINE)
_inject_body(milter, text_content, f"{header}{html_text}") milter.msg.set_body(None, f"{header}{html_text}")
text_body, text_content = _get_body_content(milter.msg, "plain") text_body, text_content = milter.msg.get_body_content("plain")
html_body, html_content = _get_body_content(milter.msg, "html") html_body, html_content = milter.msg.get_body_content("html")
variables = defaultdict(str, milter.msginfo["vars"]) variables = defaultdict(str, milter.msginfo["vars"])
variables["ENVELOPE_FROM"] = escape( variables["ENVELOPE_FROM"] = escape(

View File

@@ -135,7 +135,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.setFormatter( sysloghandler.setFormatter(
logging.Formatter("pyquarantine: %(message)s")) logging.Formatter(f"{name}[%(process)d]: %(message)s"))
root_logger.addHandler(sysloghandler) root_logger.addHandler(sysloghandler)
logger.info("milter starting") logger.info("milter starting")
@@ -149,7 +149,7 @@ def main():
# increase the recursion limit so that BeautifulSoup can # increase the recursion limit so that BeautifulSoup can
# parse larger html content # parse larger html content
sys.setrecursionlimit(2000) sys.setrecursionlimit(4000)
rc = 0 rc = 0
try: try:

View File

@@ -549,7 +549,7 @@ class Quarantine:
if not rcpts: if not rcpts:
# all recipients whitelisted # all recipients whitelisted
return return
milter.msginfo["rcpts"] = rcpts milter.msginfo["rcpts"] = rcpts.copy()
if self._milter_action in ["REJECT", "DISCARD"]: if self._milter_action in ["REJECT", "DISCARD"]:
logger.info(f"quarantine message for {rcpts}") logger.info(f"quarantine message for {rcpts}")
@@ -563,6 +563,5 @@ class Quarantine:
if self._milter_action is not None: if self._milter_action is not None:
milter.delrcpt(rcpts) milter.delrcpt(rcpts)
if self._milter_action in ["ACCEPT", "REJECT"] and \ if not milter.msginfo["rcpts"]:
not milter.msginfo["rcpts"]:
return (self._milter_action, self._reason) return (self._milter_action, self._reason)

View File

@@ -18,7 +18,7 @@ setup(name = "pyquarantine",
# 3 - Alpha # 3 - Alpha
# 4 - Beta # 4 - Beta
# 5 - Production/Stable # 5 - Production/Stable
"Development Status :: 4 - Beta", "Development Status :: 5 - Production/Stable",
"License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
"Operating System :: OS Independent", "Operating System :: OS Independent",
"Programming Language :: Python", "Programming Language :: Python",
@@ -48,6 +48,6 @@ setup(name = "pyquarantine",
] ]
) )
], ],
install_requires = ["pymilter", "jsonschema", "netaddr", "beautifulsoup4[lxml]", "peewee"], install_requires = ["pymilter >= 1.5", "jsonschema", "netaddr", "beautifulsoup4[lxml]", "peewee"],
python_requires = ">=3.8" python_requires = ">=3.9"
) )