diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index b96e205..36a7447 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -66,6 +66,7 @@ schleuder: # Run a second time - should be idempotent and not trigger any new mails - python3 -m coverage run --rcfile=setup.cfg -m multischleuder --config test/multischleuder.yml --verbose - sleep 5 # wait for mail delivery + - cat /var/spool/mail/root - test/report.py - kill -9 ${API_DAEMON_PID} || true - /usr/sbin/postmulti -i - -p stop diff --git a/multischleuder/api.py b/multischleuder/api.py index 92a8d17..d879dfd 100644 --- a/multischleuder/api.py +++ b/multischleuder/api.py @@ -64,6 +64,19 @@ class SchleuderApi: lists = self.__request('lists.json') return [SchleuderList.from_api(**s) for s in lists] + def get_list_admins(self, schleuder: SchleuderList) -> List[SchleuderSubscriber]: + response = self.__request('subscriptions.json', schleuder.id) + admins: List[SchleuderSubscriber] = [] + for r in response: + if not r['admin']: + continue + key: Optional[SchleuderKey] = None + if r['fingerprint']: + key = self.get_key(r['fingerprint'], schleuder) + admin = SchleuderSubscriber.from_api(key, **r) + admins.append(admin) + return admins + # Subscriber Management def get_subscribers(self, schleuder: SchleuderList) -> List[SchleuderSubscriber]: diff --git a/multischleuder/conflict.py b/multischleuder/conflict.py index 2e48f16..0b3bef4 100644 --- a/multischleuder/conflict.py +++ b/multischleuder/conflict.py @@ -1,5 +1,5 @@ -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Tuple import email.mime.base import email.mime.application @@ -9,196 +9,121 @@ import email.utils import hashlib import json import logging +import os import struct from collections import OrderedDict from datetime import datetime import pgpy # type: ignore +from multischleuder.reporting import ConflictMessage from multischleuder.types import SchleuderKey, SchleuderSubscriber -from multischleuder.smtp import SmtpClient - - -class ConflictMessage: - - def __init__(self, - schleuder: str, - chosen: SchleuderSubscriber, - affected: List[SchleuderSubscriber], - mail_from: str, - template: str): - self._schleuder: str = schleuder - self._chosen: SchleuderSubscriber = chosen - self._affected: List[SchleuderSubscriber] = affected - self._template: str = template - self._from: str = mail_from - # Generate a SHA1 digest that only changes when the subscription list changes - self._digest = self._make_digest() - - def _make_digest(self) -> str: - # Sort so the hash stays the same if the set of subscriptions is the same. - # There is no guarantee that the subs are in any specific order. - subs: List[SchleuderSubscriber] = list(self._affected) - subs.sort(key=lambda x: x.schleuder) - h = hashlib.new('sha1') - # Include the chosen email an source sub-list - h.update(struct.pack('!sd', - self._chosen.email.encode(), - self._chosen.schleuder)) - # Include all subscriptions, including the FULL key - for s in subs: - key = b'N/A' - if s.key is not None: - key = s.key.blob.encode() - h.update(struct.pack('!ds', s.schleuder, key)) - return h.hexdigest() - - @property - def digest(self) -> str: - return self._digest - - @property - def mime(self) -> email.mime.base.MIMEBase: - # Render the message body - fpr = 'N/A' if self._chosen.key is None else self._chosen.key.fingerprint - _chosen = f'{fpr} {self._chosen.email}' - _affected = '' - for affected in self._affected: - fpr = 'N/A' if affected.key is None else affected.key.fingerprint - _affected += f'{fpr} {affected.schleuder}\n' - msg: str = self._template.format( - subscriber=self._chosen.email, - schleuder=self._schleuder, - chosen=_chosen, - affected=_affected - ) - # Encrypt to all keys, if possible. Fall back to unencrypted otherwise - PGPy does not - # support every possible key algorithm yet, esp. it can't encrypt to ed25519 keys. - try: - mime: email.mime.base.MIMEBase = self._encrypt_message(msg) - except Exception: - mime = email.mime.text.MIMEText(msg, _subtype='plain', _charset='utf-8') - # Set all the email headers - mime['Subject'] = f'MultiSchleuder {self._schleuder} - Key Conflict' - mime['From'] = self._from - mime['Reply-To'] = self._from - mime['To'] = self._chosen.email - mime['Date'] = email.utils.formatdate() - mime['Auto-Submitted'] = 'auto-generated' - mime['Precedence'] = 'list' - mime['List-Id'] = f'<{self._schleuder.replace("@", ".")}>' - mime['List-Help'] = '' - mime['X-MultiSchleuder-Digest'] = self._digest - return mime - - def _encrypt_message(self, msg: str) -> email.mime.base.MIMEBase: - pgp = pgpy.PGPMessage.new(msg) - # Encrypt the message to all keys - cipher = pgpy.constants.SymmetricKeyAlgorithm.AES256 - sessionkey = cipher.gen_key() - try: - for affected in self._affected: - if affected.key is not None: - key, _ = pgpy.PGPKey.from_blob(affected.key.blob) - key._require_usage_flags = False - pgp = key.encrypt(pgp, cipher=cipher, sessionkey=sessionkey) - finally: - del sessionkey - # Build the MIME message - # First the small "version" part ... - mp1 = email.mime.application.MIMEApplication('Version: 1', _subtype='pgp-encrypted') - mp1['Content-Description'] = 'PGP/MIME version identification' - mp1['Content-Disposition'] = 'attachment' - # ... then the actual encrypted payload ... - mp2 = email.mime.application.MIMEApplication(str(pgp), _subtype='octet-stream', name='encrypted.asc') - mp2['Content-Description'] = 'OpenPGP encrypted message' - mp2['Content-Disposition'] = 'inline; filename="message.asc"' - # ... and finally the root multipart container - mp0 = email.mime.multipart.MIMEMultipart(_subtype='encrypted', protocol='application/pgp-encrypted') - mp0.attach(mp1) - mp0.attach(mp2) - return mp0 class KeyConflictResolution: - def __init__(self, smtp: SmtpClient, interval: int, statefile: str, template: str): - self._smtp = smtp + def __init__(self, interval: int, statefile: str, template: str): self._interval: int = interval self._state_file: str = statefile self._template: str = template - self._messages: List[ConflictMessage] = [] + self._dry_run: bool = False self._logger: logging.Logger = logging.getLogger() + def dry_run(self): + self._dry_run = True + def resolve(self, target: str, mail_from: str, - subscriptions: List[SchleuderSubscriber]) -> List[SchleuderSubscriber]: + subscriptions: List[SchleuderSubscriber]) -> Tuple[List[SchleuderSubscriber], List[ConflictMessage]]: subs: Dict[str, List[SchleuderSubscriber]] = OrderedDict() for s in subscriptions: subs.setdefault(s.email, []).append(s) # Perform conflict resolution for each set of subscribers with the same email - resolved = [self._resolve(target, mail_from, s) for s in subs.values()] - return [r for r in resolved if r is not None] + resolved: List[SchleuderSubscriber] = [] + conflicts: List[ConflictMessage] = [] + for c in subs.values(): + r, m = self._resolve(target, mail_from, c) + if r is not None: + resolved.append(r) + if m is not None: + conflicts.append(m) + return resolved, conflicts def _resolve(self, target: str, mail_from: str, - subscriptions: List[SchleuderSubscriber]) -> Optional[SchleuderSubscriber]: + subscriptions: List[SchleuderSubscriber]) \ + -> Tuple[Optional[SchleuderSubscriber], Optional[ConflictMessage]]: notnull = [s for s in subscriptions if s.key is not None] if len(notnull) == 0: - return None + return None, None if len({s.key.blob for s in subscriptions if s.key is not None}) == 1: # No conflict if all keys are the same - return notnull[0] + return notnull[0], None # Conflict Resolution: Choose the OLDEST subscription with a key, but notify using ALL keys earliest: SchleuderSubscriber = min(notnull, key=lambda x: x.created_at) assert earliest.key is not None # Make mypy happy; it can't know that earliest.key can't be None self._logger.debug(f'Key Conflict for {earliest.email} in lists, chose {earliest.key.fingerprint}:') for s in subscriptions: - fpr = 'N/A' if s.key is None else s.key.fingerprint + fpr = 'no key' if s.key is None else s.key.fingerprint self._logger.debug(f' - {s.schleuder}: {fpr}') - # At this point, the messages are only queued locally, they will be sent afterwards - msg = ConflictMessage( - target, - earliest, - subscriptions, - mail_from, - self._template - ) - self._messages.append(msg) + # Generate a SHA1 digest that only changes when the subscription list changes + digest = self._make_digest(earliest, subscriptions) + msg: Optional[ConflictMessage] = None + # Create a conflict message only if it hasn't been sent recently + if self._should_send(digest): + msg = ConflictMessage( + target, + earliest, + subscriptions, + digest, + mail_from, + self._template + ) # Return the result of conflict resolution - return earliest + return earliest, msg - def send_messages(self, dry_run: bool = False): + def _should_send(self, digest: str) -> bool: now = int(datetime.utcnow().timestamp()) + exists = os.path.exists(self._state_file) with open(self._state_file, 'a+') as f: f.seek(0) - try: - state: Dict[str, int] = json.load(f) - except BaseException: - self._logger.exception('Cannot read statefile. WARNING: This could lead to spamming') - # TODO: This could lead to a situation where multischleuder becomes a spammer - state = {} + if not exists: + state: Dict[str, int] = {} + else: + try: + state = json.load(f) + except BaseException: + self._logger.exception('Cannot read statefile. Not sending any messages!') + return False # Remove all state entries older than conflict_interval state = {k: v for k, v in state.items() if now-v < self._interval} - # Remove all messages not already sent recently - msgs = [m for m in self._messages if m.digest not in state] + # Should send if it has not been sent before or has been removed in the line above + send = digest not in state # Add all remaining messages to state dict - for m in msgs: - state[m.digest] = now + if send: + state[digest] = now # Write the new state to file - if not dry_run: + if not self._dry_run: f.seek(0) f.truncate() json.dump(state, f) - # Finally send the mails - if len(msgs) > 0 and not dry_run: - with self._smtp as smtp: - for m in msgs: - msg = m.mime - self._logger.debug(f'MIME Message:\n{str(m)}') - self._logger.info(f'Sending key conflict message to {msg["To"]}') - smtp.send_message(msg) - # Clear conflict messages - self._messages = [] + return send + + def _make_digest(self, chosen: SchleuderSubscriber, candidates: List[SchleuderSubscriber]) -> str: + # Sort so the hash stays the same if the set of subscriptions is the same. + # There is no guarantee that the subs are in any specific order. + subs: List[SchleuderSubscriber] = sorted(candidates, key=lambda x: x.schleuder) + h = hashlib.new('sha1') + # Include the chosen email an source sub-list + h.update(struct.pack('!sd', + chosen.email.encode(), + chosen.schleuder)) + # Include all subscriptions, including the FULL key + for s in subs: + key = b'no key' + if s.key is not None: + key = s.key.blob.encode() + h.update(struct.pack('!ds', s.schleuder, key)) + return h.hexdigest() diff --git a/multischleuder/main.py b/multischleuder/main.py index e71e27c..d6090c7 100644 --- a/multischleuder/main.py +++ b/multischleuder/main.py @@ -16,6 +16,7 @@ from multischleuder.smtp import SmtpClient def parse_list_config(api: SchleuderApi, kcr: KeyConflictResolution, + smtp: SmtpClient, config: Dict[str, Any]) -> 'MultiList': target = config['target'] default_from = target.replace('@', '-owner@') @@ -29,7 +30,10 @@ def parse_list_config(api: SchleuderApi, unmanaged=unmanaged, mail_from=mail_from, api=api, - kcr=kcr + kcr=kcr, + smtp=smtp, + send_admin_reports=config.get('send_admin_reports', True), + send_conflict_messages=config.get('send_conflict_messages', True), ) @@ -38,18 +42,21 @@ def parse_config(ns: argparse.Namespace) -> List['MultiList']: c = yaml.safe_load(f) api = SchleuderApi(**c['api']) - if ns.dry_run: - api.dry_run() smtp_config = c.get('smtp', {}) smtp = SmtpClient.parse(smtp_config) kcr_config = c.get('conflict', {}) - kcr = KeyConflictResolution(smtp, **kcr_config) + kcr = KeyConflictResolution(**kcr_config) + + if ns.dry_run: + api.dry_run() + kcr.dry_run() + smtp.dry_run() lists = [] for clist in c.get('lists', []): - ml = parse_list_config(api, kcr, clist) + ml = parse_list_config(api, kcr, smtp, clist) lists.append(ml) return lists diff --git a/multischleuder/processor.py b/multischleuder/processor.py index 496c5d3..a4bf2e5 100644 --- a/multischleuder/processor.py +++ b/multischleuder/processor.py @@ -5,6 +5,8 @@ import logging from multischleuder.api import SchleuderApi from multischleuder.conflict import KeyConflictResolution +from multischleuder.reporting import AdminReport, Message +from multischleuder.smtp import SmtpClient from multischleuder.types import SchleuderKey, SchleuderList, SchleuderSubscriber @@ -17,7 +19,10 @@ class MultiList: banned: List[str], mail_from: str, api: SchleuderApi, - kcr: KeyConflictResolution): + kcr: KeyConflictResolution, + smtp: SmtpClient, + send_admin_reports: bool, + send_conflict_messages: bool): self._sources: List[str] = sources self._target: str = target self._unmanaged: List[str] = unmanaged @@ -25,11 +30,16 @@ class MultiList: self._mail_from: str = mail_from self._api: SchleuderApi = api self._kcr: KeyConflictResolution = kcr + self._smtp: SmtpClient = smtp + self._send_admin_reports: bool = send_admin_reports + self._send_conflict_messages: bool = send_conflict_messages + self._messages: List[Message] = [] self._logger: logging.Logger = logging.getLogger() def process(self, dry_run: bool = False): self._logger.info(f'Processing: {self._target} {"DRY RUN" if dry_run else ""}') target_list, sources = self._lists_by_name() + target_admins = self._api.get_list_admins(target_list) # Get current subs, except for unmanaged adresses current_subs: Set[SchleuderSubscriber] = { s @@ -46,7 +56,9 @@ class MultiList: continue all_subs.append(s) # ... which is taken care of by the key conflict resolution routine - resolved = self._kcr.resolve(self._target, self._mail_from, all_subs) + resolved, conflicts = self._kcr.resolve(self._target, self._mail_from, all_subs) + if self._send_conflict_messages: + self._messages.extend(conflicts) intended_subs: Set[SchleuderSubscriber] = set(resolved) intended_keys: Set[SchleuderKey] = {s.key for s in intended_subs if s.key is not None} # Determine the change set @@ -71,12 +83,25 @@ class MultiList: for key in to_remove: self._api.delete_key(key, target_list) self._logger.info(f'Removed key: {key}') - # Finally, send any queued key conflict messages. - self._kcr.send_messages() + if len(to_add) + len(to_subscribe) + len(to_unsubscribe) + len(to_remove) == 0: self._logger.info(f'No changes for {self._target}') else: - self._logger.info(f'Finished processing: {self._target}') + if self._send_admin_reports: + for admin in target_admins: + report = AdminReport(self._target, admin.email, self._mail_from, + admin.key.blob if admin.key is not None else None, + to_subscribe, to_unsubscribe, to_update, to_add, to_remove) + self._messages.append(report) + print(str(report)) + + # Finally, send any queued messages. + if len(self._messages) > 0: + self._logger.info(f'Sending f{len(self._messages)} messages') + self._smtp.send_messages(self._messages) + self._messages = [] + + self._logger.info(f'Finished processing: {self._target}') def _lists_by_name(self) -> Tuple[SchleuderList, List[SchleuderList]]: lists = self._api.get_lists() diff --git a/multischleuder/reporting.py b/multischleuder/reporting.py new file mode 100644 index 0000000..47a22dd --- /dev/null +++ b/multischleuder/reporting.py @@ -0,0 +1,181 @@ + +from typing import Dict, List, Optional, Set + +import abc +import email.mime.base +import email.mime.application +import email.mime.multipart +import email.mime.text +import email.utils +import hashlib +import json +import logging +import struct +from collections import OrderedDict +from datetime import datetime + +import pgpy # type: ignore + +from multischleuder.types import SchleuderKey, SchleuderSubscriber + + +class Message(abc.ABC): + + def __init__(self, + schleuder: str, + mail_from: str, + mail_to: str, + content: str, + encrypt_to: List[str]): + self._schleuder: str = schleuder + self._from: str = mail_from + self._to: str = mail_to + self._keys: List[str] = encrypt_to + self._mime: email.mime.base.MIMEBase = self._make_mime(content) + + @property + def mime(self) -> email.mime.base.MIMEBase: + return self._mime + + def _make_mime(self, content: str) -> email.mime.base.MIMEBase: + # Encrypt to all keys, if possible. Fall back to unencrypted otherwise - PGPy does not + # support every possible key algorithm yet, esp. it can't encrypt to ed25519 keys. + try: + self._mime = self._encrypt_message(content) + except Exception: + self._mime = email.mime.text.MIMEText(content, _subtype='plain', _charset='utf-8') + # Set all the email headers + self._mime['From'] = self._from + self._mime['Reply-To'] = self._from + self._mime['To'] = self._to + self._mime['Date'] = email.utils.formatdate() + self._mime['Auto-Submitted'] = 'auto-generated' + self._mime['Precedence'] = 'list' + self._mime['List-Id'] = f'<{self._schleuder.replace("@", ".")}>' + self._mime['List-Help'] = '' + return self._mime + + def _encrypt_message(self, content: str) -> email.mime.base.MIMEBase: + if len(self._keys) == 0: + return email.mime.text.MIMEText(content, _subtype='plain', _charset='utf-8') + pgp = pgpy.PGPMessage.new(content) + # Encrypt the message to all keys + cipher = pgpy.constants.SymmetricKeyAlgorithm.AES256 + sessionkey = cipher.gen_key() + try: + for k in self._keys: + key, _ = pgpy.PGPKey.from_blob(k) + pgp = key.encrypt(pgp, cipher=cipher, sessionkey=sessionkey) + finally: + del sessionkey + # Build the MIME message + # First the small "version" part ... + mp1 = email.mime.application.MIMEApplication('Version: 1', _subtype='pgp-encrypted') + mp1['Content-Description'] = 'PGP/MIME version identification' + mp1['Content-Disposition'] = 'attachment' + # ... then the actual encrypted payload ... + mp2 = email.mime.application.MIMEApplication(str(pgp), _subtype='octet-stream', name='encrypted.asc') + mp2['Content-Description'] = 'OpenPGP encrypted message' + mp2['Content-Disposition'] = 'inline; filename="message.asc"' + # ... and finally the root multipart container + mp0 = email.mime.multipart.MIMEMultipart(_subtype='encrypted', protocol='application/pgp-encrypted') + mp0.attach(mp1) + mp0.attach(mp2) + return mp0 + + +class ConflictMessage(Message): + + def __init__(self, + schleuder: str, + chosen: SchleuderSubscriber, + affected: List[SchleuderSubscriber], + digest: str, + mail_from: str, + template: str): + # Render the message body + fpr = 'no key' if chosen.key is None else chosen.key.fingerprint + _chosen = f'{fpr} {chosen.email}' + _affected = '' + for a in affected: + fpr = 'no key' if a.key is None else a.key.fingerprint + _affected += f'{fpr} {a.schleuder}\n' + content = template.format( + subscriber=chosen.email, + schleuder=schleuder, + chosen=_chosen, + affected=_affected + ) + self._chosen: SchleuderSubscriber = chosen + self._affected: List[SchleuderSubscriber] = affected + self._template: str = template + super().__init__( + schleuder=schleuder, + mail_from=mail_from, + mail_to=chosen.email, + content=content, + encrypt_to=[s.key.blob for s in affected if s.key is not None] + ) + self.mime['Subject'] = f'MultiSchleuder {self._schleuder} - Key Conflict' + self.mime['X-MultiSchleuder-Digest'] = digest + + +class AdminReport(Message): + + def __init__(self, + schleuder: str, + mail_to: str, + mail_from: str, + encrypt_to: Optional[str], + subscribed: Set[SchleuderSubscriber], + unsubscribed: Set[SchleuderSubscriber], + updated: Set[SchleuderSubscriber], + added: Set[SchleuderKey], + removed: Set[SchleuderKey]): + if len(subscribed) == 0 and len(unsubscribed) == 0 and \ + len(removed) == 0 and len(added) == 0 and len(updated) == 0: + raise ValueError('No changes, not creating admin report') + content = f''' +== Admin Report for MultiSchleuder {schleuder} == +''' + if len(subscribed) > 0: + content += ''' +>>> Subscribed: +''' + for s in subscribed: + fpr = 'no key' if s.key is None else s.key.fingerprint + content += f'{s.email} ({fpr})\n' + if len(unsubscribed) > 0: + content += ''' +>>> Unsubscribed: +''' + for s in unsubscribed: + fpr = 'no key' if s.key is None else s.key.fingerprint + content += f'{s.email} ({fpr})\n' + if len(updated) > 0: + content += ''' +>>> Subscriber keys changed: +''' + for s in updated: + fpr = 'no key' if s.key is None else s.key.fingerprint + content += f'{s.email} ({fpr})\n' + if len(added) > 0: + content += ''' +>>> Keys added: +''' + for k in added: + content += f'{k.fingerprint} ({k.email})\n' + if len(removed) > 0: + content += ''' +>>> Keys deleted: +''' + for k in removed: + content += f'{k.fingerprint} ({k.email})\n' + super().__init__( + schleuder=schleuder, + mail_from=mail_from, + mail_to=mail_to, + content=content, + encrypt_to=[encrypt_to] if encrypt_to is not None else [] + ) + self.mime['Subject'] = f'MultiSchleuder Admin Report: {self._schleuder}' diff --git a/multischleuder/smtp.py b/multischleuder/smtp.py index 8d069b3..a9325e9 100644 --- a/multischleuder/smtp.py +++ b/multischleuder/smtp.py @@ -1,11 +1,13 @@ -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional import email import enum import logging import smtplib +from multischleuder.reporting import Message + class TlsMode(enum.Enum): PLAIN = 'smtp', 25 @@ -39,6 +41,7 @@ class SmtpClient: self._username: Optional[str] = username self._password: Optional[str] = password self._smtp: Optional[smtplib.SMTP] = None + self._dry_run: bool = False self._logger = logging.getLogger() @staticmethod @@ -52,10 +55,22 @@ class SmtpClient: password=config.get('password') ) + def dry_run(self): + self._dry_run = True + + def send_messages(self, messages: List[Message]): + if len(messages) > 0 and not self._dry_run: + with self as smtp: + for m in messages: + msg = m.mime + self._logger.debug(f'MIME Message:\n{str(msg)}') + self.send_message(msg) + def send_message(self, msg: email.message.Message): if self._smtp is None: raise RuntimeError('SMTP connection is not established') - self._smtp.send_message(msg) + if not self._dry_run: + self._smtp.send_message(msg) self._logger.debug(f'Sent email message.') def __enter__(self): diff --git a/multischleuder/test/test_api.py b/multischleuder/test/test_api.py index b4f5cb1..a4612c9 100644 --- a/multischleuder/test/test_api.py +++ b/multischleuder/test/test_api.py @@ -86,7 +86,7 @@ _SUBSCRIBER_RESPONSE_NOKEY = ''' "list_id": 42, "email": "andy.example@example.org", "fingerprint": "", - "admin": false, + "admin": true, "delivery_enabled": true, "created_at": "2022-04-15T01:11:12.123Z", "updated_at": "2022-04-15T01:11:12.123Z" @@ -144,6 +144,19 @@ class TestSchleuderApi(unittest.TestCase): self.assertEqual('Basic c2NobGV1ZGVyOjg2Y2YyNjc2ZDA2NWRjOTAyNTQ4ZTU2M2FiMjJiNTc4NjhlZDJlYjY=', mock.call_args[0][0].headers.get('Authorization')) + @patch('urllib.request.urlopen') + def test_get_list_admins(self, mock): + api = self._mock_api(mock) + admins = api.get_list_admins(SchleuderList(42, '', '')) + self.assertEqual(0, len(admins)) + api = self._mock_api(mock, nokey=True) + admins = api.get_list_admins(SchleuderList(42, '', '')) + self.assertEqual(1, len(admins)) + self.assertEqual(24, admins[0].id) + self.assertEqual('andy.example@example.org', admins[0].email) + self.assertEqual(42, admins[0].schleuder) + self.assertIsNone(admins[0].key) + @patch('urllib.request.urlopen') def test_get_subscribers(self, mock): api = self._mock_api(mock) diff --git a/multischleuder/test/test_conflict.py b/multischleuder/test/test_conflict.py index 83392d2..3c2d707 100644 --- a/multischleuder/test/test_conflict.py +++ b/multischleuder/test/test_conflict.py @@ -79,51 +79,36 @@ class TestKeyConflictResolution(unittest.TestCase): key2 = SchleuderKey(_PRIVKEY_2.fingerprint.replace(' ', ''), 'foo@example.org', str(_PRIVKEY_2.pubkey), sch2.id) date2 = datetime(2022, 4, 13, 5, 23, 42, 0, tzinfo=tzutc()) sub2 = SchleuderSubscriber(7, 'foo@example.org', key2, sch2.id, date2) + kcr = KeyConflictResolution(3600, '/tmp/state.json', _TEMPLATE) - msg1 = ConflictMessage( - schleuder='', - chosen=sub2, - affected=[sub1, sub2], - mail_from='', - template='') - msg2 = ConflictMessage( - schleuder='', - chosen=sub2, - affected=[sub2, sub1], - mail_from='', - template='') - msg3 = ConflictMessage( - schleuder='', - chosen=sub1, - affected=[sub2, sub1], - mail_from='', - template='') - msg4 = ConflictMessage( - schleuder='foo', - chosen=sub1, - affected=[sub2, sub1], - mail_from='bar', - template='baz') + digest1 = kcr._make_digest(sub2, [sub1, sub2]) + digest2 = kcr._make_digest(sub2, [sub2, sub1]) + digest3 = kcr._make_digest(sub1, [sub1, sub2]) + digest4 = kcr._make_digest(sub1, [sub2, sub1]) - self.assertEqual(msg1.digest, msg2.digest) - self.assertNotEqual(msg1.digest, msg3.digest) - self.assertEqual(msg3.digest, msg4.digest) + self.assertEqual(digest1, digest2) + self.assertNotEqual(digest1, digest3) + self.assertEqual(digest3, digest4) def test_empty(self): - kcr = KeyConflictResolution(None, 3600, '/tmp/state.json', _TEMPLATE) - self.assertEqual(0, len(kcr.resolve('', '', []))) - self.assertEqual(0, len(kcr._messages)) + kcr = KeyConflictResolution(3600, '/tmp/state.json', _TEMPLATE) + contents = io.StringIO(_CONFLICT_STATE_NONE) + with patch('builtins.open', mock_open(read_data=_CONFLICT_STATE_NONE)) as mock_statefile: + mock_statefile().__enter__.return_value = contents + resolved, messages = kcr.resolve('', '', []) + self.assertEqual(0, len(resolved)) + self.assertEqual(0, len(messages)) def test_one(self): sch1 = SchleuderList(42, 'test-north@schleuder.example.org', '474777DA74528A7021184C8A0017324A6CFFBF92') key1 = SchleuderKey(_PRIVKEY_1.fingerprint.replace(' ', ''), 'foo@example.org', str(_PRIVKEY_1.pubkey), sch1.id) date1 = datetime(2022, 4, 15, 5, 23, 42, 0, tzinfo=tzutc()) sub1 = SchleuderSubscriber(3, 'foo@example.org', key1, sch1.id, date1) - kcr = KeyConflictResolution(None, 3600, '/tmp/state.json', _TEMPLATE) - resolved = kcr.resolve('', '', [sub1]) + kcr = KeyConflictResolution(3600, '/tmp/state.json', _TEMPLATE) + resolved, messages = kcr.resolve('', '', [sub1]) self.assertEqual(1, len(resolved)) self.assertEqual(sub1, resolved[0]) - self.assertEqual(0, len(kcr._messages)) + self.assertEqual(0, len(messages)) def test_same_keys_conflict(self): sch1 = SchleuderList(42, 'test-north@schleuder.example.org', '474777DA74528A7021184C8A0017324A6CFFBF92') @@ -137,16 +122,19 @@ class TestKeyConflictResolution(unittest.TestCase): date2 = datetime(2022, 4, 13, 5, 23, 42, 0, tzinfo=tzutc()) sub2 = SchleuderSubscriber(7, 'foo@example.org', key2, sch2.id, date2) - kcr = KeyConflictResolution(None, 3600, '/tmp/state.json', _TEMPLATE) - resolved = kcr.resolve( - target='test@schleuder.example.org', - mail_from='test-owner@schleuder.example.org', - subscriptions=[sub1, sub2]) + kcr = KeyConflictResolution(3600, '/tmp/state.json', _TEMPLATE) + contents = io.StringIO(_CONFLICT_STATE_NONE) + with patch('builtins.open', mock_open(read_data=_CONFLICT_STATE_NONE)) as mock_statefile: + mock_statefile().__enter__.return_value = contents + resolved, messages = kcr.resolve( + target='test@schleuder.example.org', + mail_from='test-owner@schleuder.example.org', + subscriptions=[sub1, sub2]) self.assertEqual(1, len(resolved)) self.assertEqual('2FBBC0DF97FDBF1E4B704EEDE39EF4FAC420BEB6', resolved[0].key.fingerprint) # Same keys, no conflict message - self.assertEqual(0, len(kcr._messages)) + self.assertEqual(0, len(messages)) def test_different_keys_conflict(self): sch1 = SchleuderList(42, 'test-north@schleuder.example.org', '474777DA74528A7021184C8A0017324A6CFFBF92') @@ -160,17 +148,20 @@ class TestKeyConflictResolution(unittest.TestCase): date2 = datetime(2022, 4, 13, 5, 23, 42, 0, tzinfo=tzutc()) sub2 = SchleuderSubscriber(7, 'foo@example.org', key2, sch2.id, date2) - kcr = KeyConflictResolution(None, 3600, '/tmp/state.json', _TEMPLATE) - resolved = kcr.resolve( - target='test@schleuder.example.org', - mail_from='test-owner@schleuder.example.org', - subscriptions=[sub1, sub2]) + kcr = KeyConflictResolution(3600, '/tmp/state.json', _TEMPLATE) + contents = io.StringIO(_CONFLICT_STATE_NONE) + with patch('builtins.open', mock_open(read_data=_CONFLICT_STATE_NONE)) as mock_statefile: + mock_statefile().__enter__.return_value = contents + resolved, messages = kcr.resolve( + target='test@schleuder.example.org', + mail_from='test-owner@schleuder.example.org', + subscriptions=[sub1, sub2]) self.assertEqual(1, len(resolved)) self.assertEqual('135AFA0FB3FF584828911208B7913308392972A4', resolved[0].key.fingerprint) # Different keys should trigger a conflict message - self.assertEqual(1, len(kcr._messages)) - msg = kcr._messages[0].mime + self.assertEqual(1, len(messages)) + msg = messages[0].mime pgp = pgpy.PGPMessage.from_blob(msg.get_payload()[1].get_payload(decode=True)) # Verify that the message is encrypted with both keys dec1 = _PRIVKEY_1.decrypt(pgp) @@ -193,28 +184,35 @@ class TestKeyConflictResolution(unittest.TestCase): date2 = datetime(2022, 4, 13, 5, 23, 42, 0, tzinfo=tzutc()) sub2 = SchleuderSubscriber(7, 'foo@example.org', None, sch2.id, date2) - kcr = KeyConflictResolution(None, 3600, '/tmp/state.json', _TEMPLATE) - resolved = kcr.resolve( - target='test@schleuder.example.org', - mail_from='test-owner@schleuder.example.org', - subscriptions=[sub1, sub2]) + kcr = KeyConflictResolution(3600, '/tmp/state.json', _TEMPLATE) + contents = io.StringIO(_CONFLICT_STATE_NONE) + with patch('builtins.open', mock_open(read_data=_CONFLICT_STATE_NONE)) as mock_statefile: + mock_statefile().__enter__.return_value = contents + resolved, messages = kcr.resolve( + target='test@schleuder.example.org', + mail_from='test-owner@schleuder.example.org', + subscriptions=[sub1, sub2]) self.assertEqual(1, len(resolved)) self.assertEqual('2FBBC0DF97FDBF1E4B704EEDE39EF4FAC420BEB6', resolved[0].key.fingerprint) # Null key should not trigger a confict - self.assertEqual(0, len(kcr._messages)) + self.assertEqual(0, len(messages)) def test_conflict_only_nullkeys(self): sch1 = SchleuderList(42, 'test-north@schleuder.example.org', '474777DA74528A7021184C8A0017324A6CFFBF92') date1 = datetime(2022, 4, 15, 5, 23, 42, 0, tzinfo=tzutc()) sub1 = SchleuderSubscriber(3, 'foo@example.org', None, sch1.id, date1) - kcr = KeyConflictResolution(None, 3600, '/tmp/state.json', _TEMPLATE) - resolved = kcr.resolve( - target='test@schleuder.example.org', - mail_from='test-owner@schleuder.example.org', - subscriptions=[sub1]) + kcr = KeyConflictResolution(3600, '/tmp/state.json', _TEMPLATE) + contents = io.StringIO(_CONFLICT_STATE_NONE) + with patch('builtins.open', mock_open(read_data=_CONFLICT_STATE_NONE)) as mock_statefile: + mock_statefile().__enter__.return_value = contents + resolved, messages = kcr.resolve( + target='test@schleuder.example.org', + mail_from='test-owner@schleuder.example.org', + subscriptions=[sub1]) self.assertEqual(0, len(resolved)) + self.assertEqual(0, len(messages)) def test_send_messages_nostate(self): sch1 = SchleuderList(42, 'test-north@schleuder.example.org', '474777DA74528A7021184C8A0017324A6CFFBF92') @@ -229,31 +227,31 @@ class TestKeyConflictResolution(unittest.TestCase): sub3 = SchleuderSubscriber(7, 'foo@example.org', key2, sch2.id, date2) sub4 = SchleuderSubscriber(8, 'bar@example.org', key2, sch2.id, date1) - mock_smtp = MagicMock() - mock_smtp.__enter__.return_value = mock_smtp - kcr = KeyConflictResolution(mock_smtp, 3600, '/tmp/state.json', _TEMPLATE) - resolved = kcr.resolve( - target='test@schleuder.example.org', - mail_from='test-owner@schleuder.example.org', - subscriptions=[sub1, sub2, sub3, sub4]) - self.assertEqual(2, len(kcr._messages)) - msgs = kcr._messages - - now = datetime.utcnow().timestamp() + kcr = KeyConflictResolution(3600, '/tmp/state.json', _TEMPLATE) contents = io.StringIO(_CONFLICT_STATE_NONE) with patch('builtins.open', mock_open(read_data=_CONFLICT_STATE_NONE)) as mock_statefile: mock_statefile().__enter__.return_value = contents - kcr.send_messages() + resolved, msgs = kcr.resolve( + target='test@schleuder.example.org', + mail_from='test-owner@schleuder.example.org', + subscriptions=[sub1, sub2, sub3, sub4]) + self.assertEqual(2, len(msgs)) + + now = datetime.utcnow().timestamp() mock_statefile.assert_called_with('/tmp/state.json', 'a+') - mock_smtp.__enter__.assert_called_once() - self.assertEqual(2, mock_smtp.send_message.call_count) contents.seek(0) state = json.loads(contents.read()) self.assertEqual(2, len(state)) - self.assertIn(msgs[0].digest, state) - self.assertIn(msgs[1].digest, state) - self.assertLess(now - state[msgs[0].digest], 60) - self.assertLess(now - state[msgs[1].digest], 60) + self.assertIn(msgs[0].mime['X-MultiSchleuder-Digest'], state) + self.assertIn(msgs[1].mime['X-MultiSchleuder-Digest'], state) + self.assertLess(now - state[msgs[0].mime['X-MultiSchleuder-Digest']], 60) + self.assertLess(now - state[msgs[0].mime['X-MultiSchleuder-Digest']], 60) + + # Todo: move this over to test_multilist + # mock_smtp = MagicMock() + # mock_smtp.__enter__.return_value = mock_smtp + # mock_smtp.__enter__.assert_called_once() + # self.assertEqual(2, mock_smtp.send_message.call_count) def test_send_messages_stalestate(self): sch1 = SchleuderList(42, 'test-north@schleuder.example.org', '474777DA74528A7021184C8A0017324A6CFFBF92') @@ -266,29 +264,24 @@ class TestKeyConflictResolution(unittest.TestCase): date2 = datetime(2022, 4, 13, 5, 23, 42, 0, tzinfo=tzutc()) sub2 = SchleuderSubscriber(7, 'foo@example.org', key2, sch2.id, date2) - mock_smtp = MagicMock() - mock_smtp.__enter__.return_value = mock_smtp - kcr = KeyConflictResolution(mock_smtp, 3600, '/tmp/state.json', _TEMPLATE) - resolved = kcr.resolve( - target='test@schleuder.example.org', - mail_from='test-owner@schleuder.example.org', - subscriptions=[sub1, sub2]) - self.assertEqual(1, len(kcr._messages)) - msg = kcr._messages[0] - - now = datetime.utcnow().timestamp() + kcr = KeyConflictResolution(3600, '/tmp/state.json', _TEMPLATE) contents = io.StringIO(_CONFLICT_STATE_STALE) with patch('builtins.open', mock_open(read_data=_CONFLICT_STATE_STALE)) as mock_statefile: mock_statefile().__enter__.return_value = contents - kcr.send_messages() + resolved, messages = kcr.resolve( + target='test@schleuder.example.org', + mail_from='test-owner@schleuder.example.org', + subscriptions=[sub1, sub2]) + self.assertEqual(1, len(messages)) + msg = messages[0] + + now = datetime.utcnow().timestamp() mock_statefile.assert_called_with('/tmp/state.json', 'a+') - mock_smtp.__enter__.assert_called_once() - self.assertEqual(1, mock_smtp.send_message.call_count) contents.seek(0) state = json.loads(contents.read()) self.assertEqual(1, len(state)) - self.assertIn(msg.digest, state) - self.assertLess(now - state[msg.digest], 60) + self.assertIn(msg.mime['X-MultiSchleuder-Digest'], state) + self.assertLess(now - state[msg.mime['X-MultiSchleuder-Digest']], 60) def test_send_messages_recentstate(self): sch1 = SchleuderList(42, 'test-north@schleuder.example.org', '474777DA74528A7021184C8A0017324A6CFFBF92') @@ -301,32 +294,25 @@ class TestKeyConflictResolution(unittest.TestCase): date2 = datetime(2022, 4, 13, 5, 23, 42, 0, tzinfo=tzutc()) sub2 = SchleuderSubscriber(7, 'foo@example.org', key2, sch2.id, date2) - mock_smtp = MagicMock() - mock_smtp.__enter__.return_value = mock_smtp - kcr = KeyConflictResolution(mock_smtp, 86400, '/tmp/state.json', _TEMPLATE) - resolved = kcr.resolve( - target='test@schleuder.example.org', - mail_from='test-owner@schleuder.example.org', - subscriptions=[sub1, sub2]) - self.assertEqual(1, len(kcr._messages)) - msg = kcr._messages[0] - - now = datetime.utcnow().timestamp() + kcr = KeyConflictResolution(86400, '/tmp/state.json', _TEMPLATE) contents = io.StringIO(_CONFLICT_STATE_RECENT) with patch('builtins.open', mock_open(read_data=_CONFLICT_STATE_RECENT)) as mock_statefile: mock_statefile().__enter__.return_value = contents - kcr.send_messages() + resolved, messages = kcr.resolve( + target='test@schleuder.example.org', + mail_from='test-owner@schleuder.example.org', + subscriptions=[sub1, sub2]) + self.assertEqual(0, len(messages)) + + now = datetime.utcnow().timestamp() mock_statefile.assert_called_with('/tmp/state.json', 'a+') - # No message should be sent - mock_smtp.__enter__.assert_not_called() - mock_smtp.send_message.assert_not_called() # Statefile should not have been updated contents.seek(0) state = json.loads(contents.read()) self.assertEqual(1, len(state)) - self.assertIn(msg.digest, state) - self.assertLess(now - state[msg.digest], 86460) - self.assertGreater(now - state[msg.digest], 60) + for then in state.values(): + self.assertLess(now - then, 86460) + self.assertGreater(now - then, 60) def test_send_messages_dryrun(self): sch1 = SchleuderList(42, 'test-north@schleuder.example.org', '474777DA74528A7021184C8A0017324A6CFFBF92') @@ -339,25 +325,20 @@ class TestKeyConflictResolution(unittest.TestCase): date2 = datetime(2022, 4, 13, 5, 23, 42, 0, tzinfo=tzutc()) sub2 = SchleuderSubscriber(7, 'foo@example.org', key2, sch2.id, date2) - mock_smtp = MagicMock() - mock_smtp.__enter__.return_value = mock_smtp - kcr = KeyConflictResolution(mock_smtp, 3600, '/tmp/state.json', _TEMPLATE) - resolved = kcr.resolve( - target='test@schleuder.example.org', - mail_from='test-owner@schleuder.example.org', - subscriptions=[sub1, sub2]) - self.assertEqual(1, len(kcr._messages)) - msg = kcr._messages[0] - - now = datetime.utcnow().timestamp() + kcr = KeyConflictResolution(3600, '/tmp/state.json', _TEMPLATE) + kcr.dry_run() contents = io.StringIO(_CONFLICT_STATE_STALE) with patch('builtins.open', mock_open(read_data=_CONFLICT_STATE_STALE)) as mock_statefile: mock_statefile().__enter__.return_value = contents - kcr.send_messages(dry_run=True) + resolved, messages = kcr.resolve( + target='test@schleuder.example.org', + mail_from='test-owner@schleuder.example.org', + subscriptions=[sub1, sub2]) + self.assertEqual(1, len(messages)) + + now = datetime.utcnow().timestamp() mock_statefile.assert_called_with('/tmp/state.json', 'a+') mock_statefile().write.assert_not_called() - mock_smtp.__enter__.assert_not_called() - mock_smtp.send_message.assert_not_called() contents.seek(0) self.assertEqual(_CONFLICT_STATE_STALE, contents.read()) @@ -376,15 +357,16 @@ class TestKeyConflictResolution(unittest.TestCase): date3 = datetime(2022, 4, 11, 5, 23, 42, 0, tzinfo=tzutc()) sub3 = SchleuderSubscriber(7, 'foo@example.org', None, sch3.id, date3) - mock_smtp = MagicMock() - mock_smtp.__enter__.return_value = mock_smtp - kcr = KeyConflictResolution(mock_smtp, 3600, '/tmp/state.json', _TEMPLATE) - resolved = kcr.resolve( - target='test@schleuder.example.org', - mail_from='test-owner@schleuder.example.org', - subscriptions=[sub1, sub2, sub3]) - self.assertEqual(1, len(kcr._messages)) - msg = kcr._messages[0].mime + kcr = KeyConflictResolution(3600, '/tmp/state.json', _TEMPLATE) + contents = io.StringIO(_CONFLICT_STATE_NONE) + with patch('builtins.open', mock_open(read_data=_CONFLICT_STATE_NONE)) as mock_statefile: + mock_statefile().__enter__.return_value = contents + resolved, messages = kcr.resolve( + target='test@schleuder.example.org', + mail_from='test-owner@schleuder.example.org', + subscriptions=[sub1, sub2, sub3]) + self.assertEqual(1, len(messages)) + msg = messages[0].mime pgp = pgpy.PGPMessage.from_blob(msg.get_payload()[1].get_payload(decode=True)) # Verify that the message is encrypted with both keys self.assertEqual(2, len(pgp.encrypters)) diff --git a/multischleuder/test/test_multilist.py b/multischleuder/test/test_multilist.py index e9d2ab4..5340b64 100644 --- a/multischleuder/test/test_multilist.py +++ b/multischleuder/test/test_multilist.py @@ -1,5 +1,5 @@ -from typing import Dict, List +from typing import Dict, List, Tuple import unittest from datetime import datetime @@ -8,18 +8,19 @@ from unittest.mock import MagicMock from dateutil.tz import tzutc from multischleuder.processor import MultiList +from multischleuder.reporting import ConflictMessage from multischleuder.types import SchleuderKey, SchleuderList, SchleuderSubscriber def _resolve(target: str, mail_from: str, - subscriptions: List[SchleuderSubscriber]) -> List[SchleuderSubscriber]: + subscriptions: List[SchleuderSubscriber]) -> Tuple[List[SchleuderSubscriber], List[ConflictMessage]]: # Mock conflict resolution that does not send or prepare messages subs: Dict[str, List[SchleuderSubscriber]] = {} for s in subscriptions: if s.key is not None: subs.setdefault(s.email, []).append(s) - return [min(s, key=lambda x: x.created_at) for s in subs.values()] + return [min(s, key=lambda x: x.created_at)for s in subs.values()], [] def _list_lists(): @@ -152,13 +153,17 @@ class TestMultiList(unittest.TestCase): 'test-west@schleuder.example.org' ] api = self._api_mock() + smtp = MagicMock() ml = MultiList(sources=sources, target='test-global@schleuder.example.org', unmanaged=['admin@example.org'], banned=['aspammer@example.org', 'anotherspammer@example.org'], mail_from='test-global-owner@schleuder.example.org', api=api, - kcr=self._kcr_mock()) + kcr=self._kcr_mock(), + smtp=smtp, + send_admin_reports=True, + send_conflict_messages=True) ml.process() # Key uploads @@ -211,3 +216,5 @@ class TestMultiList(unittest.TestCase): self.assertEqual(2, c[1].id) self.assertEqual('aspammer@example.org', c[0].email) self.assertEqual('8258FAF8B161B3DD8F784874F73E2DDF045AE2D6', c[0].fingerprint) + + # Todo: check message queue diff --git a/test/multischleuder.yml b/test/multischleuder.yml index 95d5ea4..446df79 100644 --- a/test/multischleuder.yml +++ b/test/multischleuder.yml @@ -19,10 +19,12 @@ lists: - test-south@schleuder.example.org - test-west@schleuder.example.org from: test-global-owner@schleuder.example.org + send_admin_reports: no - target: test2-global@schleuder.example.org unmanaged: - admin@example.org + - admin2@example.org banned: - aspammer@example.org - anotherspammer@example.org diff --git a/test/prepare-schleuder.sh b/test/prepare-schleuder.sh index 0037657..7fbf162 100755 --- a/test/prepare-schleuder.sh +++ b/test/prepare-schleuder.sh @@ -84,3 +84,6 @@ sleep 5 # to get different subscription dates schleuder-cli subscriptions new test-west@schleuder.example.org andy.example@example.org /tmp/andy.example@example.org.1.asc # should not be subscribed subscribe test-west@schleuder.example.org amy.example@example.org # should be subscribed - conflict but same key + +schleuder-cli subscriptions new test2-global@schleuder.example.org arno.example@example.org + # should be unsubscribed - no key diff --git a/test/report.py b/test/report.py index 266b144..d4270ad 100755 --- a/test/report.py +++ b/test/report.py @@ -84,24 +84,47 @@ if len(keysdiff) > 0: # Test mbox mbox = mailbox.mbox('/var/spool/mail/root') -if len(mbox) != 1: - print(f'Expected 1 message in mbox, got {len(mbox)}') +if len(mbox) != 2: + print(f'Expected 2 messages in mbox, got {len(mbox)}') exit(1) -_, msg = mbox.popitem() +_, msg1 = mbox.popitem() +_, msg2 = mbox.popitem() mbox.close() -digest = msg['X-MultiSchleuder-Digest'].strip() +if 'X-MultiSchleuder-Digest' not in msg1: + msg1, msg2 = msg2, msg1 -if msg['From'] != 'test-global-owner@schleuder.example.org': - print(f'Expected "From: test-global-owner@schleuder.example.org", got {msg["From"]}') + +if 'X-MultiSchleuder-Digest' not in msg1: + print(f'Key conflict message should have a X-MultiSchleuder-Digest header, missing') exit(1) -if msg['To'] != 'andy.example@example.org': - print(f'Expected "To: andy.example@example.org", got {msg["To"]}') +digest = msg1['X-MultiSchleuder-Digest'].strip() +if msg1['From'] != 'test-global-owner@schleuder.example.org': + print(f'Expected "From: test-global-owner@schleuder.example.org", got {msg1["From"]}') exit(1) -if msg['Auto-Submitted'] != 'auto-generated': - print(f'Expected "Auto-Submitted: auto-generated", got {msg["Auto-Submitted"]}') +if msg1['To'] != 'andy.example@example.org': + print(f'Expected "To: andy.example@example.org", got {msg1["To"]}') exit(1) -if msg['Precedence'] != 'list': - print(f'Expected "Precedence: list", got {msg["Precedence"]}') +if msg1['Auto-Submitted'] != 'auto-generated': + print(f'Expected "Auto-Submitted: auto-generated", got {msg1["Auto-Submitted"]}') + exit(1) +if msg1['Precedence'] != 'list': + print(f'Expected "Precedence: list", got {msg1["Precedence"]}') + exit(1) + +if 'X-MultiSchleuder-Digest' in msg2: + print(f'Admin report message should not have a X-MultiSchleuder-Digest header, got {msg2["X-MultiSchleuder-Digest"]}') + exit(1) +if msg2['From'] != 'test2-global-owner@schleuder.example.org': + print(f'Expected "From: test2-global-owner@schleuder.example.org", got {msg2["From"]}') + exit(1) +if msg2['To'] != 'admin2@example.org': + print(f'Expected "To: admin2@example.org", got {msg2["To"]}') + exit(1) +if msg2['Auto-Submitted'] != 'auto-generated': + print(f'Expected "Auto-Submitted: auto-generated", got {msg2["Auto-Submitted"]}') + exit(1) +if msg2['Precedence'] != 'list': + print(f'Expected "Precedence: list", got {msg2["Precedence"]}') exit(1)