48 Commits

Author SHA1 Message Date
1e14b189dc change version to 2.1.2 2025-08-21 14:16:41 +02:00
5a8808c7d0 fix Gentoo ebuild 2025-08-21 14:14:17 +02:00
b345487052 remove data_files from setup.py 2025-08-21 14:09:05 +02:00
5e717f562a update Gentoo ebuild 2025-08-11 09:30:23 +02:00
4b3bec365f update Gentoo ebuild 2025-08-11 09:20:10 +02:00
50a513b8ac change version to 2.1.1 2024-01-15 10:17:54 +01:00
ab5469db29 cleanup 2024-01-12 17:51:20 +01:00
276aa06fe2 fix logging 2024-01-12 17:25:29 +01:00
b910570d21 fix logging and CLI 2024-01-12 17:10:44 +01:00
da0598be23 fix notification within quarantine 2024-01-12 15:24:29 +01:00
dc085a7138 fix multiple bugs 2024-01-12 14:46:48 +01:00
6389bf6668 change README.md 2024-01-12 10:02:37 +01:00
110ffd2080 change README.md 2024-01-12 10:00:10 +01:00
8a460d1c0d change README.md 2024-01-11 16:44:23 +01:00
5e231cdc6e change README.md 2024-01-11 16:27:15 +01:00
ec9455d36b change required python version in ebuild 2024-01-10 17:03:44 +01:00
c3b672ec58 more logging improvements 2024-01-10 16:14:08 +01:00
4266cbb9d4 extend conditions logging 2024-01-10 16:03:07 +01:00
3ca6dd468f fix logging and notifications 2024-01-10 15:50:35 +01:00
2218107228 remove unused imports in cli.py 2024-01-10 13:57:04 +01:00
79457d27ac fix CLI 2024-01-08 14:40:31 +01:00
1c81505126 fix bugs 2024-01-08 14:34:02 +01:00
4da1a0e9b3 change config structure 2024-01-03 19:54:34 +01:00
5d21b81530 change CLI to handle global lists 2023-12-12 19:20:22 +01:00
a226bc70a9 fix bugs 2023-12-12 18:15:09 +01:00
af800c73aa fix DatabaseList init 2023-12-12 14:32:19 +01:00
479c1513a3 add list condition 2023-12-12 14:13:08 +01:00
f42860d900 rename whitelist to allowlist in README.md 2023-12-11 18:48:36 +01:00
bbd4d2c95b rename whitelist to allowlist 2023-12-11 18:42:06 +01:00
9a86e8cd24 require at least python 3.10 and remove old runtime patch 2023-12-11 17:56:43 +01:00
9d3c7c84c1 add quarantine copy command to CLI 2023-12-11 17:51:04 +01:00
5fe152b6b6 change version to 2.0.8 2022-11-15 15:05:20 +01:00
5991f722ec fix header decoding 2022-11-15 15:04:22 +01:00
74bcfb6639 change version to 2.0.7 2022-11-15 14:00:35 +01:00
bc6d706dc7 headers are getting corrupted, remove surrogates again 2022-11-15 13:58:10 +01:00
5212201cd1 change version to 2.0.6 2022-09-15 15:00:45 +02:00
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
18 changed files with 564 additions and 440 deletions

162
README.md
View File

