diff --git a/pymodmilter/__init__.py b/pymodmilter/__init__.py
index 5223a7a..f54d680 100644
--- a/pymodmilter/__init__.py
+++ b/pymodmilter/__init__.py
@@ -16,12 +16,13 @@ __all__ = [
"actions",
"base",
"conditions",
+ "modifications",
"rules",
"run",
"ModifyMilterConfig",
"ModifyMilter"]
-__version__ = "1.1.7"
+__version__ = "1.2.0"
from pymodmilter import _runtime_patches
@@ -327,18 +328,15 @@ class ModifyMilter(Milter.Base):
def eom(self):
try:
- # setup msg and msg_info to be read/modified by rules and actions
+ # msg and msginfo contain the runtime data that
+ # is read/modified by actions
self.fp.seek(0)
self.msg = message_from_binary_file(
self.fp, _class=MilterMessage, policy=SMTPUTF8.clone(
refold_source='none'))
- self.msg_info = defaultdict(str)
- self.msg_info["ip"] = self.IP
- self.msg_info["port"] = self.port
- self.msg_info["heloname"] = self.heloname
- self.msg_info["envfrom"] = self.mailfrom
- self.msg_info["rcpts"] = self.rcpts
- self.msg_info["qid"] = self.qid
+ self.msginfo = {
+ "mailfrom": self.mailfrom,
+ "rcpts": self.rcpts}
self._replacebody = False
milter_action = None
diff --git a/pymodmilter/actions.py b/pymodmilter/actions.py
index 09733ef..ebe7d78 100644
--- a/pymodmilter/actions.py
+++ b/pymodmilter/actions.py
@@ -13,328 +13,17 @@
#
__all__ = [
- "add_header",
- "mod_header",
- "del_header",
- "add_disclaimer",
- "rewrite_links",
- "store",
"ActionConfig",
"Action"]
-import logging
import os
import re
-from base64 import b64encode
from bs4 import BeautifulSoup
-from collections import defaultdict
-from copy import copy
-from datetime import datetime
-from email.message import MIMEPart
from pymodmilter import CustomLogger, BaseConfig
from pymodmilter.conditions import ConditionsConfig, Conditions
-from pymodmilter import replace_illegal_chars
-
-
-def add_header(milter, field, value, pretend=False,
- logger=logging.getLogger(__name__)):
- """Add a mail header field."""
- header = f"{field}: {value}"
- if logger.getEffectiveLevel() == logging.DEBUG:
- logger.debug(f"add_header: {header}")
- else:
- logger.info(f"add_header: {header[0:70]}")
-
- milter.msg.add_header(field, replace_illegal_chars(value))
-
- if not pretend:
- milter.addheader(field, value)
-
-
-def mod_header(milter, field, value, search=None, pretend=False,
- logger=logging.getLogger(__name__)):
- """Change the value of a mail header field."""
- if isinstance(field, str):
- field = re.compile(field, re.IGNORECASE)
-
- if isinstance(search, str):
- search = re.compile(search, re.MULTILINE + re.DOTALL + re.IGNORECASE)
-
- idx = defaultdict(int)
-
- for i, (f, v) in enumerate(milter.msg.items()):
- f_lower = f.lower()
- idx[f_lower] += 1
-
- if not field.match(f):
- continue
-
- new_value = v
- if search is not None:
- new_value = search.sub(value, v).strip()
- else:
- new_value = value
-
- if not new_value:
- logger.warning(
- "mod_header: resulting value is empty, "
- "skip modification")
- continue
-
- if new_value == v:
- continue
-
- header = f"{f}: {v}"
- new_header = f"{f}: {new_value}"
-
- if logger.getEffectiveLevel() == logging.DEBUG:
- logger.debug(f"mod_header: {header}: {new_header}")
- else:
- logger.info(f"mod_header: {header[0:70]}: {new_header[0:70]}")
-
- milter.msg.replace_header(
- f, replace_illegal_chars(new_value), idx=idx[f_lower])
-
- if not pretend:
- milter.chgheader(f, new_value, idx=idx[f_lower])
-
-
-def del_header(milter, field, value=None, pretend=False,
- logger=logging.getLogger(__name__)):
- """Delete a mail header field."""
- if isinstance(field, str):
- field = re.compile(field, re.IGNORECASE)
-
- if isinstance(value, str):
- value = re.compile(value, re.MULTILINE + re.DOTALL + re.IGNORECASE)
-
- idx = defaultdict(int)
-
- for f, v in milter.msg.items():
- f_lower = f.lower()
- idx[f_lower] += 1
-
- if not field.match(f):
- continue
-
- if value is not None and not value.search(v):
- continue
-
- header = f"{f}: {v}"
- if logger.getEffectiveLevel() == logging.DEBUG:
- logger.debug(f"del_header: {header}")
- else:
- logger.info(f"del_header: {header[0:70]}")
- milter.msg.remove_header(f, idx=idx[f_lower])
-
- if not pretend:
- milter.chgheader(f, "", idx=idx[f_lower])
-
- idx[f_lower] -= 1
-
-
-def _get_body_content(msg, pref):
- part = None
- content = None
- if not msg.is_multipart() and msg.get_content_type() == f"text/{pref}":
- part = msg
- else:
- part = msg.get_body(preferencelist=(pref))
-
- if part is not None:
- content = part.get_content()
-
- return (part, content)
-
-
-def _has_content_before_body_tag(soup):
- s = copy(soup)
- for element in s.find_all("head") + s.find_all("body"):
- element.extract()
-
- if len(s.text.strip()) > 0:
- return True
-
- return False
-
-
-def _patch_message_body(milter, action, text_template, html_template, logger):
- text_body, text_content = _get_body_content(milter.msg, "plain")
- html_body, html_content = _get_body_content(milter.msg, "html")
-
- if text_content is None and html_content is None:
- raise RuntimeError("message does not contain any body part")
-
- if text_content is not None:
- logger.info(f"{action} text disclaimer")
-
- if action == "prepend":
- content = f"{text_template}{text_content}"
- else:
- content = f"{text_content}{text_template}"
-
- text_body.set_content(
- content.encode(), maintype="text", subtype="plain")
- text_body.set_param("charset", "UTF-8", header="Content-Type")
- del text_body["MIME-Version"]
-
- if html_content is not None:
- logger.info(f"{action} html disclaimer")
-
- soup = BeautifulSoup(html_content, "html.parser")
-
- body = soup.find('body')
- if not body:
- body = soup
- elif _has_content_before_body_tag(soup):
- body = soup
-
- if action == "prepend":
- body.insert(0, copy(html_template))
- else:
- body.append(html_template)
-
- html_body.set_content(
- str(soup).encode(), maintype="text", subtype="html")
- html_body.set_param("charset", "UTF-8", header="Content-Type")
- del html_body["MIME-Version"]
-
-
-def _wrap_message(milter, logger):
- attachment = MIMEPart()
- attachment.set_content(milter.msg.as_bytes(),
- maintype="plain", subtype="text",
- disposition="attachment",
- filename=f"{milter.qid}.eml",
- params={"name": f"{milter.qid}.eml"})
-
- milter.msg.clear_content()
- milter.msg.set_content(
- "Please see the original email attached.")
- milter.msg.add_alternative(
- "
Please see the original email attached.",
- subtype="html")
- milter.msg.make_mixed()
- milter.msg.attach(attachment)
-
-
-def _inject_body(milter):
- 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("")
- milter.msg.add_alternative("", subtype="html")
- milter.msg.make_mixed()
-
- for attachment in attachments:
- milter.msg.attach(attachment)
-
-
-def add_disclaimer(milter, text_template, html_template, action, error_policy,
- pretend=False, logger=logging.getLogger(__name__)):
- """Append or prepend a disclaimer to the mail body."""
- old_headers = milter.msg.items()
-
- try:
- try:
- _patch_message_body(
- milter, action, text_template, html_template, logger)
- except RuntimeError as e:
- logger.info(f"{e}, inject empty plain and html body")
- _inject_body(milter)
- _patch_message_body(
- milter, action, text_template, html_template, logger)
- except Exception as e:
- logger.warning(e)
- if error_policy == "ignore":
- logger.info(
- "unable to add disclaimer to message body, "
- "ignore error according to policy")
- return
- elif error_policy == "reject":
- logger.info(
- "unable to add disclaimer to message body, "
- "reject message according to policy")
- return [
- ("reject", "Message rejected due to error")]
-
- logger.info("wrap original message in a new message envelope")
- try:
- _wrap_message(milter, logger)
- _patch_message_body(
- milter, action, text_template, html_template, logger)
- except Exception as e:
- logger.error(e)
- raise Exception(
- "unable to wrap message in a new message envelope, "
- "give up ...")
-
- if not pretend:
- milter.update_headers(old_headers)
- milter.replacebody()
-
-
-def rewrite_links(milter, repl, pretend=False,
- logger=logging.getLogger(__name__)):
- """Rewrite link targets in the mail html body."""
-
- html_body, html_content = _get_body_content(milter.msg, "html")
- if html_content is not None:
- soup = BeautifulSoup(html_content, "html.parser")
-
- rewritten = 0
- for link in soup.find_all("a", href=True):
- if not link["href"]:
- continue
-
- if "{URL_B64}" in repl:
- url_b64 = b64encode(link["href"].encode()).decode()
- target = repl.replace("{URL_B64}", url_b64)
- else:
- target = repl
-
- link["href"] = target
- rewritten += 1
-
- if rewritten:
- logger.info(f"rewrote {rewritten} link(s) in html body")
-
- html_body.set_content(
- str(soup).encode(), maintype="text", subtype="html")
- html_body.set_param("charset", "UTF-8", header="Content-Type")
- del html_body["MIME-Version"]
-
- if not pretend:
- milter.replacebody()
-
-
-def store(milter, directory, original=False, pretend=False,
- logger=logging.getLogger(__name__)):
- timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
- store_id = f"{timestamp}_{milter.qid}"
- datafile = os.path.join(directory, store_id)
-
- logger.info(f"store message in file {datafile}")
-
- if not pretend:
- try:
- with open(datafile, "wb") as fp:
- if original:
- milter.fp.seek(0)
- fp.write(milter.fp.read())
- else:
- fp.write(milter.msg.as_bytes())
- except IOError as e:
- raise RuntimeError(f"unable to store message: {e}")
+from pymodmilter import modifications, storages
class ActionConfig(BaseConfig):
@@ -369,7 +58,7 @@ class ActionConfig(BaseConfig):
self["type"] = cfg["type"]
if self["type"] == "add_header":
- self["func"] = add_header
+ self["class"] = modifications.AddHeader
self["headersonly"] = True
if "field" not in cfg and "header" in cfg:
@@ -378,7 +67,7 @@ class ActionConfig(BaseConfig):
self.add_string_arg(cfg, ("field", "value"))
elif self["type"] == "mod_header":
- self["func"] = mod_header
+ self["class"] = modifications.ModHeader
self["headersonly"] = True
if "field" not in cfg and "header" in cfg:
@@ -399,7 +88,7 @@ class ActionConfig(BaseConfig):
raise ValueError(f"{self['name']}: {arg}: {e}")
elif self["type"] == "del_header":
- self["func"] = del_header
+ self["class"] = modifications.DelHeader
self["headersonly"] = True
if "field" not in cfg and "header" in cfg:
@@ -419,7 +108,7 @@ class ActionConfig(BaseConfig):
raise ValueError(f"{self['name']}: {arg}: {e}")
elif self["type"] == "add_disclaimer":
- self["func"] = add_disclaimer
+ self["class"] = modifications.AddDisclaimer
self["headersonly"] = False
if "html_template" not in cfg and "html_file" in cfg:
@@ -460,12 +149,11 @@ class ActionConfig(BaseConfig):
f"{self['name']}: unable to open/read template file: {e}")
elif self["type"] == "rewrite_links":
- self["func"] = rewrite_links
+ self["class"] = modifications.RewriteLinks
self["headersonly"] = False
self.add_string_arg(cfg, "repl")
elif self["type"] == "store":
- self["func"] = store
self["headersonly"] = False
assert "storage_type" in cfg, \
@@ -479,7 +167,15 @@ class ActionConfig(BaseConfig):
self.add_bool_arg(cfg, "original")
if self["storage_type"] == "file":
+ self["class"] = storages.FileMailStorage
self.add_string_arg(cfg, "directory")
+ # check if directory exists and is writable
+ if not os.path.isdir(self["args"]["directory"]) or \
+ not os.access(self["args"]["directory"], os.W_OK):
+ raise RuntimeError(
+ f"{self['name']}: file quarantine directory "
+ f"'{self['directory']}' does not exist or is "
+ f"not writable")
else:
raise RuntimeError(
f"{self['name']}: storage_type: invalid storage type")
@@ -512,8 +208,7 @@ class Action:
self.pretend = cfg["pretend"]
self._name = cfg["name"]
- self._func = cfg["func"]
- self._args = cfg["args"]
+ self._class = cfg["class"](**cfg["args"])
self._headersonly = cfg["headersonly"]
def headersonly(self):
@@ -529,5 +224,5 @@ class Action:
self.conditions.match(envfrom=milter.mailfrom,
envto=[*milter.rcpts],
headers=milter.msg.items()):
- return self._func(milter=milter, pretend=self.pretend,
- logger=logger, **self._args)
+ return self._class.execute(
+ milter=milter, pretend=self.pretend, logger=logger)
diff --git a/pymodmilter/modifications.py b/pymodmilter/modifications.py
new file mode 100644
index 0000000..0f398af
--- /dev/null
+++ b/pymodmilter/modifications.py
@@ -0,0 +1,320 @@
+# PyMod-Milter is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# PyMod-Milter is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with PyMod-Milter. If not, see .
+#
+
+__all__ = [
+ "AddHeader",
+ "ModHeader",
+ "DelHeader",
+ "AddDisclaimer",
+ "RewriteLinks"]
+
+import logging
+
+from base64 import b64encode
+from bs4 import BeautifulSoup
+from collections import defaultdict
+from copy import copy
+from email.message import MIMEPart
+
+from pymodmilter import replace_illegal_chars
+
+
+class AddHeader:
+ """Add a mail header field."""
+ def __init__(self, field, value):
+ self.field = field
+ self.value = value
+
+ def execute(self, milter, pretend=False,
+ logger=logging.getLogger(__name__)):
+ header = f"{self.field}: {self.value}"
+ if logger.getEffectiveLevel() == logging.DEBUG:
+ logger.debug(f"add_header: {header}")
+ else:
+ logger.info(f"add_header: {header[0:70]}")
+
+ milter.msg.add_header(self.field, self.value)
+
+ if not pretend:
+ milter.addheader(self.field, self.value)
+
+
+class ModHeader:
+ """Change the value of a mail header field."""
+ def __init__(self, field, value, search=None):
+ self.field = field
+ self.value = value
+ self.search = search
+
+ def execute(self, milter, pretend=False,
+ logger=logging.getLogger(__name__)):
+ idx = defaultdict(int)
+
+ for i, (field, value) in enumerate(milter.msg.items()):
+ field_lower = field.lower()
+ idx[field_lower] += 1
+
+ if not self.field.match(field):
+ continue
+
+ new_value = value
+ if self.search is not None:
+ new_value = self.search.sub(self.value, value).strip()
+ else:
+ new_value = self.value
+
+ if not new_value:
+ logger.warning(
+ "mod_header: resulting value is empty, "
+ "skip modification")
+ continue
+
+ if new_value == value:
+ continue
+
+ header = f"{field}: {value}"
+ new_header = f"{field}: {new_value}"
+
+ if logger.getEffectiveLevel() == logging.DEBUG:
+ logger.debug(f"mod_header: {header}: {new_header}")
+ else:
+ logger.info(f"mod_header: {header[0:70]}: {new_header[0:70]}")
+
+ milter.msg.replace_header(
+ field, replace_illegal_chars(new_value), idx=idx[field_lower])
+
+ if not pretend:
+ milter.chgheader(field, new_value, idx=idx[field_lower])
+
+
+class DelHeader:
+ """Delete a mail header field."""
+ def __init__(self, field, value=None):
+ self.field = field
+ self.value = value
+
+ def execute(self, milter, pretend=False,
+ logger=logging.getLogger(__name__)):
+ idx = defaultdict(int)
+
+ for field, value in milter.msg.items():
+ field_lower = field.lower()
+ idx[field_lower] += 1
+
+ if not self.field.match(field):
+ continue
+
+ if self.value is not None and not self.value.search(value):
+ continue
+
+ header = f"{field}: {value}"
+ if logger.getEffectiveLevel() == logging.DEBUG:
+ logger.debug(f"del_header: {header}")
+ else:
+ logger.info(f"del_header: {header[0:70]}")
+ milter.msg.remove_header(field, idx=idx[field_lower])
+
+ if not pretend:
+ milter.chgheader(field, "", idx=idx[field_lower])
+
+ idx[field_lower] -= 1
+
+
+def _get_body_content(msg, pref):
+ part = None
+ content = None
+ if not msg.is_multipart() and msg.get_content_type() == f"text/{pref}":
+ part = msg
+ else:
+ part = msg.get_body(preferencelist=(pref))
+
+ if part is not None:
+ content = part.get_content()
+
+ return (part, content)
+
+
+def _has_content_before_body_tag(soup):
+ s = copy(soup)
+ for element in s.find_all("head") + s.find_all("body"):
+ element.extract()
+
+ if len(s.text.strip()) > 0:
+ return True
+
+ return False
+
+
+def _inject_body(milter):
+ 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("")
+ milter.msg.add_alternative("", subtype="html")
+ milter.msg.make_mixed()
+
+ for attachment in attachments:
+ milter.msg.attach(attachment)
+
+
+def _wrap_message(milter):
+ attachment = MIMEPart()
+ attachment.set_content(milter.msg.as_bytes(),
+ maintype="plain", subtype="text",
+ disposition="attachment",
+ filename=f"{milter.qid}.eml",
+ params={"name": f"{milter.qid}.eml"})
+
+ milter.msg.clear_content()
+ milter.msg.set_content(
+ "Please see the original email attached.")
+ milter.msg.add_alternative(
+ "Please see the original email attached.",
+ subtype="html")
+ milter.msg.make_mixed()
+ milter.msg.attach(attachment)
+
+
+class AddDisclaimer:
+ """Append or prepend a disclaimer to the mail body."""
+ def __init__(self, text_template, html_template, action, error_policy):
+ self.text_template = text_template
+ self.html_template = html_template
+ self.action = action
+ self.error_policy = error_policy
+
+ def patch_message_body(self, milter, logger):
+ text_body, text_content = _get_body_content(milter.msg, "plain")
+ html_body, html_content = _get_body_content(milter.msg, "html")
+
+ if text_content is None and html_content is None:
+ raise RuntimeError("message does not contain any body part")
+
+ if text_content is not None:
+ logger.info(f"{self.action} text disclaimer")
+
+ if self.action == "prepend":
+ content = f"{self.text_template}{text_content}"
+ else:
+ content = f"{text_content}{self.text_template}"
+
+ text_body.set_content(
+ content.encode(), maintype="text", subtype="plain")
+ text_body.set_param("charset", "UTF-8", header="Content-Type")
+ del text_body["MIME-Version"]
+
+ if html_content is not None:
+ logger.info(f"{self.action} html disclaimer")
+
+ soup = BeautifulSoup(html_content, "html.parser")
+
+ body = soup.find('body')
+ if not body:
+ body = soup
+ elif _has_content_before_body_tag(soup):
+ body = soup
+
+ if self.action == "prepend":
+ body.insert(0, copy(self.html_template))
+ else:
+ body.append(self.html_template)
+
+ html_body.set_content(
+ str(soup).encode(), maintype="text", subtype="html")
+ html_body.set_param("charset", "UTF-8", header="Content-Type")
+ del html_body["MIME-Version"]
+
+ def execute(self, milter, pretend=False,
+ logger=logging.getLogger(__name__)):
+ old_headers = milter.msg.items()
+
+ try:
+ try:
+ self.patch_message_body(milter, logger)
+ except RuntimeError as e:
+ logger.info(f"{e}, inject empty plain and html body")
+ _inject_body(milter)
+ self.patch_message_body(milter, logger)
+
+ except Exception as e:
+ logger.warning(e)
+ if self.error_policy == "ignore":
+ logger.info(
+ "unable to add disclaimer to message body, "
+ "ignore error according to policy")
+ return
+ elif self.error_policy == "reject":
+ logger.info(
+ "unable to add disclaimer to message body, "
+ "reject message according to policy")
+ return [
+ ("reject", "Message rejected due to error")]
+
+ logger.info("wrap original message in a new message envelope")
+ try:
+ _wrap_message(milter)
+ self.patch_message_body(milter, logger)
+ except Exception as e:
+ logger.error(e)
+ raise Exception(
+ "unable to wrap message in a new message envelope, "
+ "give up ...")
+
+ if not pretend:
+ milter.update_headers(old_headers)
+ milter.replacebody()
+
+
+class RewriteLinks:
+ """Rewrite link targets in the mail html body."""
+ def __init__(self, repl):
+ self.repl = repl
+
+ def execute(self, milter, pretend=False,
+ logger=logging.getLogger(__name__)):
+ html_body, html_content = _get_body_content(milter.msg, "html")
+ if html_content is not None:
+ soup = BeautifulSoup(html_content, "html.parser")
+
+ rewritten = 0
+ for link in soup.find_all("a", href=True):
+ if not link["href"]:
+ continue
+
+ if "{URL_B64}" in self.repl:
+ url_b64 = b64encode(link["href"].encode()).decode()
+ target = self.repl.replace("{URL_B64}", url_b64)
+ else:
+ target = self.repl
+
+ link["href"] = target
+ rewritten += 1
+
+ if rewritten:
+ logger.info(f"rewrote {rewritten} link(s) in html body")
+
+ html_body.set_content(
+ str(soup).encode(), maintype="text", subtype="html")
+ html_body.set_param("charset", "UTF-8", header="Content-Type")
+ del html_body["MIME-Version"]
+
+ if not pretend:
+ milter.replacebody()
diff --git a/pymodmilter/storages.py b/pymodmilter/storages.py
new file mode 100644
index 0000000..8376cbd
--- /dev/null
+++ b/pymodmilter/storages.py
@@ -0,0 +1,226 @@
+# PyMod-Milter is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# PyMod-Milter is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with PyMod-Milter. If not, see .
+#
+
+import json
+import logging
+import os
+
+from calendar import timegm
+from datetime import datetime
+from glob import glob
+from time import gmtime
+
+
+class BaseMailStorage(object):
+ "Mail storage base class"
+ def __init__(self):
+ return
+
+ def add(self, data, qid, mailfrom="", recipients=[]):
+ "Add email to storage."
+ return ("", "")
+
+ def find(self, mailfrom=None, recipients=None, older_than=None):
+ "Find emails in storage."
+ return
+
+ def get_metadata(self, storage_id):
+ "Return metadata of email in storage."
+ return
+
+ def delete(self, storage_id, recipients=None):
+ "Delete email from storage."
+ return
+
+ def get_mail(self, storage_id):
+ "Return email and metadata."
+ return
+
+
+class FileMailStorage(BaseMailStorage):
+ "Storage class to store mails on filesystem."
+ def __init__(self, directory, original=False):
+ super().__init__()
+ self.directory = directory
+ self.original = original
+ self._metadata_suffix = ".metadata"
+
+ def _save_datafile(self, storage_id, data):
+ datafile = os.path.join(self.directory, storage_id)
+ try:
+ with open(datafile, "wb") as f:
+ f.write(data)
+ except IOError as e:
+ raise RuntimeError(f"unable save data file: {e}")
+
+ return datafile
+
+ def _save_metafile(self, storage_id, metadata):
+ metafile = os.path.join(
+ self.directory, f"{storage_id}{self._metadata_suffix}")
+ try:
+ with open(metafile, "w") as f:
+ json.dump(metadata, f, indent=2)
+ except IOError as e:
+ raise RuntimeError(f"unable to save metadata file: {e}")
+
+ def _remove(self, storage_id):
+ datafile = os.path.join(self.directory, storage_id)
+ metafile = f"{datafile}{self._metadata_suffix}"
+
+ try:
+ os.remove(metafile)
+ except IOError as e:
+ raise RuntimeError(f"unable to remove metadata file: {e}")
+
+ try:
+ os.remove(datafile)
+ except IOError as e:
+ raise RuntimeError(f"unable to remove data file: {e}")
+
+ def add(self, data, qid, mailfrom="", recipients=[], subject=""):
+ "Add email to file storage and return storage id."
+ super().add(data, qid, mailfrom, recipients)
+ timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
+ storage_id = f"{timestamp}_{qid}"
+
+ # save mail
+ datafile = self._save_datafile(storage_id, data)
+
+ # save metadata
+ metadata = {
+ "mailfrom": mailfrom,
+ "recipients": recipients,
+ "subject": subject,
+ "timestamp": timegm(gmtime()),
+ "queue_id": qid}
+
+ try:
+ self._save_metafile(storage_id, metadata)
+ except RuntimeError as e:
+ os.remove(datafile)
+ raise e
+
+ return (storage_id, datafile)
+
+ def execute(self, milter, pretend=False,
+ logger=logging.getLogger(__name__)):
+ if self.original:
+ milter.fp.seek(0)
+ data = milter.fp.read
+ mailfrom = milter.mailfrom
+ recipients = list(milter.rcpts)
+ subject = ""
+ else:
+ data = milter.msg.as_bytes
+ mailfrom = milter.msginfo["mailfrom"]
+ recipients = list(milter.msginfo["rcpts"])
+ subject = milter.msg["subject"] or ""
+
+ storage_id, datafile = self.add(
+ data(), milter.qid, mailfrom, recipients, subject)
+ logger.info(f"stored message in file {datafile}")
+
+ def get_metadata(self, storage_id):
+ "Return metadata of email in storage."
+ super(FileMailStorage, self).get_metadata(storage_id)
+
+ metafile = os.path.join(
+ self.directory, f"{storage_id}{self._metadata_suffix}")
+ if not os.path.isfile(metafile):
+ raise RuntimeError(
+ f"invalid storage id '{storage_id}'")
+
+ try:
+ with open(metafile, "r") as f:
+ metadata = json.load(f)
+ except IOError as e:
+ raise RuntimeError(f"unable to read metadata file: {e}")
+ except json.JSONDecodeError as e:
+ raise RuntimeError(
+ f"invalid metafile '{metafile}': {e}")
+
+ return metadata
+
+ def find(self, mailfrom=None, recipients=None, older_than=None):
+ "Find emails in storage."
+ super(FileMailStorage, self).find(mailfrom, recipients, older_than)
+ if isinstance(mailfrom, str):
+ mailfrom = [mailfrom]
+ if isinstance(recipients, str):
+ recipients = [recipients]
+
+ emails = {}
+ metafiles = glob(os.path.join(
+ self.directory, f"*{self._metadata_suffix}"))
+ for metafile in metafiles:
+ if not os.path.isfile(metafile):
+ continue
+
+ storage_id = os.path.basename(
+ metafile[:-len(self._metadata_suffix)])
+ metadata = self.get_metadata(storage_id)
+ if older_than is not None:
+ if timegm(gmtime()) - metadata["date"] < (older_than * 86400):
+ continue
+
+ if mailfrom is not None:
+ if metadata["mailfrom"] not in mailfrom:
+ continue
+
+ if recipients is not None:
+ if len(recipients) == 1 and \
+ recipients[0] not in metadata["recipients"]:
+ continue
+ elif len(set(recipients + metadata["recipients"])) == \
+ len(recipients + metadata["recipients"]):
+ continue
+
+ emails[storage_id] = metadata
+
+ return emails
+
+ def delete(self, storage_id, recipients=None):
+ "Delete email from storage."
+ super(FileMailStorage, self).delete(storage_id, recipients)
+
+ try:
+ metadata = self.get_metadata(storage_id)
+ except RuntimeError as e:
+ raise RuntimeError(f"unable to delete email: {e}")
+
+ if not recipients:
+ self._remove(storage_id)
+ else:
+ if type(recipients) == str:
+ recipients = [recipients]
+ for recipient in recipients:
+ if recipient not in metadata["recipients"]:
+ raise RuntimeError(f"invalid recipient '{recipient}'")
+ metadata["recipients"].remove(recipient)
+ if not metadata["recipients"]:
+ self._remove(storage_id)
+ else:
+ self._save_metafile(storage_id, metadata)
+
+ def get_mail(self, storage_id):
+ super(FileMailStorage, self).get_mail(storage_id)
+
+ metadata = self.get_metadata(storage_id)
+ datafile = os.path.join(self.directory, storage_id)
+ try:
+ fp = open(datafile, "rb")
+ except IOError as e:
+ raise RuntimeError(f"unable to open email data file: {e}")
+ return (fp, metadata)