Refactor to take mailing out of the key conflict resolution components. Add admin reporting
This commit is contained in:
parent
5c38407be8
commit
ade119ca69
13 changed files with 503 additions and 306 deletions
|
@ -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
|
||||
|
|
|
@ -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]:
|
||||
|
|
|
@ -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'] = '<https://gitlab.com/s3lph/multischleuder>'
|
||||
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()
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
181
multischleuder/reporting.py
Normal file
181
multischleuder/reporting.py
Normal file
|
@ -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'] = '<https://gitlab.com/s3lph/multischleuder>'
|
||||
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}'
|
|
@ -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):
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
Loading…
Reference in a new issue