@@ -6,10 +6,10 @@ A pymilter based sendmail/postfix pre-queue filter with the ability to ...
* append and prepend disclaimers to e-mail text parts * append and prepend disclaimers to e-mail text parts
* quarantine e-mails (store e-mail, optionally notify receivers) * quarantine e-mails (store e-mail, optionally notify receivers)
It is useful in many cases due to its flexible configuration and the ability to handle any number of quarantines and modifications sequential and conditional. Storages and whitelists used by quarantines can be managed with the built-in CLI. It is useful in many cases due to its flexible configuration and the ability to handle any number of quarantines and modifications sequential and conditional. Storages and lists used by quarantines can be managed with the built-in CLI.
Addionally, pyquarantine-milter provides a sanitized, harmless version of the text parts of e-mails as template variable, 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. Addionally, pyquarantine-milter provides a sanitized, harmless version of the text parts of e-mails as template variable, 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.
It is also possible to use any metavariable as template variable (e.g. storage ID, envelope-from address, ...). This may be used to give your users the ability to release e-mails or whitelist the from-address for example. A webservice then releases the e-mail from the quarantine. It is also possible to use any metavariable as template variable (e.g. storage ID, envelope-from address, ...). This may be used to give your users the ability to release e-mails or add the from-address to an allowlist. A webservice then releases the e-mail from the quarantine.
The project is currently in beta status, but it is already used in a productive enterprise environment that processes about a million e-mails per month. The project is currently in beta status, but it is already used in a productive enterprise environment that processes about a million e-mails per month.
@@ -76,9 +76,40 @@ Global config options:
* **debug** * **debug**
* **pretend** (optional, default: false) * **pretend** (optional, default: false)
Pretend actions, for test purposes. This option may be overriden by any rule or action object. Pretend actions, for test purposes. This option may be overriden by any rule or action object.
* **storages**
Object containing Storage objects.
* **notifications**
Object containing Notification objects.
* **lists**
Object containing List objects.
* **rules** * **rules**
List of rule objects. List of rule objects.
### Storage
Config options for Storage objects:
* **type**
See section [Storage types](#Storage-types).
* **original** (optional, default: false)
If set to true, store the message as received by the MTA instead of storing the current state of the message, that may was modified already by other actions.
* **metadata** (optional, default: false)
Store metadata.
* **metavar** (optional)
Prefix for the metavariable names. If not set, no metavariables will be provided.
The storage provides the following metavariables:
* **ID** (the storage ID of the e-mail)
* **DATAFILE** (path to the data file)
* **METAFILE** (path to the meta file if **metadata** is set to **true**)
### Notification
Config options for Notification objects:
* **type**
See section [Notification types](#Notification-types).
### List
Config options for List objects:
* **type**
See section [List types](#List-types).
### Rule ### Rule
Config options for rule objects: Config options for rule objects:
* **name** * **name**
@@ -119,19 +150,14 @@ Config options for conditions objects:
Matches e-mails for which all envelope-to addresses match the given regular expression. Matches e-mails for which all envelope-to addresses match the given regular expression.
* **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) * **list** (optional)
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. Matches e-mails for which the given list has an entry for the envelope-from and envelope-to address combination, see section [List](#List) 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)
Prefix for the name of metavariables which are possibly provided by the **envfrom**, **envto** or **headers** condition. Meta variables will be provided if the regular expressions contain named subgroups, see [python.re](https://docs.python.org/3/library/re.html) for details. Prefix for the name of metavariables which are possibly provided by the **envfrom**, **envto** or **headers** condition. Meta variables will be provided if the regular expressions contain named subgroups, see [python.re](https://docs.python.org/3/library/re.html) for details.
If not set, no metavariables will be provided. If not set, no metavariables will be provided.
### Whitelist
Config options for whitelist objects:
* **type**
See section [Whitelist types](#Whitelist-types).
### Action types ### Action types
Available action types: Available action types:
##### add_header ##### add_header
@@ -187,37 +213,27 @@ Options:
##### store ##### store
Store e-mail. Store e-mail.
Options: Options:
* **type** * **storage**
See section [Storage types](#Storage-types). Index of a Storage object in the global storages object.
* **original** (optional, default: false)
If set to true, store the message as received by the MTA instead of storing the current state of the message, that may was modified already by other actions.
* **metadata** (optional, default: false)
Store metadata.
* **metavar** (optional)
Prefix for the metavariable names. If not set, no metavariables will be provided.
The storage provides the following metavariables:
* **ID** (the storage ID of the e-mail)
* **DATAFILE** (path to the data file)
* **METAFILE** (path to the meta file if **metadata** is set to **true**)
##### notify ##### notify
Send notification. Send notification.
Options: Options:
* **type** * **notification**
See section [Notification types](#Notification-types). Index of a Notification object in the global notifications object.
##### quarantine ##### quarantine
Quarantine e-mail. Quarantine e-mail.
Options: Options:
* **store** * **storage**
Options for e-mail storage, see action [store](#store). Index of a Storage object in the global storages object.
If the option **metadata** is not specificall set for this storage, it will be set to true. If the option **metadata** is not specifically 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) * **notification** (optional)
Options for e-mail notifications, see action [notify](#notify). Index of a Notification object in the global notifications object.
* **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.
@@ -230,8 +246,9 @@ Options:
Tell the MTA to silently discard the e-mail. Tell the MTA to silently discard the e-mail.
* **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) * **allowlist** (optional)
Options for a whitelist object, see section [Whitelist](#Whitelist). Ignore e-mails for which the given list has an entry for the envelope-from and envelope-to address combination, see section [List](#List) for details.
If an e-mail as multiple recipients, the decision is made per recipient.
### Storage types ### Storage types
Available storage types: Available storage types:
@@ -289,10 +306,10 @@ Options:
* **embed_imgs** (optional) * **embed_imgs** (optional)
List of images to embed into the notification e-mail. The Content-ID of each image will be set to the filename, so you can reference it from the e-mail template. List of images to embed into the notification e-mail. The Content-ID of each image will be set to the filename, so you can reference it from the e-mail template.
### Whitelist types ### List types
Available whitelist types: Available list types:
##### db ##### db
Whitelist stored in database. The table is created automatically if it does not exist yet. List stored in database. The table is created automatically if it does not exist yet.
Options: Options:
* **connection** * **connection**
Database connection string, see [Peewee Playhouse Extension](https://docs.peewee-orm.com/en/latest/peewee/playhouse.html#db-url). Database connection string, see [Peewee Playhouse Extension](https://docs.peewee-orm.com/en/latest/peewee/playhouse.html#db-url).
@@ -311,6 +328,38 @@ In this example it is assumed, that another milter (e.g. Amavisd or Rspamd) adds
```json ```json
{ {
"socket": "unix:/tmp/pyquarantine.sock", "socket": "unix:/tmp/pyquarantine.sock",
"storages": {
"virus": {
"type": "file",
"directory": "/mnt/data/quarantine/virus"
},
"spam": {
"type": "file",
"directory": "/mnt/data/quarantine/spam"
}
},
"notifications": {
"virus": {
"type": "email",
"smtp_host": "localhost",
"smtp_port": 2525,
"envelope_from": "notifications@example.com",
"from_header": "{FROM}",
"subject": "[VIRUS] {SUBJECT}",
"template": "/etc/pyquarantine/templates/notification.template",
"repl_img": "/etc/pyquarantine/templates/removed.png"
},
"spam": {
"type": "email",
"smtp_host": "localhost",
"smtp_port": 2525,
"envelope_from": "notifications@example.com",
"from_header": "{FROM}",
"subject": "[SPAM] {SUBJECT}",
"template": "/etc/pyquarantine/templates/notification.template",
"repl_img": "/etc/pyquarantine/templates/removed.png"
}
},
"rules": [ "rules": [
{ {
"name": "inbound", "name": "inbound",
@@ -322,23 +371,11 @@ In this example it is assumed, that another milter (e.g. Amavisd or Rspamd) adds
"name": "virus", "name": "virus",
"type": "quarantine", "type": "quarantine",
"conditions": { "conditions": {
"headers": ["^X-Virus: Yes"], "headers": ["^X-Virus: Yes"]
}, },
"options": { "options": {
"store": { "storage": "virus",
"type": "file", "notification": "virus",
"directory": "/mnt/data/quarantine/virus",
},
"notify": {
"type": "email",
"smtp_host": "localhost",
"smtp_port": 2525,
"envelope_from": "notifications@example.com",
"from_header": "{FROM}",
"subject": "[VIRUS] {SUBJECT}",
"template": "/etc/pyquarantine/templates/notification.template",
"repl_img": "/etc/pyquarantine/templates/removed.png"
},
"smtp_host": "localhost", "smtp_host": "localhost",
"smtp_port": 2525, "smtp_port": 2525,
"milter_action": "REJECT", "milter_action": "REJECT",
@@ -351,20 +388,8 @@ In this example it is assumed, that another milter (e.g. Amavisd or Rspamd) adds
"headers": ["^X-Spam: Yes"] "headers": ["^X-Spam: Yes"]
}, },
"options": { "options": {
"store": { "storage": "spam",
"type": "file", "notification": "spam",
"directory": "/mnt/data/quarantine/spam",
},
"notify": {
"type": "email",
"smtp_host": "localhost",
"smtp_port": 2525,
"envelope_from": "notifications@example.com",
"from_header": "{FROM}",
"subject": "[SPAM] {SUBJECT}",
"template": "/etc/pyquarantine/templates/notification.template",
"repl_img": "/etc/pyquarantine/templates/removed.png"
},
"smtp_host": "localhost", "smtp_host": "localhost",
"smtp_port": 2525, "smtp_port": 2525,
"milter_action": "DISCARD" "milter_action": "DISCARD"
@@ -420,6 +445,13 @@ In this example it is assumed, that another milter (e.g. Amavisd or Rspamd) adds
```json ```json
{ {
"socket": "unix:/tmp/pyquarantine.sock", "socket": "unix:/tmp/pyquarantine.sock",
"storages": {
"orig": {
"type": "file",
"directory": "/mnt/data/incoming",
"original": true
}
},
"rules": [ "rules": [
{ {
"name": "inbound", "name": "inbound",
@@ -431,9 +463,7 @@ In this example it is assumed, that another milter (e.g. Amavisd or Rspamd) adds
"name": "store_original", "name": "store_original",
"type": "store", "type": "store",
"options": { "options": {
"type": "file", "storage": "orig"
"directory": "/mnt/data/incoming",
"original": true,
} }
} }
] ]

View File

@@ -1,9 +1,9 @@
# Copyright 2020 Gentoo Authors # Copyright 2020 Gentoo Authors
# 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=8
PYTHON_COMPAT=( python3_{9,10} ) PYTHON_COMPAT=( python3_{11..13} )
DISTUTILS_USE_SETUPTOOLS=rdepend DISTUTILS_USE_PEP517=setuptools
SCM="" SCM=""
if [ "${PV#9999}" != "${PV}" ] ; then if [ "${PV#9999}" != "${PV}" ] ; then
@@ -31,19 +31,21 @@ LICENSE="GPL-3"
SLOT="0" SLOT="0"
IUSE="+lxml systemd" IUSE="+lxml systemd"
RDEPEND=" RDEPEND="dev-python/beautifulsoup4[${PYTHON_USEDEP}]
dev-python/beautifulsoup4[${PYTHON_USEDEP}]
dev-python/jsonschema[${PYTHON_USEDEP}] dev-python/jsonschema[${PYTHON_USEDEP}]
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.0.5[${PYTHON_USEDEP}]"
python_install_all() { python_install_all() {
distutils-r1_python_install_all
use systemd && systemd_dounit pyquarantine/misc/systemd/${PN}.service use systemd && systemd_dounit pyquarantine/misc/systemd/${PN}.service
newinitd pyquarantine/misc/openrc/${PN}.initd ${PN} newinitd pyquarantine/misc/openrc/${PN}.initd ${PN}
newconfd pyquarantine/misc/openrc/${PN}.confd ${PN} newconfd pyquarantine/misc/openrc/${PN}.confd ${PN}
insinto /etc/pyquarantine
doins pyquarantine/misc/pyquarantine.conf.default
doins -r pyquarantine/misc/templates
distutils-r1_python_install_all
} }
pkg_postinst() { pkg_postinst() {

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

@@ -14,4 +14,4 @@ select version in $(find . -maxdepth 1 -type f -name "pyquarantine-*.*.*.tar.gz"
[ -n "${version}" ] && break [ -n "${version}" ] && break
echo -e "\ninvalid choice\n\n${msg}" echo -e "\ninvalid choice\n\n${msg}"
done done
${TWINE} upload "${version}"{.tar.gz,-*.whl} ${TWINE} upload -u __token__ "${version}"{.tar.gz,-*.whl}

View File

@@ -24,10 +24,10 @@ __all__ = [
"rule", "rule",
"run", "run",
"storage", "storage",
"whitelist", "lists",
"QuarantineMilter"] "QuarantineMilter"]
__version__ = "2.0.1" __version__ = "2.1.2"
from pyquarantine import _runtime_patches from pyquarantine import _runtime_patches
@@ -71,7 +71,7 @@ class QuarantineMilter(Milter.Base):
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.setLevel(QuarantineMilter._loglevel) logger.setLevel(QuarantineMilter._loglevel)
for idx, rule_cfg in enumerate(cfg["rules"]): for rule_cfg in cfg["rules"]:
rule = Rule(rule_cfg, local_addrs, debug) rule = Rule(rule_cfg, local_addrs, debug)
logger.debug(rule) logger.debug(rule)
QuarantineMilter._rules.append(rule) QuarantineMilter._rules.append(rule)
@@ -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

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

@@ -150,68 +150,6 @@ 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
#
# 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"""
setattr(email.errors, "InvalidDateDefect", InvalidDateDefect)
import email.utils
from email.utils import _parsedate_tz
import datetime
def parsedate_to_datetime(data):
parsed_date_tz = _parsedate_tz(data)
if parsed_date_tz is None:
raise ValueError('Invalid date value or format "%s"' % str(data))
*dtuple, tz = parsed_date_tz
if tz is None:
return datetime.datetime(*dtuple[:6])
return datetime.datetime(*dtuple[:6],
tzinfo=datetime.timezone(datetime.timedelta(seconds=tz)))
setattr(email.utils, "parsedate_to_datetime", parsedate_to_datetime)
import email.headerregistry
from email import utils, _header_value_parser as parser
@classmethod
def parse(cls, value, kwds):
if not value:
kwds['defects'].append(errors.HeaderMissingRequiredValue())
kwds['datetime'] = None
kwds['decoded'] = ''
kwds['parse_tree'] = parser.TokenList()
return
if isinstance(value, str):
kwds['decoded'] = value
try:
value = utils.parsedate_to_datetime(value)
except ValueError:
kwds['defects'].append(errors.InvalidDateDefect('Invalid date value or format'))
kwds['datetime'] = None
kwds['parse_tree'] = parser.TokenList()
return
kwds['datetime'] = value
kwds['decoded'] = utils.format_datetime(kwds['datetime'])
kwds['parse_tree'] = cls.value_parser(kwds['decoded'])
setattr(email.headerregistry.DateHeader, "parse", parse)
####################################### #######################################
# add charset alias for windows-874 # # add charset alias for windows-874 #
####################################### #######################################

View File

@@ -14,6 +14,7 @@
__all__ = ["Action"] __all__ = ["Action"]
from copy import deepcopy
from pyquarantine import modify, notify, storage from pyquarantine import modify, notify, storage
from pyquarantine.conditions import Conditions from pyquarantine.conditions import Conditions
@@ -39,9 +40,8 @@ class Action:
self.conditions["loglevel"] = cfg["loglevel"] self.conditions["loglevel"] = cfg["loglevel"]
self.conditions = Conditions(self.conditions, local_addrs, debug) self.conditions = Conditions(self.conditions, local_addrs, debug)
action_type = cfg["type"] self.action = self.ACTION_TYPES[cfg["type"]](
self.action = self.ACTION_TYPES[action_type]( deepcopy(cfg), local_addrs, debug)
cfg, local_addrs, debug)
def __str__(self): def __str__(self):
cfg = [] cfg = []

View File

@@ -71,7 +71,7 @@ class MilterMessage(MIMEPart):
maintype, subtype = part.get_content_type().split("/") maintype, subtype = part.get_content_type().split("/")
if maintype == "text": if maintype == "text":
if subtype in preferencelist: if subtype in preferencelist:
yield(preferencelist.index(subtype), parent) yield (preferencelist.index(subtype), parent)
return return
if maintype != "multipart" or not self.is_multipart(): if maintype != "multipart" or not self.is_multipart():
return return
@@ -81,7 +81,7 @@ class MilterMessage(MIMEPart):
subpart, preferencelist, part) subpart, preferencelist, part)
return return
if 'related' in preferencelist: if 'related' in preferencelist:
yield(preferencelist.index('related'), parent) yield (preferencelist.index('related'), parent)
candidate = None candidate = None
start = part.get_param('start') start = part.get_param('start')
if start: if start:
@@ -162,8 +162,9 @@ class MilterMessage(MIMEPart):
def inject_body_part(part, content, subtype="plain"): def inject_body_part(part, content, subtype="plain"):
parts = [] parts = []
text_body = None text_body = None
text_content = None
if subtype == "html": if subtype == "html":
text_body = part.get_body(preferencelist=("plain")) text_body, text_content = part.get_body_content("plain")
for p in part.iter_parts(): for p in part.iter_parts():
if text_body and p == text_body: if text_body and p == text_body:
@@ -173,8 +174,8 @@ def inject_body_part(part, content, subtype="plain"):
boundary = part.get_boundary() boundary = part.get_boundary()
p_subtype = part.get_content_subtype() p_subtype = part.get_content_subtype()
part.clear_content() part.clear_content()
if text_body: if text_content is not None:
part.set_content(content) part.set_content(text_content)
part.add_alternative(content, subtype=subtype) part.add_alternative(content, subtype=subtype)
else: else:
part.set_content(content, subtype=subtype) part.set_content(content, subtype=subtype)

View File

@@ -23,31 +23,49 @@ import time
from pyquarantine.config import get_milter_config, ActionConfig from pyquarantine.config import get_milter_config, ActionConfig
from pyquarantine.storage import Quarantine from pyquarantine.storage import Quarantine
from pyquarantine.lists import DatabaseList
from pyquarantine import __version__ as version from pyquarantine import __version__ as version
def _get_quarantine(quarantines, name, debug): def _get_quarantines(milter_cfg):
quarantines = []
for rule in milter_cfg["rules"]:
for action in rule["actions"]:
if action["type"] == "quarantine":
quarantines.append(action)
return quarantines
def _get_quarantine(milter_cfg, name, debug):
try: try:
quarantine = next((q for q in quarantines if q["name"] == name)) quarantine = next(
(q for q in _get_quarantines(milter_cfg) if q["name"] == name))
except StopIteration: except StopIteration:
raise RuntimeError(f"invalid quarantine '{name}'") raise RuntimeError(f"invalid quarantine '{name}'")
return Quarantine(ActionConfig(quarantine), [], debug)
cfg = ActionConfig(quarantine, milter_cfg)
return Quarantine(cfg, [], debug)
def _get_notification(quarantines, name, debug): def _get_notification(cfg, name, debug):
notification = _get_quarantine(quarantines, name, debug).notification notification = _get_quarantine(cfg, name, debug).notification
if not notification: if not notification:
raise RuntimeError( raise RuntimeError(
"notification type is set to NONE") "notification type is set to NONE")
return notification return notification
def _get_whitelist(quarantines, name, debug): def _get_list(cfg, name, debug):
whitelist = _get_quarantine(quarantines, name, debug).whitelist try:
if not whitelist: list_cfg = cfg["lists"][name]
raise RuntimeError( except KeyError:
"whitelist type is set to NONE") raise RuntimeError(f"list '{name}' is not configured")
return whitelist
if list_cfg["type"] == "db":
list_cfg["loglevel"] = cfg["loglevel"]
return DatabaseList(list_cfg, debug)
else:
raise RuntimeError("invalid lists type")
def print_table(columns, rows): def print_table(columns, rows):
@@ -91,49 +109,66 @@ def print_table(columns, rows):
print(row_format.format(*row)) print(row_format.format(*row))
def list_quarantines(quarantines, args): def show(cfg, args):
quarantines = _get_quarantines(cfg)
if args.batch: if args.batch:
print("\n".join([q["name"] for q in quarantines])) print("\n".join([q["name"] for q in quarantines]))
else: else:
qlist = [] qlist = []
for q in quarantines: for q in quarantines:
cfg = q["options"] qcfg = q["options"]
storage_type = cfg["store"]["type"]
if "notify" in cfg: if "notification" in qcfg:
notification_type = cfg["notify"]["type"] notification = cfg["notifications"][qcfg["notification"]]
notify_type = notification["type"]
else: else:
notification_type = "NONE" notify_type = "NONE"
if "whitelist" in cfg: if "allowlist" in qcfg:
whitelist_type = cfg["whitelist"]["type"] allowlist = qcfg["allowlist"]
else: else:
whitelist_type = "NONE" allowlist = "NONE"
if "milter_action" in cfg: if "milter_action" in qcfg:
milter_action = cfg["milter_action"] milter_action = qcfg["milter_action"]
else: else:
milter_action = "NONE" milter_action = "NONE"
storage_type = cfg["storages"][qcfg["storage"]]["type"]
qlist.append({ qlist.append({
"name": q["name"], "name": q["name"],
"storage": storage_type, "storage": storage_type,
"notification": notification_type, "notification": notify_type,
"whitelist": whitelist_type, "lists": allowlist,
"action": milter_action}) "action": milter_action})
print_table( print_table(
[("Name", "name"), [("Quarantine", "name"),
("Storage", "storage"), ("Storage", "storage"),
("Notification", "notification"), ("Notification", "notification"),
("Whitelist", "whitelist"), ("Allowlist", "lists"),
("Action", "action")], ("Action", "action")],
qlist qlist
) )
if "lists" in cfg:
lst_list = []
for name, options in cfg["lists"].items():
lst_list.append({
"name": name,
"type": options["type"]})
def list_quarantine_emails(quarantines, args): print("\n")
storage = _get_quarantine(quarantines, args.quarantine, args.debug).storage print_table(
[("List", "name"),
("Type", "type")],
lst_list
)
def list_quarantine_emails(cfg, args):
storage = _get_quarantine(cfg, args.quarantine, args.debug).storage
# find emails and transform some metadata values to strings # find emails and transform some metadata values to strings
rows = [] rows = []
@@ -180,16 +215,16 @@ def list_quarantine_emails(quarantines, args):
) )
def list_whitelist(quarantines, args): def list_list(cfg, args):
whitelist = _get_whitelist(quarantines, args.quarantine, args.debug) lst = _get_list(cfg, args.list, args.debug)
# find whitelist entries # find lists entries
entries = whitelist.find( entries = lst.find(
mailfrom=args.mailfrom, mailfrom=args.mailfrom,
recipients=args.recipients, recipients=args.recipients,
older_than=args.older_than) older_than=args.older_than)
if not entries: if not entries:
print(f"whitelist of quarantine '{args.quarantine}' is empty") print("list is empty")
return return
# transform some values to strings # transform some values to strings
@@ -210,12 +245,12 @@ def list_whitelist(quarantines, args):
) )
def add_whitelist_entry(quarantines, args): def add_list_entry(cfg, args):
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
whitelist = _get_whitelist(quarantines, args.quarantine, args.debug) lst = _get_list(cfg, args.list, args.debug)
# check existing entries # check existing entries
entries = whitelist.check(args.mailfrom, args.recipient, logger) entries = lst.check(args.mailfrom, args.recipient, logger)
if entries: if entries:
# check if the exact entry exists already # check if the exact entry exists already
for entry in entries.values(): for entry in entries.values():
@@ -245,47 +280,56 @@ def add_whitelist_entry(quarantines, args):
"from/to combination is already covered by the entries above, " "from/to combination is already covered by the entries above, "
"use --force to override.") "use --force to override.")
# add entry to whitelist # add entry to lists
whitelist.add(args.mailfrom, args.recipient, args.comment, args.permanent) lst.add(args.mailfrom, args.recipient, args.comment, args.permanent)
print("whitelist entry added successfully") print("list entry added successfully")
def delete_whitelist_entry(quarantines, args): def delete_list_entry(cfg, args):
whitelist = _get_whitelist(quarantines, args.quarantine, args.debug) lst = _get_list(cfg, args.list, args.debug)
whitelist.delete(args.whitelist_id) lst.delete(args.list_id)
print("whitelist entry deleted successfully") print("list entry deleted successfully")
def notify(quarantines, args): def notify(cfg, args):
quarantine = _get_quarantine(quarantines, args.quarantine, args.debug) quarantine = _get_quarantine(cfg, args.quarantine, args.debug)
quarantine.notify(args.quarantine_id, args.recipient) quarantine.notify(args.quarantine_id, args.recipient)
print("notification sent successfully") print("notification sent successfully")
def release(quarantines, args): def release(cfg, args):
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
quarantine = _get_quarantine(quarantines, args.quarantine, args.debug) quarantine = _get_quarantine(cfg, args.quarantine, args.debug)
rcpts = quarantine.release(args.quarantine_id, args.recipient) rcpts = quarantine.release(args.quarantine_id, args.recipient)
rcpts = ", ".join(rcpts) rcpts = ", ".join(rcpts)
logger.info( logger.info(
f"{args.quarantine}: released message with id {args.quarantine_id} " f"{quarantine.cfg['name']}: released message with id "
f"for {rcpts}") f"{args.quarantine_id} for {rcpts}")
def delete(quarantines, args): def copy(cfg, args):
storage = _get_quarantine(quarantines, args.quarantine, args.debug).storage logger = logging.getLogger(__name__)
quarantine = _get_quarantine(cfg, args.quarantine, args.debug)
quarantine.copy(args.quarantine_id, args.recipient)
logger.info(
f"{args.quarantine}: sent a copy of message with id "
f"{args.quarantine_id} to {args.recipient}")
def delete(cfg, args):
storage = _get_quarantine(cfg, args.quarantine, args.debug).storage
storage.delete(args.quarantine_id, args.recipient) storage.delete(args.quarantine_id, args.recipient)
print("quarantined message deleted successfully") print("quarantined message deleted successfully")
def get(quarantines, args): def get(cfg, args):
storage = _get_quarantine(quarantines, args.quarantine, args.debug).storage storage = _get_quarantine(cfg, args.quarantine, args.debug).storage
data = storage.get_mail_bytes(args.quarantine_id) data = storage.get_mail_bytes(args.quarantine_id)
sys.stdout.buffer.write(data) sys.stdout.buffer.write(data)
def metadata(quarantines, args): def metadata(cfg, args):
storage = _get_quarantine(quarantines, args.quarantine, args.debug).storage storage = _get_quarantine(cfg, args.quarantine, args.debug).storage
metadata = storage.get_metadata(args.quarantine_id) metadata = storage.get_metadata(args.quarantine_id)
print(json.dumps(metadata)) print(json.dumps(metadata))
@@ -330,15 +374,15 @@ def main():
subparsers.required = True subparsers.required = True
# list command # list command
list_parser = subparsers.add_parser( show_parser = subparsers.add_parser(
"list", "show",
help="List available quarantines.", help="Show quarantines.",
formatter_class=formatter_class) formatter_class=formatter_class)
list_parser.add_argument( show_parser.add_argument(
"-b", "--batch", "-b", "--batch",
help="Print results using only quarantine names, each on a new line.", help="Print results using only quarantine names, each on a new line.",
action="store_true") action="store_true")
list_parser.set_defaults(func=list_quarantines) show_parser.set_defaults(func=show)
# quarantine command group # quarantine command group
quar_parser = subparsers.add_parser( quar_parser = subparsers.add_parser(
@@ -432,6 +476,28 @@ def main():
help="Release email for all recipients.", help="Release email for all recipients.",
action="store_true") action="store_true")
quar_release_parser.set_defaults(func=release) quar_release_parser.set_defaults(func=release)
# quarantine copy command
quar_copy_parser = quar_subparsers.add_parser(
"copy",
description="Send a copy of email to another recipient.",
help="Send a copy of email to another recipient.",
formatter_class=formatter_class)
quar_copy_parser.add_argument(
"quarantine_id",
metavar="ID",
help="Quarantine ID.")
quar_copy_parser.add_argument(
"-n",
"--disable-syslog",
dest="syslog",
help="Disable syslog messages.",
action="store_false")
quar_copy_parser.add_argument(
"-t", "--to",
dest="recipient",
required=True,
help="Release email for one recipient address.")
quar_copy_parser.set_defaults(func=copy)
# quarantine delete command # quarantine delete command
quar_delete_parser = quar_subparsers.add_parser( quar_delete_parser = quar_subparsers.add_parser(
"delete", "delete",
@@ -481,86 +547,86 @@ def main():
help="Quarantine ID.") help="Quarantine ID.")
quar_metadata_parser.set_defaults(func=metadata) quar_metadata_parser.set_defaults(func=metadata)
# whitelist command group # list command group
whitelist_parser = subparsers.add_parser( list_parser = subparsers.add_parser(
"whitelist",
description="Manage whitelists.",
help="Manage whitelists.",
formatter_class=formatter_class)
whitelist_parser.add_argument(
"quarantine",
metavar="QUARANTINE",
help="Quarantine name.")
whitelist_subparsers = whitelist_parser.add_subparsers(
dest="command",
title="Whitelist commands")
whitelist_subparsers.required = True
# whitelist list command
whitelist_list_parser = whitelist_subparsers.add_parser(
"list", "list",
description="List whitelist entries.", description="Manage lists.",
help="List whitelist entries.", help="Manage lists.",
formatter_class=formatter_class) formatter_class=formatter_class)
whitelist_list_parser.add_argument( list_parser.add_argument(
"list",
metavar="LIST",
help="List name.")
list_subparsers = list_parser.add_subparsers(
dest="command",
title="Lists commands.")
list_subparsers.required = True
# lists list command
list_list_parser = list_subparsers.add_parser(
"list",
description="List list entries.",
help="List list entries.",
formatter_class=formatter_class)
list_list_parser.add_argument(
"-f", "--from", "-f", "--from",
dest="mailfrom", dest="mailfrom",
help="Filter entries by from address.", help="Filter entries by from address.",
default=None, default=None,
nargs="+") nargs="+")
whitelist_list_parser.add_argument( list_list_parser.add_argument(
"-t", "--to", "-t", "--to",
dest="recipients", dest="recipients",
help="Filter entries by recipient address.", help="Filter entries by recipient address.",
default=None, default=None,
nargs="+") nargs="+")
whitelist_list_parser.add_argument( list_list_parser.add_argument(
"-o", "--older-than", "-o", "--older-than",
dest="older_than", dest="older_than",
help="Filter emails by last used date (days).", help="Filter emails by last used date (days).",
default=None, default=None,
type=float) type=float)
whitelist_list_parser.set_defaults(func=list_whitelist) list_list_parser.set_defaults(func=list_list)
# whitelist add command # lists add command
whitelist_add_parser = whitelist_subparsers.add_parser( list_add_parser = list_subparsers.add_parser(
"add", "add",
description="Add whitelist entry.", description="Add list entry.",
help="Add whitelist entry.", help="Add list entry.",
formatter_class=formatter_class) formatter_class=formatter_class)
whitelist_add_parser.add_argument( list_add_parser.add_argument(
"-f", "--from", "-f", "--from",
dest="mailfrom", dest="mailfrom",
help="From address.", help="From address.",
required=True) required=True)
whitelist_add_parser.add_argument( list_add_parser.add_argument(
"-t", "--to", "-t", "--to",
dest="recipient", dest="recipient",
help="Recipient address.", help="Recipient address.",
required=True) required=True)
whitelist_add_parser.add_argument( list_add_parser.add_argument(
"-c", "--comment", "-c", "--comment",
help="Comment.", help="Comment.",
default="added by CLI") default="added by CLI")
whitelist_add_parser.add_argument( list_add_parser.add_argument(
"-p", "--permanent", "-p", "--permanent",
help="Add a permanent entry.", help="Add a permanent entry.",
action="store_true") action="store_true")
whitelist_add_parser.add_argument( list_add_parser.add_argument(
"--force", "--force",
help="Force adding an entry, " help="Force adding an entry, "
"even if already covered by another entry.", "even if already covered by another entry.",
action="store_true") action="store_true")
whitelist_add_parser.set_defaults(func=add_whitelist_entry) list_add_parser.set_defaults(func=add_list_entry)
# whitelist delete command # lists delete command
whitelist_delete_parser = whitelist_subparsers.add_parser( list_delete_parser = list_subparsers.add_parser(
"delete", "delete",
description="Delete whitelist entry.", description="Delete list entry.",
help="Delete whitelist entry.", help="Delete list entry.",
formatter_class=formatter_class) formatter_class=formatter_class)
whitelist_delete_parser.add_argument( list_delete_parser.add_argument(
"whitelist_id", "list_id",
metavar="ID", metavar="ID",
help="Whitelist ID.") help="List ID.")
whitelist_delete_parser.set_defaults(func=delete_whitelist_entry) list_delete_parser.set_defaults(func=delete_list_entry)
args = parser.parse_args() args = parser.parse_args()
@@ -591,7 +657,8 @@ def main():
try: try:
logger.debug("read milter configuration") logger.debug("read milter configuration")
cfg = get_milter_config(args.config, raw=True) cfg = get_milter_config(args.config, rec=False)
if "rules" not in cfg or not cfg["rules"]: if "rules" not in cfg or not cfg["rules"]:
raise RuntimeError("no rules configured") raise RuntimeError("no rules configured")
@@ -599,16 +666,11 @@ def main():
if "actions" not in rule or not rule["actions"]: if "actions" not in rule or not rule["actions"]:
raise RuntimeError( raise RuntimeError(
f"{rule['name']}: no actions configured") f"{rule['name']}: no actions configured")
except (RuntimeError, AssertionError) as e: except (RuntimeError, AssertionError) as e:
logger.error(f"config error: {e}") logger.error(f"config error: {e}")
sys.exit(255) sys.exit(255)
quarantines = []
for rule in cfg["rules"]:
for action in rule["actions"]:
if action["type"] == "quarantine":
quarantines.append(action)
if args.syslog: if args.syslog:
# setup syslog # setup syslog
sysloghandler = logging.handlers.SysLogHandler( sysloghandler = logging.handlers.SysLogHandler(
@@ -625,7 +687,7 @@ def main():
# call the commands function # call the commands function
try: try:
args.func(quarantines, args) args.func(cfg, args)
except RuntimeError as e: except RuntimeError as e:
logger.error(e) logger.error(e)
sys.exit(1) sys.exit(1)

View File

@@ -19,7 +19,7 @@ import re
from netaddr import IPAddress, IPNetwork, AddrFormatError from netaddr import IPAddress, IPNetwork, AddrFormatError
from pyquarantine import CustomLogger from pyquarantine import CustomLogger
from pyquarantine.whitelist import DatabaseWhitelist from pyquarantine.lists import DatabaseList
class Conditions: class Conditions:
@@ -33,7 +33,7 @@ class Conditions:
self.logger.setLevel(cfg.get_loglevel(debug)) self.logger.setLevel(cfg.get_loglevel(debug))
for arg in ("local", "hosts", "envfrom", "envto", "headers", "metavar", for arg in ("local", "hosts", "envfrom", "envto", "headers", "metavar",
"var"): "var", "list"):
if arg not in cfg: if arg not in cfg:
setattr(self, arg, None) setattr(self, arg, None)
continue continue
@@ -59,30 +59,28 @@ class Conditions:
header, re.IGNORECASE + re.DOTALL + re.MULTILINE)) header, re.IGNORECASE + re.DOTALL + re.MULTILINE))
except re.error as e: except re.error as e:
raise RuntimeError(e) raise RuntimeError(e)
elif arg == "list":
if cfg["list"]["type"] == "db":
cfg["list"]["name"] = cfg["name"]
cfg["list"]["loglevel"] = cfg["loglevel"]
self.list = DatabaseList(cfg["list"], debug)
else:
raise RuntimeError("invalid list type")
else: else:
setattr(self, arg, cfg[arg]) setattr(self, arg, cfg[arg])
self.whitelist = cfg["whitelist"] if "whitelist" in cfg else None
if self.whitelist is not None:
self.whitelist["name"] = f"{cfg['name']}: whitelist"
self.whitelist["loglevel"] = cfg["loglevel"]
if self.whitelist["type"] == "db":
self.whitelist = DatabaseWhitelist(self.whitelist, debug)
else:
raise RuntimeError("invalid whitelist type")
def __str__(self): def __str__(self):
cfg = [] cfg = []
for arg in ("local", "hosts", "envfrom", "envto", "headers", for arg in ("local", "hosts", "envfrom", "envto", "headers",
"var", "metavar"): "var", "metavar"):
if arg in self.cfg: if arg in self.cfg:
cfg.append(f"{arg}={self.cfg[arg]}") cfg.append(f"{arg}={self.cfg[arg]}")
if self.whitelist is not None: if self.list is not None:
cfg.append(f"whitelist={self.whitelist}") cfg.append(f"list={self.list}")
return "Conditions(" + ", ".join(cfg) + ")" return "Conditions(" + ", ".join(cfg) + ")"
def get_whitelist(self): def get_list(self):
return self.whitelist return self.list
def match_host(self, host): def match_host(self, host):
logger = CustomLogger( logger = CustomLogger(
@@ -123,17 +121,6 @@ class Conditions:
return True return True
def get_wl_rcpts(self, mailfrom, rcpts, logger):
if not self.whitelist:
return {}
wl_rcpts = []
for rcpt in rcpts:
if self.whitelist.check(mailfrom, rcpt, logger):
wl_rcpts.append(rcpt)
return wl_rcpts
def update_msginfo_from_match(self, milter, match): def update_msginfo_from_match(self, milter, match):
if self.metavar is None: if self.metavar is None:
return return
@@ -175,9 +162,9 @@ class Conditions:
f"envto does not match") f"envto does not match")
return False return False
logger.debug( logger.debug(
f"envto matches for " f"envto matches for "
f"envelope-to address {envto}") f"envelope-to address {envto}")
self.update_msginfo_from_match(milter, match) self.update_msginfo_from_match(milter, match)
if self.headers is not None: if self.headers is not None:
@@ -198,6 +185,27 @@ class Conditions:
if self.var is not None: if self.var is not None:
if self.var not in milter.msginfo["vars"]: if self.var not in milter.msginfo["vars"]:
logger.debug(
"ignore message, "
"vars does not match")
return False return False
logger.debug(f"vars matches, variable {self.var} is available")
if self.list is not None:
envfrom = milter.msginfo["mailfrom"]
envto = milter.msginfo["rcpts"]
if not isinstance(envto, list):
envto = [envto]
for to in envto:
if not self.list.check(envfrom, to, logger):
logger.debug(
"ignore message, "
"list does not match")
return False
logger.debug(
"list matches envelope-from and envelope-to address")
return True return True

View File

@@ -20,9 +20,11 @@ __all__ = [
"DelHeaderConfig", "DelHeaderConfig",
"AddDisclaimerConfig", "AddDisclaimerConfig",
"RewriteLinksConfig", "RewriteLinksConfig",
"StorageConfig",
"StoreConfig", "StoreConfig",
"NotificationConfig",
"NotifyConfig", "NotifyConfig",
"WhitelistConfig", "ListConfig",
"QuarantineConfig", "QuarantineConfig",
"ActionConfig", "ActionConfig",
"RuleConfig", "RuleConfig",
@@ -43,7 +45,7 @@ class BaseConfig:
"properties": { "properties": {
"loglevel": {"type": "string", "default": "info"}}} "loglevel": {"type": "string", "default": "info"}}}
def __init__(self, config): def __init__(self, config, *args, **kwargs):
required = self.JSON_SCHEMA["required"] required = self.JSON_SCHEMA["required"]
properties = self.JSON_SCHEMA["properties"] properties = self.JSON_SCHEMA["properties"]
for p in properties.keys(): for p in properties.keys():
@@ -89,19 +91,21 @@ class BaseConfig:
return self._config return self._config
class WhitelistConfig(BaseConfig): class ListConfig(BaseConfig):
JSON_SCHEMA = { JSON_SCHEMA = {
"type": "object", "type": "object",
"required": ["type"], "required": ["type"],
"additionalProperties": True, "additionalProperties": True,
"properties": { "properties": {
"type": {"enum": ["db"]}}, "type": {"enum": ["db"]},
"name": {"type": "string"}},
"if": {"properties": {"type": {"const": "db"}}}, "if": {"properties": {"type": {"const": "db"}}},
"then": { "then": {
"required": ["connection", "table"], "required": ["connection", "table"],
"additionalProperties": False, "additionalProperties": False,
"properties": { "properties": {
"type": {"type": "string"}, "type": {"type": "string"},
"name": {"type": "string"},
"connection": {"type": "string"}, "connection": {"type": "string"},
"table": {"type": "string"}}}} "table": {"type": "string"}}}}
@@ -121,14 +125,16 @@ class ConditionsConfig(BaseConfig):
"headers": {"type": "array", "headers": {"type": "array",
"items": {"type": "string"}}, "items": {"type": "string"}},
"var": {"type": "string"}, "var": {"type": "string"},
"whitelist": {"type": "object"}}} "list": {"type": "string"}}}
def __init__(self, config, rec=True): def __init__(self, config, lists, rec=True):
super().__init__(config) super().__init__(config)
if not rec: if "list" in self:
return lst = self["list"]
if "whitelist" in self: try:
self["whitelist"] = WhitelistConfig(self["whitelist"]) self["list"] = lists[lst]
except KeyError:
raise RuntimeError(f"list '{lst}' not found in config")
class AddHeaderConfig(BaseConfig): class AddHeaderConfig(BaseConfig):
@@ -184,7 +190,7 @@ class RewriteLinksConfig(BaseConfig):
"repl": {"type": "string"}}} "repl": {"type": "string"}}}
class StoreConfig(BaseConfig): class StorageConfig(BaseConfig):
JSON_SCHEMA = { JSON_SCHEMA = {
"type": "object", "type": "object",
"required": ["type"], "required": ["type"],
@@ -204,7 +210,24 @@ class StoreConfig(BaseConfig):
"original": {"type": "boolean", "default": False}}}} "original": {"type": "boolean", "default": False}}}}
class NotifyConfig(BaseConfig): class StoreConfig(BaseConfig):
JSON_SCHEMA = {
"type": "object",
"required": ["storage"],
"additionalProperties": False,
"properties": {
"storage": {"type": "string"}}}
def __init__(self, config, milter_config):
super().__init__(config)
storage = self["storage"]
try:
self["storage"] = milter_config["storages"][storage]
except KeyError:
raise RuntimeError(f"storage '{storage}' not found")
class NotificationConfig(BaseConfig):
JSON_SCHEMA = { JSON_SCHEMA = {
"type": "object", "type": "object",
"required": ["type"], "required": ["type"],
@@ -232,33 +255,62 @@ class NotifyConfig(BaseConfig):
"default": []}}}} "default": []}}}}
class NotifyConfig(BaseConfig):
JSON_SCHEMA = {
"type": "object",
"required": ["notification"],
"additionalProperties": False,
"properties": {
"notification": {"type": "string"}}}
def __init__(self, config, milter_config):
super().__init__(config)
notification = self["notification"]
try:
self["notification"] = milter_config["notifications"][notification]
except KeyError:
raise RuntimeError(f"notification '{notification}' not found")
class QuarantineConfig(BaseConfig): class QuarantineConfig(BaseConfig):
JSON_SCHEMA = { JSON_SCHEMA = {
"type": "object", "type": "object",
"required": ["store", "smtp_host", "smtp_port"], "required": ["storage", "smtp_host", "smtp_port"],
"additionalProperties": False, "additionalProperties": False,
"properties": { "properties": {
"name": {"type": "string"}, "name": {"type": "string"},
"notify": {"type": "object"}, "notification": {"type": "string"},
"milter_action": {"type": "string"}, "milter_action": {"type": "string"},
"reject_reason": {"type": "string"}, "reject_reason": {"type": "string"},
"whitelist": {"type": "object"}, "allowlist": {"type": "string"},
"store": {"type": "object"}, "storage": {"type": "string"},
"smtp_host": {"type": "string"}, "smtp_host": {"type": "string"},
"smtp_port": {"type": "number"}}} "smtp_port": {"type": "number"}}}
def __init__(self, config, rec=True): def __init__(self, config, milter_config, rec=True):
super().__init__(config) super().__init__(config)
storage = self["storage"]
try:
self["storage"] = milter_config["storages"][storage]
except KeyError:
raise RuntimeError(f"storage '{storage}' not found")
if "metadata" not in self["storage"]:
self["storage"]["metadata"] = True
if "notification" in self:
name = self["notification"]
try:
self["notification"] = milter_config["notifications"][name]
except KeyError:
raise RuntimeError(f"notification '{name}' not found")
if "allowlist" in self:
allowlist = self["allowlist"]
try:
self["allowlist"] = milter_config["lists"][allowlist]
except KeyError:
raise RuntimeError(f"list '{allowlist}' not found")
if not rec: if not rec:
return return
if "metadata" not in self["store"]:
self["store"]["metadata"] = True
self["store"] = StoreConfig(self["store"])
if "notify" in self:
self["notify"] = NotifyConfig(self["notify"])
if "whitelist" in self:
self["whitelist"] = ConditionsConfig(
{"whitelist": self["whitelist"]}, rec)
class ActionConfig(BaseConfig): class ActionConfig(BaseConfig):
@@ -284,13 +336,16 @@ class ActionConfig(BaseConfig):
"type": {"enum": list(ACTION_TYPES.keys())}, "type": {"enum": list(ACTION_TYPES.keys())},
"options": {"type": "object"}}} "options": {"type": "object"}}}
def __init__(self, config, rec=True): def __init__(self, config, milter_config, rec=True):
super().__init__(config) super().__init__(config)
if not rec: if not rec:
return return
lists = milter_config["lists"]
if "conditions" in self: if "conditions" in self:
self["conditions"] = ConditionsConfig(self["conditions"]) self["conditions"] = ConditionsConfig(self["conditions"], lists)
self["action"] = self.ACTION_TYPES[self["type"]](self["options"])
self["action"] = self.ACTION_TYPES[self["type"]](
self["options"], milter_config)
class RuleConfig(BaseConfig): class RuleConfig(BaseConfig):
@@ -305,20 +360,21 @@ class RuleConfig(BaseConfig):
"conditions": {"type": "object"}, "conditions": {"type": "object"},
"actions": {"type": "array"}}} "actions": {"type": "array"}}}
def __init__(self, config, rec=True): def __init__(self, config, milter_config, rec=True):
super().__init__(config) super().__init__(config)
if not rec: if not rec:
return return
lists = milter_config["lists"]
if "conditions" in self: if "conditions" in self:
self["conditions"] = ConditionsConfig(self["conditions"]) self["conditions"] = ConditionsConfig(self["conditions"], lists)
actions = [] actions = []
for idx, action in enumerate(self["actions"]): for action in self["actions"]:
if "loglevel" not in action: if "loglevel" not in action:
action["loglevel"] = config["loglevel"] action["loglevel"] = config["loglevel"]
if "pretend" not in action: if "pretend" not in action:
action["pretend"] = config["pretend"] action["pretend"] = config["pretend"]
actions.append(ActionConfig(action, rec)) actions.append(ActionConfig(action, milter_config, rec))
self["actions"] = actions self["actions"] = actions
@@ -340,23 +396,50 @@ class QuarantineMilterConfig(BaseConfig):
"192.168.0.0/16"]}, "192.168.0.0/16"]},
"loglevel": {"type": "string", "default": "info"}, "loglevel": {"type": "string", "default": "info"},
"pretend": {"type": "boolean", "default": False}, "pretend": {"type": "boolean", "default": False},
"lists": {
"type": "object",
"patternProperties": {"^(.+)$": {"type": "object"}},
"additionalProperties": False,
"default": {}},
"storages": {
"type": "object",
"patternProperties": {"^(.+)$": {"type": "object"}},
"additionalProperties": False,
"default": {}},
"notifications": {
"type": "object",
"patternProperties": {"^(.+)$": {"type": "object"}},
"additionalProperties": False,
"default": {}},
"rules": {"type": "array"}}} "rules": {"type": "array"}}}
def __init__(self, config, rec=True): def __init__(self, config, rec=True):
super().__init__(config) super().__init__(config)
for name, cfg in self["lists"].items():
if "name" not in cfg:
cfg["name"] = name
self["lists"][name] = ListConfig(cfg)
for name, cfg in self["storages"].items():
self["storages"][name] = StorageConfig(cfg)
for name, cfg in self["notifications"].items():
self["notifications"][name] = NotificationConfig(cfg)
if not rec: if not rec:
return return
rules = [] rules = []
for idx, rule in enumerate(self["rules"]): for rule in self["rules"]:
if "loglevel" not in rule: if "loglevel" not in rule:
rule["loglevel"] = config["loglevel"] rule["loglevel"] = config["loglevel"]
if "pretend" not in rule: if "pretend" not in rule:
rule["pretend"] = config["pretend"] rule["pretend"] = config["pretend"]
rules.append(RuleConfig(rule, rec)) rules.append(RuleConfig(rule, self, rec))
self["rules"] = rules self["rules"] = rules
def get_milter_config(cfgfile, raw=False): def get_milter_config(cfgfile, rec=True):
try: try:
with open(cfgfile, "r") as fh: with open(cfgfile, "r") as fh:
# remove lines with leading # (comments), they # remove lines with leading # (comments), they
@@ -371,6 +454,4 @@ def get_milter_config(cfgfile, raw=False):
cfg_text = [f"{n+1}: {l}" for n, l in enumerate(cfg.splitlines())] cfg_text = [f"{n+1}: {l}" for n, l in enumerate(cfg.splitlines())]
msg = "\n".join(cfg_text) msg = "\n".join(cfg_text)
raise RuntimeError(f"{e}\n{msg}") raise RuntimeError(f"{e}\n{msg}")
if raw: return QuarantineMilterConfig(cfg, rec)
return cfg
return QuarantineMilterConfig(cfg)

View File

@@ -13,8 +13,8 @@
# #
__all__ = [ __all__ = [
"DatabaseWhitelist", "DatabaseList",
"WhitelistBase"] "ListBase"]
import logging import logging
import peewee import peewee
@@ -24,8 +24,8 @@ from datetime import datetime
from playhouse.db_url import connect from playhouse.db_url import connect
class WhitelistBase: class ListBase:
"Whitelist base class" "List base class"
def __init__(self, cfg, debug): def __init__(self, cfg, debug):
self.cfg = cfg self.cfg = cfg
self.logger = logging.getLogger(cfg["name"]) self.logger = logging.getLogger(cfg["name"])
@@ -47,15 +47,15 @@ class WhitelistBase:
return self.batv_regex.sub(r"\g<LEFT_PART>@", addr, count=1) return self.batv_regex.sub(r"\g<LEFT_PART>@", addr, count=1)
def check(self, mailfrom, recipient): def check(self, mailfrom, recipient):
"Check if mailfrom/recipient combination is whitelisted." "Check if mailfrom/recipient combination is listed."
return return
def find(self, mailfrom=None, recipients=None, older_than=None): def find(self, mailfrom=None, recipients=None, older_than=None):
"Find whitelist entries." "Find list entries."
return return
def add(self, mailfrom, recipient, comment, permanent): def add(self, mailfrom, recipient, comment, permanent):
"Add entry to whitelist." "Add entry to list."
# check if mailfrom and recipient are valid # check if mailfrom and recipient are valid
if not self.valid_entry_regex.match(mailfrom): if not self.valid_entry_regex.match(mailfrom):
raise RuntimeError("invalid from address") raise RuntimeError("invalid from address")
@@ -63,12 +63,12 @@ class WhitelistBase:
raise RuntimeError("invalid recipient") raise RuntimeError("invalid recipient")
return return
def delete(self, whitelist_id): def delete(self, list_id):
"Delete entry from whitelist." "Delete entry from list."
return return
class WhitelistModel(peewee.Model): class DatabaseListModel(peewee.Model):
mailfrom = peewee.CharField() mailfrom = peewee.CharField()
recipient = peewee.CharField() recipient = peewee.CharField()
created = peewee.DateTimeField(default=datetime.now) created = peewee.DateTimeField(default=datetime.now)
@@ -84,9 +84,9 @@ class Meta:
) )
class DatabaseWhitelist(WhitelistBase): class DatabaseList(ListBase):
"Whitelist class to store whitelist in a database" "List class to store lists in a database"
whitelist_type = "db" list_type = "db"
_db_connections = {} _db_connections = {}
_db_tables = {} _db_tables = {}
@@ -96,8 +96,8 @@ class DatabaseWhitelist(WhitelistBase):
tablename = cfg["table"] tablename = cfg["table"]
connection_string = cfg["connection"] connection_string = cfg["connection"]
if connection_string in DatabaseWhitelist._db_connections.keys(): if connection_string in DatabaseList._db_connections.keys():
db = DatabaseWhitelist._db_connections[connection_string] db = DatabaseList._db_connections[connection_string]
else: else:
try: try:
# connect to database # connect to database
@@ -112,22 +112,22 @@ class DatabaseWhitelist(WhitelistBase):
raise RuntimeError( raise RuntimeError(
f"unable to connect to database: {e}") f"unable to connect to database: {e}")
DatabaseWhitelist._db_connections[connection_string] = db DatabaseList._db_connections[connection_string] = db
# generate model meta class # generate model meta class
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( self.model = type(
f"WhitelistModel_{self.cfg['name']}", f"DatabaseListModel_{self.cfg['name']}",
(WhitelistModel,), (DatabaseListModel,),
{"Meta": self.meta}) {"Meta": self.meta})
if connection_string not in DatabaseWhitelist._db_tables.keys(): if connection_string not in DatabaseList._db_tables.keys():
DatabaseWhitelist._db_tables[connection_string] = [] DatabaseList._db_tables[connection_string] = []
if tablename not in DatabaseWhitelist._db_tables[connection_string]: if tablename not in DatabaseList._db_tables[connection_string]:
DatabaseWhitelist._db_tables[connection_string].append(tablename) DatabaseList._db_tables[connection_string].append(tablename)
try: try:
db.create_tables([self.model]) db.create_tables([self.model])
except Exception as e: except Exception as e:
@@ -139,7 +139,7 @@ class DatabaseWhitelist(WhitelistBase):
for arg in ("connection", "table"): for arg in ("connection", "table"):
if arg in self.cfg: if arg in self.cfg:
cfg.append(f"{arg}={self.cfg[arg]}") cfg.append(f"{arg}={self.cfg[arg]}")
return "DatabaseWhitelist(" + ", ".join(cfg) + ")" return "DatabaseList(" + ", ".join(cfg) + ")"
def _entry_to_dict(self, entry): def _entry_to_dict(self, entry):
result = {} result = {}
@@ -164,14 +164,14 @@ class DatabaseWhitelist(WhitelistBase):
return value return value
def check(self, mailfrom, recipient, logger): def check(self, mailfrom, recipient, logger):
# check if mailfrom/recipient combination is whitelisted # check if mailfrom/recipient combination is listed
super().check(mailfrom, recipient) super().check(mailfrom, recipient)
mailfrom = self.remove_batv(mailfrom) mailfrom = self.remove_batv(mailfrom)
recipient = self.remove_batv(recipient) recipient = self.remove_batv(recipient)
# generate list of possible mailfroms # generate list of possible mailfroms
logger.debug( logger.debug(
f"query database for whitelist entries from <{mailfrom}> " f"query database for list entries from <{mailfrom}> "
f"to <{recipient}>") f"to <{recipient}>")
mailfroms = [""] mailfroms = [""]
if "@" in mailfrom and not mailfrom.startswith("@"): if "@" in mailfrom and not mailfrom.startswith("@"):
@@ -196,7 +196,7 @@ class DatabaseWhitelist(WhitelistBase):
raise RuntimeError(f"unable to query database: {e}") raise RuntimeError(f"unable to query database: {e}")
if not entries: if not entries:
# no whitelist entry found # no list entry found
return {} return {}
if len(entries) > 1: if len(entries) > 1:
@@ -213,7 +213,7 @@ class DatabaseWhitelist(WhitelistBase):
return result return result
def find(self, mailfrom=None, recipients=None, older_than=None): def find(self, mailfrom=None, recipients=None, older_than=None):
"Find whitelist entries." "Find list entries."
super().find(mailfrom, recipients, older_than) super().find(mailfrom, recipients, older_than)
if isinstance(mailfrom, str): if isinstance(mailfrom, str):
@@ -244,7 +244,7 @@ class DatabaseWhitelist(WhitelistBase):
return entries return entries
def add(self, mailfrom, recipient, comment, permanent): def add(self, mailfrom, recipient, comment, permanent):
"Add entry to whitelist." "Add entry to list."
super().add( super().add(
mailfrom, mailfrom,
recipient, recipient,
@@ -263,16 +263,16 @@ class DatabaseWhitelist(WhitelistBase):
except Exception as e: except Exception as e:
raise RuntimeError(f"unable to add entry to database: {e}") raise RuntimeError(f"unable to add entry to database: {e}")
def delete(self, whitelist_id): def delete(self, list_id):
"Delete entry from whitelist." "Delete entry from list."
super().delete(whitelist_id) super().delete(list_id)
try: try:
query = self.model.delete().where(self.model.id == whitelist_id) query = self.model.delete().where(self.model.id == list_id)
deleted = query.execute() deleted = query.execute()
except Exception as e: except Exception as e:
raise RuntimeError( raise RuntimeError(
f"unable to delete entry from database: {e}") f"unable to delete entry from database: {e}")
if deleted == 0: if deleted == 0:
raise RuntimeError("invalid whitelist id") raise RuntimeError("invalid list id")

View File

@@ -203,14 +203,14 @@ class EMailNotification(BaseNotification):
f"and its content") f"and its content")
element.extract() element.extract()
# remove not whitelisted elements, but keep their content # remove not allowed 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:
logger.debug( logger.debug(
f"removing tag '{element.name}', keep its content") f"removing tag '{element.name}', keep its content")
element.replaceWithChildren() element.replaceWithChildren()
# remove not whitelisted attributes # remove not allowed attributes
for element in soup.find_all(True): for element in soup.find_all(True):
for attribute in list(element.attrs.keys()): for attribute in list(element.attrs.keys()):
if attribute not in EMailNotification._good_attributes: if attribute not in EMailNotification._good_attributes:
@@ -223,7 +223,7 @@ class EMailNotification(BaseNotification):
logger.debug( logger.debug(
f"removing attribute '{attribute}' " f"removing attribute '{attribute}' "
f"from tag '{element.name}'") f"from tag '{element.name}'")
del(element.attrs[attribute]) del element.attrs[attribute]
return soup return soup
def notify(self, msg, qid, mailfrom, recipients, logger, def notify(self, msg, qid, mailfrom, recipients, logger,
@@ -317,6 +317,7 @@ class EMailNotification(BaseNotification):
def execute(self, milter, logger): def execute(self, milter, logger):
super().execute(milter, logger) super().execute(milter, logger)
logger.info(f"send notification(s) to {milter.msginfo['rcpts']}")
self.notify(msg=milter.msg, qid=milter.qid, self.notify(msg=milter.msg, qid=milter.qid,
mailfrom=milter.msginfo["mailfrom"], mailfrom=milter.msginfo["mailfrom"],
recipients=milter.msginfo["rcpts"], recipients=milter.msginfo["rcpts"],
@@ -333,16 +334,16 @@ class Notify:
self.logger = logging.getLogger(cfg["name"]) self.logger = logging.getLogger(cfg["name"])
self.logger.setLevel(cfg.get_loglevel(debug)) self.logger.setLevel(cfg.get_loglevel(debug))
nodification_type = cfg["options"]["type"] nodification_type = cfg["options"]["notification"]["type"]
del cfg["options"]["type"] del cfg["options"]["notification"]["type"]
cfg["options"]["pretend"] = cfg["pretend"]
self._notification = self.NOTIFICATION_TYPES[nodification_type]( ncfg = cfg["options"]["notification"]
**cfg["options"]) self._notification = self.NOTIFICATION_TYPES[nodification_type](**ncfg)
self._headersonly = self._notification._headersonly self._headersonly = self._notification._headersonly
def __str__(self): def __str__(self):
cfg = [] cfg = []
for key, value in self.cfg["options"].items(): for key, value in self.cfg["options"]["notification"].items():
cfg.append(f"{key}={value}") cfg.append(f"{key}={value}")
class_name = type(self._notification).__name__ class_name = type(self._notification).__name__
return f"{class_name}(" + ", ".join(cfg) + ")" return f"{class_name}(" + ", ".join(cfg) + ")"

View File

@@ -46,8 +46,9 @@ class Rule:
actions = [] actions = []
for action in self.actions: for action in self.actions:
actions.append(str(action)) actions.append(str(action))
cfg.append("actions=[" + ", ".join(actions) + "]") cfg.append("actions=[\n " +
return "Rule(" + ", ".join(cfg) + ")" ",\n ".join(actions) + "\n ]")
return "Rule(\n " + ",\n ".join(cfg) + "\n)"
def execute(self, milter): def execute(self, milter):
"""Execute all actions of this rule.""" """Execute all actions of this rule."""

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")

View File

@@ -31,8 +31,7 @@ from time import gmtime
from pyquarantine import mailer from pyquarantine import mailer
from pyquarantine.base import CustomLogger, MilterMessage from pyquarantine.base import CustomLogger, MilterMessage
from pyquarantine.conditions import Conditions from pyquarantine.lists import DatabaseList
from pyquarantine.config import ActionConfig
from pyquarantine.notify import Notify from pyquarantine.notify import Notify
@@ -334,7 +333,7 @@ class FileMailStorage(BaseMailStorage):
metafile, _ = self._get_file_paths(storage_id) metafile, _ = self._get_file_paths(storage_id)
if type(recipients) == str: if isinstance(recipients, str):
recipients = [recipients] recipients = [recipients]
for recipient in recipients: for recipient in recipients:
@@ -375,16 +374,16 @@ class Store:
self.logger = logging.getLogger(cfg["name"]) self.logger = logging.getLogger(cfg["name"])
self.logger.setLevel(cfg.get_loglevel(debug)) self.logger.setLevel(cfg.get_loglevel(debug))
storage_type = cfg["options"]["type"] storage_type = cfg["options"]["storage"]["type"]
del cfg["options"]["type"] del cfg["options"]["storage"]["type"]
cfg["options"]["pretend"] = cfg["pretend"]
self._storage = self.STORAGE_TYPES[storage_type]( scfg = cfg["options"]["storage"]
**cfg["options"]) self._storage = self.STORAGE_TYPES[storage_type](**scfg)
self._headersonly = self._storage._headersonly self._headersonly = self._storage._headersonly
def __str__(self): def __str__(self):
cfg = [] cfg = []
for key, value in self.cfg["options"].items(): for key, value in self.cfg["options"]["storage"].items():
cfg.append(f"{key}={value}") cfg.append(f"{key}={value}")
class_name = type(self._storage).__name__ class_name = type(self._storage).__name__
return f"{class_name}(" + ", ".join(cfg) + ")" return f"{class_name}(" + ", ".join(cfg) + ")"
@@ -407,36 +406,24 @@ class Quarantine:
self.logger = logging.getLogger(cfg["name"]) self.logger = logging.getLogger(cfg["name"])
self.logger.setLevel(cfg.get_loglevel(debug)) self.logger.setLevel(cfg.get_loglevel(debug))
storage_cfg = ActionConfig({ self._storage = Store(cfg, local_addrs, debug)
"name": cfg["name"],
"loglevel": cfg["loglevel"],
"pretend": cfg["pretend"],
"type": "store",
"options": cfg["options"]["store"].get_config()})
self._storage = Store(storage_cfg, local_addrs, debug)
self.smtp_host = cfg["options"]["smtp_host"] self.smtp_host = cfg["options"]["smtp_host"]
self.smtp_port = cfg["options"]["smtp_port"] self.smtp_port = cfg["options"]["smtp_port"]
self._notification = None self._notification = None
if "notify" in cfg["options"]: if "notification" in cfg["options"]:
notify_cfg = ActionConfig({ self._notification = Notify(cfg, local_addrs, debug)
"name": cfg["name"],
"loglevel": cfg["loglevel"],
"pretend": cfg["pretend"],
"type": "notify",
"options": cfg["options"]["notify"].get_config()})
self._notification = Notify(notify_cfg, local_addrs, debug)
self._whitelist = None self._allowlist = None
if "whitelist" in cfg["options"]: if "allowlist" in cfg["options"]:
whitelist_cfg = cfg["options"]["whitelist"] allowlist = cfg["options"]["allowlist"]
whitelist_cfg["name"] = cfg["name"] if allowlist["type"] == "db":
whitelist_cfg["loglevel"] = cfg["loglevel"] allowlist["name"] = f"{cfg['name']}: allowlist"
self._whitelist = Conditions( allowlist["loglevel"] = cfg["loglevel"]
whitelist_cfg, self._allowlist = DatabaseList(allowlist, debug)
local_addrs=[], else:
debug=debug) raise RuntimeError("invalid allowlist type")
self._milter_action = None self._milter_action = None
if "milter_action" in cfg["options"]: if "milter_action" in cfg["options"]:
@@ -456,8 +443,8 @@ class Quarantine:
cfg.append(f"store={str(self._storage)}") cfg.append(f"store={str(self._storage)}")
if self._notification is not None: if self._notification is not None:
cfg.append(f"notify={str(self._notification)}") cfg.append(f"notify={str(self._notification)}")
if self._whitelist is not None: if self._allowlist is not None:
cfg.append(f"whitelist={str(self._whitelist)}") cfg.append(f"allowlist={str(self._allowlist)}")
for key in ["milter_action", "reject_reason"]: for key in ["milter_action", "reject_reason"]:
if key not in self.cfg["options"]: if key not in self.cfg["options"]:
continue continue
@@ -481,10 +468,8 @@ class Quarantine:
return self._notification.get_notification() return self._notification.get_notification()
@property @property
def whitelist(self): def allowlist(self):
if self._whitelist is None: return self._allowlist
return None
return self._whitelist.get_whitelist()
@property @property
def milter_action(self): def milter_action(self):
@@ -512,7 +497,7 @@ class Quarantine:
def release(self, storage_id, recipients=None): def release(self, storage_id, recipients=None):
metadata, msg = self.storage.get_mail(storage_id) metadata, msg = self.storage.get_mail(storage_id)
if recipients and type(recipients) == str: if recipients and isinstance(recipients, str):
recipients = [recipients] recipients = [recipients]
else: else:
recipients = metadata["recipients"] recipients = metadata["recipients"]
@@ -535,31 +520,51 @@ class Quarantine:
return recipients return recipients
def copy(self, storage_id, recipient):
metadata, msg = self.storage.get_mail(storage_id)
try:
mailer.smtp_send(
self.smtp_host,
self.smtp_port,
metadata["mailfrom"],
recipient,
msg.as_string())
except Exception as e:
raise RuntimeError(
f"error while sending message to '{recipient}': {e}")
def execute(self, milter): def execute(self, milter):
logger = CustomLogger( logger = CustomLogger(
self.logger, {"name": self.cfg["name"], "qid": milter.qid}) self.logger, {"name": self.cfg["name"], "qid": milter.qid})
rcpts = milter.msginfo["rcpts"] rcpts = milter.msginfo["rcpts"]
wl_rcpts = [] allowed_rcpts = []
if self._whitelist: if self._allowlist:
wl_rcpts = self._whitelist.get_wl_rcpts( allowed_rcpts = []
milter.msginfo["mailfrom"], rcpts, logger) for rcpt in rcpts:
if wl_rcpts: if self._allowlist.check(
logger.info(f"whitelisted recipients: {wl_rcpts}") milter.msginfo["mailfrom"], rcpt, logger):
rcpts = [rcpt for rcpt in rcpts if rcpt not in wl_rcpts] allowed_rcpts.append(rcpt)
if allowed_rcpts:
logger.info(f"allowed recipients: {allowed_rcpts}")
rcpts = [rcpt for rcpt in rcpts if rcpt not in allowed_rcpts]
if not rcpts: if not rcpts:
# all recipients whitelisted # all recipients allowed
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}")
else:
logger.info(f"save message for {rcpts}")
self._storage.execute(milter) self._storage.execute(milter)
if self._notification is not None: if self._notification is not None:
self._notification.execute(milter) self._notification.execute(milter)
milter.msginfo["rcpts"].extend(wl_rcpts) milter.msginfo["rcpts"].extend(allowed_rcpts)
if self._milter_action is not None: if self._milter_action is not None:
milter.delrcpt(rcpts) milter.delrcpt(rcpts)

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",
@@ -32,22 +32,6 @@ setup(name = "pyquarantine",
"pyquarantine=pyquarantine.cli:main", "pyquarantine=pyquarantine.cli:main",
] ]
}, },
data_files = [ install_requires = ["pymilter >= 1.5", "jsonschema", "netaddr", "beautifulsoup4[lxml]", "peewee"],
( python_requires = ">=3.10"
"/etc/pyquarantine",
[
"pyquarantine/misc/pyquarantine.conf.default"
]
), (
"/etc/pyquarantine/templates",
[
"pyquarantine/misc/templates/disclaimer_html.template",
"pyquarantine/misc/templates/disclaimer_text.template",
"pyquarantine/misc/templates/notification.template",
"pyquarantine/misc/templates/removed.png"
]
)
],
install_requires = ["pymilter", "jsonschema", "netaddr", "beautifulsoup4[lxml]", "peewee"],
python_requires = ">=3.8"
) )