Add conflict resolution for multiple users using the same key. Fixes #1.
This commit is contained in:
parent
e33692f40a
commit
4b9dfa550a
8 changed files with 360 additions and 74 deletions
|
@ -16,16 +16,17 @@ from datetime import datetime
|
|||
|
||||
import pgpy # type: ignore
|
||||
|
||||
from multischleuder.reporting import ConflictMessage, Message
|
||||
from multischleuder.reporting import KeyConflictMessage, Message, UserConflictMessage
|
||||
from multischleuder.types import SchleuderKey, SchleuderSubscriber
|
||||
|
||||
|
||||
class KeyConflictResolution:
|
||||
|
||||
def __init__(self, interval: int, statefile: str, template: str):
|
||||
def __init__(self, interval: int, statefile: str, key_template: str, user_template: str):
|
||||
self._interval: int = interval
|
||||
self._state_file: str = statefile
|
||||
self._template: str = template
|
||||
self._key_template: str = key_template
|
||||
self._user_template: str = user_template
|
||||
self._dry_run: bool = False
|
||||
self._logger: logging.Logger = logging.getLogger()
|
||||
|
||||
|
@ -36,25 +37,41 @@ class KeyConflictResolution:
|
|||
target: str,
|
||||
mail_from: str,
|
||||
subscriptions: List[SchleuderSubscriber]) -> Tuple[List[SchleuderSubscriber], List[Optional[Message]]]:
|
||||
subs: Dict[str, List[SchleuderSubscriber]] = OrderedDict()
|
||||
conflicts: List[Optional[Message]] = []
|
||||
|
||||
# First check for keys that are being used by more than one subscriber
|
||||
keys: Dict[str, List[SchleuderSubscriber]] = {}
|
||||
for s in subscriptions:
|
||||
if s.key is None:
|
||||
continue
|
||||
keys.setdefault(s.key.fingerprint, []).append(s)
|
||||
key_resolved: List[SchleuderSubscriber] = []
|
||||
for k in keys.values():
|
||||
rk, m = self._resolve_users(target, mail_from, k)
|
||||
if rk is not None:
|
||||
key_resolved.append(rk)
|
||||
if m is not None:
|
||||
conflicts.extend(m)
|
||||
|
||||
subs: Dict[str, List[SchleuderSubscriber]] = OrderedDict()
|
||||
# Only consider subscribers who have not been removed due to a key used by multiple users
|
||||
for s in key_resolved:
|
||||
subs.setdefault(s.email, []).append(s)
|
||||
# Perform conflict resolution for each set of subscribers with the same email
|
||||
resolved: List[SchleuderSubscriber] = []
|
||||
conflicts: List[Optional[Message]] = []
|
||||
for c in subs.values():
|
||||
r, m = self._resolve(target, mail_from, c)
|
||||
r, msg = self._resolve_keys(target, mail_from, c)
|
||||
if r is not None:
|
||||
resolved.append(r)
|
||||
if m is not None:
|
||||
conflicts.append(m)
|
||||
if msg is not None:
|
||||
conflicts.append(msg)
|
||||
return resolved, conflicts
|
||||
|
||||
def _resolve(self,
|
||||
target: str,
|
||||
mail_from: str,
|
||||
subscriptions: List[SchleuderSubscriber]) \
|
||||
-> Tuple[Optional[SchleuderSubscriber], Optional[ConflictMessage]]:
|
||||
def _resolve_keys(self,
|
||||
target: str,
|
||||
mail_from: str,
|
||||
subscriptions: List[SchleuderSubscriber]) \
|
||||
-> Tuple[Optional[SchleuderSubscriber], Optional[Message]]:
|
||||
notnull = [s for s in subscriptions if s.key is not None]
|
||||
if len(notnull) == 0:
|
||||
return None, None
|
||||
|
@ -69,21 +86,58 @@ class KeyConflictResolution:
|
|||
fpr = 'no key' if s.key is None else s.key.fingerprint
|
||||
self._logger.debug(f' - {s.schleuder}: {fpr}')
|
||||
# Generate a SHA1 digest that only changes when the subscription list changes
|
||||
digest = self._make_digest(earliest, subscriptions)
|
||||
msg: Optional[ConflictMessage] = None
|
||||
digest = self._make_key_digest(earliest, subscriptions)
|
||||
msg: Optional[Message] = None
|
||||
# Create a conflict message only if it hasn't been sent recently
|
||||
if self._should_send(digest):
|
||||
msg = ConflictMessage(
|
||||
msg = KeyConflictMessage(
|
||||
target,
|
||||
earliest,
|
||||
subscriptions,
|
||||
digest,
|
||||
mail_from,
|
||||
self._template
|
||||
self._key_template
|
||||
)
|
||||
# Return the result of conflict resolution
|
||||
return earliest, msg
|
||||
|
||||
def _resolve_users(self,
|
||||
target: str,
|
||||
mail_from: str,
|
||||
subscriptions: List[SchleuderSubscriber]) \
|
||||
-> Tuple[Optional[SchleuderSubscriber], Optional[List[Message]]]:
|
||||
notnull = [s for s in subscriptions if s.key is not None]
|
||||
if len(notnull) == 0:
|
||||
return None, None
|
||||
emails = {s.email for s in notnull}
|
||||
if len(emails) == 1:
|
||||
# No conflict if all emails are the same
|
||||
return notnull[0], None
|
||||
# Conflict Resolution: Choose the OLDEST subscription with a key, but notify using ALL recipients
|
||||
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'User Conflict for {earliest.key.fingerprint} in lists, chose {earliest.email}:')
|
||||
for s in subscriptions:
|
||||
self._logger.debug(f' - {s.schleuder}: {s.email}')
|
||||
# Generate a SHA1 digest that only changes when the subscription list changes
|
||||
digest = self._make_user_digest(earliest, subscriptions)
|
||||
msgs: Optional[List[Message]] = None
|
||||
# Create a conflict message only if it hasn't been sent recently
|
||||
if self._should_send(digest):
|
||||
msgs = []
|
||||
for email in emails:
|
||||
msgs.append(UserConflictMessage(
|
||||
target,
|
||||
email,
|
||||
earliest,
|
||||
subscriptions,
|
||||
digest,
|
||||
mail_from,
|
||||
self._user_template
|
||||
))
|
||||
# Return the result of conflict resolution
|
||||
return earliest, msgs
|
||||
|
||||
def _should_send(self, digest: str) -> bool:
|
||||
now = int(datetime.utcnow().timestamp())
|
||||
try:
|
||||
|
@ -114,7 +168,7 @@ class KeyConflictResolution:
|
|||
self._logger.exception('Cannot open or write statefile. Not sending any messages!')
|
||||
return False
|
||||
|
||||
def _make_digest(self, chosen: SchleuderSubscriber, candidates: List[SchleuderSubscriber]) -> str:
|
||||
def _make_key_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)
|
||||
|
@ -130,3 +184,19 @@ class KeyConflictResolution:
|
|||
key = s.key.blob.encode()
|
||||
h.update(struct.pack('!ds', s.schleuder, key))
|
||||
return h.hexdigest()
|
||||
|
||||
def _make_user_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')
|
||||
assert chosen.key is not None # Make mypy happy; it can't know that chosen.key can't be None
|
||||
# Include the chosen email an source sub-list
|
||||
h.update(struct.pack('!ssd',
|
||||
chosen.key.fingerprint.encode(),
|
||||
chosen.email.encode(),
|
||||
chosen.schleuder))
|
||||
# Include all subscriptions, including the FULL key
|
||||
for s in subs:
|
||||
h.update(struct.pack('!ds', s.schleuder, s.email.encode()))
|
||||
return h.hexdigest()
|
||||
|
|
|
@ -83,7 +83,7 @@ class Message(abc.ABC):
|
|||
return mp0
|
||||
|
||||
|
||||
class ConflictMessage(Message):
|
||||
class KeyConflictMessage(Message):
|
||||
|
||||
def __init__(self,
|
||||
schleuder: str,
|
||||
|
@ -119,6 +119,43 @@ class ConflictMessage(Message):
|
|||
self.mime['X-MultiSchleuder-Digest'] = digest
|
||||
|
||||
|
||||
class UserConflictMessage(Message):
|
||||
|
||||
def __init__(self,
|
||||
schleuder: str,
|
||||
subscriber: str,
|
||||
chosen: SchleuderSubscriber,
|
||||
affected: List[SchleuderSubscriber],
|
||||
digest: str,
|
||||
mail_from: str,
|
||||
template: str):
|
||||
# Render the message body
|
||||
assert chosen.key is not None
|
||||
_chosen = chosen.email
|
||||
_affected = ''
|
||||
for a in affected:
|
||||
_affected += f'{a.email} {a.schleuder}\n'
|
||||
content = template.format(
|
||||
subscriber=subscriber,
|
||||
fingerprint=chosen.key.fingerprint,
|
||||
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=[chosen.key.blob]
|
||||
)
|
||||
self.mime['Subject'] = f'MultiSchleuder {self._schleuder} - Subscriber Conflict'
|
||||
self.mime['X-MultiSchleuder-Digest'] = digest
|
||||
|
||||
|
||||
class AdminReport(Message):
|
||||
|
||||
def __init__(self,
|
||||
|
@ -194,7 +231,9 @@ class Reporter:
|
|||
def add_message(self, message: Optional[Message]):
|
||||
if message is None:
|
||||
return
|
||||
if not self._send_conflict_messages and isinstance(message, ConflictMessage):
|
||||
if not self._send_conflict_messages and isinstance(message, KeyConflictMessage):
|
||||
return
|
||||
if not self._send_conflict_messages and isinstance(message, UserConflictMessage):
|
||||
return
|
||||
if not self._send_admin_reports and isinstance(message, AdminReport):
|
||||
return
|
||||
|
|
|
@ -13,7 +13,8 @@ api:
|
|||
conflict:
|
||||
interval: 3600
|
||||
statefile: /tmp/state.json
|
||||
template: ''
|
||||
key_template: ''
|
||||
user_template: ''
|
||||
'''
|
||||
|
||||
|
||||
|
@ -34,13 +35,20 @@ smtp:
|
|||
conflict:
|
||||
interval: 3600
|
||||
statefile: /tmp/state.json
|
||||
template: |
|
||||
key_template: |
|
||||
Dear {subscriber}
|
||||
This is a test and should not be used in production:
|
||||
{affected}
|
||||
If you ever receive this text via email, notify your admin:
|
||||
{chosen}
|
||||
Regards, {schleuder}
|
||||
user_template: |
|
||||
Dear {subscriber}
|
||||
This is a test and should not be used in production:
|
||||
{affected}
|
||||
If you ever receive this text via email, notify your admin:
|
||||
{chosen}
|
||||
Kind regards, {schleuder}
|
||||
|
||||
lists:
|
||||
|
||||
|
@ -97,7 +105,8 @@ class TestConfig(unittest.TestCase):
|
|||
self.assertEqual(False, list1._api._dry_run)
|
||||
self.assertEqual(3600, list2._kcr._interval)
|
||||
self.assertEqual('/tmp/state.json', list1._kcr._state_file)
|
||||
self.assertIn('Regards, {schleuder}', list2._kcr._template)
|
||||
self.assertIn('Regards, {schleuder}', list2._kcr._key_template)
|
||||
self.assertIn('Kind regards, {schleuder}', list2._kcr._user_template)
|
||||
|
||||
self.assertEqual('test-global@schleuder.example.org', list1._target)
|
||||
self.assertEqual('test-global-owner@schleuder.example.org', list1._mail_from)
|
||||
|
|
|
@ -8,7 +8,8 @@ from unittest.mock import patch, mock_open, MagicMock
|
|||
import pgpy # type: ignore
|
||||
from dateutil.tz import tzutc
|
||||
|
||||
from multischleuder.conflict import ConflictMessage, KeyConflictResolution
|
||||
from multischleuder.conflict import KeyConflictResolution
|
||||
from multischleuder.reporting import UserConflictMessage
|
||||
from multischleuder.types import SchleuderKey, SchleuderList, SchleuderSubscriber
|
||||
|
||||
|
||||
|
@ -52,7 +53,13 @@ QBzu2Swgk6MkU45SLQD+LagpBVJxHcbvmK+n8MFvTSrusF8H78P4TrMLP4Onvw4=
|
|||
''')
|
||||
|
||||
|
||||
_TEMPLATE = '''{{
|
||||
_KEY_TEMPLATE = '''{{
|
||||
"subscriber": "{subscriber}",
|
||||
"schleuder": "{schleuder}",
|
||||
"chosen": "{chosen}"
|
||||
}}'''
|
||||
_USER_TEMPLATE = '''{{
|
||||
"fingerprint": "{fingerprint}",
|
||||
"subscriber": "{subscriber}",
|
||||
"schleuder": "{schleuder}",
|
||||
"chosen": "{chosen}"
|
||||
|
@ -70,7 +77,7 @@ _CONFLICT_STATE_RECENT = f'''{{
|
|||
|
||||
class TestKeyConflictResolution(unittest.TestCase):
|
||||
|
||||
def test_order_resistent_hash(self):
|
||||
def test_order_resistent_key_hash(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())
|
||||
|
@ -79,19 +86,39 @@ 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)
|
||||
kcr = KeyConflictResolution(3600, '/tmp/state.json', _KEY_TEMPLATE, _USER_TEMPLATE)
|
||||
|
||||
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])
|
||||
digest1 = kcr._make_key_digest(sub2, [sub1, sub2])
|
||||
digest2 = kcr._make_key_digest(sub2, [sub2, sub1])
|
||||
digest3 = kcr._make_key_digest(sub1, [sub1, sub2])
|
||||
digest4 = kcr._make_key_digest(sub1, [sub2, sub1])
|
||||
|
||||
self.assertEqual(digest1, digest2)
|
||||
self.assertNotEqual(digest1, digest3)
|
||||
self.assertEqual(digest3, digest4)
|
||||
|
||||
def test_order_resistent_user_hash(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)
|
||||
sch2 = SchleuderList(23, 'test-south@schleuder.example.org', 'AF586C0625CF77BBB659747515D41C5D84BF99D3')
|
||||
key2 = SchleuderKey(_PRIVKEY_1.fingerprint.replace(' ', ''), 'bar@example.org', str(_PRIVKEY_1.pubkey), sch2.id)
|
||||
date2 = datetime(2022, 4, 13, 5, 23, 42, 0, tzinfo=tzutc())
|
||||
sub2 = SchleuderSubscriber(7, 'bar@example.org', key2, sch2.id, date2)
|
||||
kcr = KeyConflictResolution(3600, '/tmp/state.json', _KEY_TEMPLATE, _USER_TEMPLATE)
|
||||
|
||||
digest1 = kcr._make_user_digest(sub2, [sub1, sub2])
|
||||
digest2 = kcr._make_user_digest(sub2, [sub2, sub1])
|
||||
digest3 = kcr._make_user_digest(sub1, [sub1, sub2])
|
||||
digest4 = kcr._make_user_digest(sub1, [sub2, sub1])
|
||||
|
||||
self.assertEqual(digest1, digest2)
|
||||
self.assertNotEqual(digest1, digest3)
|
||||
self.assertEqual(digest3, digest4)
|
||||
|
||||
def test_empty(self):
|
||||
kcr = KeyConflictResolution(3600, '/tmp/state.json', _TEMPLATE)
|
||||
kcr = KeyConflictResolution(3600, '/tmp/state.json', _KEY_TEMPLATE, _USER_TEMPLATE)
|
||||
contents = io.StringIO(_CONFLICT_STATE_NONE)
|
||||
contents.seek(io.SEEK_END) # Opened with 'a+'
|
||||
with patch('builtins.open', mock_open(read_data=_CONFLICT_STATE_NONE)) as mock_statefile:
|
||||
|
@ -105,7 +132,7 @@ class TestKeyConflictResolution(unittest.TestCase):
|
|||
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(3600, '/tmp/state.json', _TEMPLATE)
|
||||
kcr = KeyConflictResolution(3600, '/tmp/state.json', _KEY_TEMPLATE, _USER_TEMPLATE)
|
||||
resolved, messages = kcr.resolve('', '', [sub1])
|
||||
self.assertEqual(1, len(resolved))
|
||||
self.assertEqual(sub1, resolved[0])
|
||||
|
@ -123,7 +150,7 @@ 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(3600, '/tmp/state.json', _TEMPLATE)
|
||||
kcr = KeyConflictResolution(3600, '/tmp/state.json', _KEY_TEMPLATE, _USER_TEMPLATE)
|
||||
contents = io.StringIO(_CONFLICT_STATE_NONE)
|
||||
contents.seek(io.SEEK_END) # Opened with 'a+'
|
||||
with patch('builtins.open', mock_open(read_data=_CONFLICT_STATE_NONE)) as mock_statefile:
|
||||
|
@ -150,7 +177,7 @@ 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(3600, '/tmp/state.json', _TEMPLATE)
|
||||
kcr = KeyConflictResolution(3600, '/tmp/state.json', _KEY_TEMPLATE, _USER_TEMPLATE)
|
||||
contents = io.StringIO(_CONFLICT_STATE_NONE)
|
||||
contents.seek(io.SEEK_END) # Opened with 'a+'
|
||||
with patch('builtins.open', mock_open(read_data=_CONFLICT_STATE_NONE)) as mock_statefile:
|
||||
|
@ -187,7 +214,7 @@ 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(3600, '/tmp/state.json', _TEMPLATE)
|
||||
kcr = KeyConflictResolution(3600, '/tmp/state.json', _KEY_TEMPLATE, _USER_TEMPLATE)
|
||||
contents = io.StringIO(_CONFLICT_STATE_NONE)
|
||||
contents.seek(io.SEEK_END) # Opened with 'a+'
|
||||
with patch('builtins.open', mock_open(read_data=_CONFLICT_STATE_NONE)) as mock_statefile:
|
||||
|
@ -207,7 +234,7 @@ class TestKeyConflictResolution(unittest.TestCase):
|
|||
date1 = datetime(2022, 4, 15, 5, 23, 42, 0, tzinfo=tzutc())
|
||||
sub1 = SchleuderSubscriber(3, 'foo@example.org', None, sch1.id, date1)
|
||||
|
||||
kcr = KeyConflictResolution(3600, '/tmp/state.json', _TEMPLATE)
|
||||
kcr = KeyConflictResolution(3600, '/tmp/state.json', _KEY_TEMPLATE, _USER_TEMPLATE)
|
||||
contents = io.StringIO(_CONFLICT_STATE_NONE)
|
||||
contents.seek(io.SEEK_END) # Opened with 'a+'
|
||||
with patch('builtins.open', mock_open(read_data=_CONFLICT_STATE_NONE)) as mock_statefile:
|
||||
|
@ -219,6 +246,114 @@ class TestKeyConflictResolution(unittest.TestCase):
|
|||
self.assertEqual(0, len(resolved))
|
||||
self.assertEqual(0, len(messages))
|
||||
|
||||
def test_different_emails_conflict(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)
|
||||
|
||||
# This subscription is older, so it will be preferred
|
||||
sch2 = SchleuderList(23, 'test-south@schleuder.example.org', 'AF586C0625CF77BBB659747515D41C5D84BF99D3')
|
||||
key2 = SchleuderKey(_PRIVKEY_1.fingerprint.replace(' ', ''), 'bar@example.org', str(_PRIVKEY_1.pubkey), sch2.id)
|
||||
date2 = datetime(2022, 4, 13, 5, 23, 42, 0, tzinfo=tzutc())
|
||||
sub2 = SchleuderSubscriber(7, 'bar@example.org', key2, sch2.id, date2)
|
||||
|
||||
kcr = KeyConflictResolution(3600, '/tmp/state.json', _KEY_TEMPLATE, _USER_TEMPLATE)
|
||||
contents = io.StringIO(_CONFLICT_STATE_NONE)
|
||||
contents.seek(io.SEEK_END) # Opened with 'a+'
|
||||
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('bar@example.org', resolved[0].email)
|
||||
# Different emails should trigger conflict messages
|
||||
self.assertEqual(2, len(messages))
|
||||
self.assertIsInstance(messages[0], UserConflictMessage)
|
||||
self.assertIsInstance(messages[1], UserConflictMessage)
|
||||
msg1 = messages[0].mime
|
||||
pgp1 = pgpy.PGPMessage.from_blob(msg1.get_payload()[1].get_payload(decode=True))
|
||||
msg2 = messages[1].mime
|
||||
pgp2 = pgpy.PGPMessage.from_blob(msg2.get_payload()[1].get_payload(decode=True))
|
||||
# Verify that the message is encrypted
|
||||
dec1 = _PRIVKEY_1.decrypt(pgp1)
|
||||
dec2 = _PRIVKEY_1.decrypt(pgp2)
|
||||
self.assertNotEqual(dec1.message, dec2.message)
|
||||
|
||||
payload1 = json.loads(dec1.message)
|
||||
payload2 = json.loads(dec2.message)
|
||||
if payload1['subscriber'] != 'foo@example.org':
|
||||
payload1, payload2 = payload2, payload1
|
||||
self.assertEqual('foo@example.org', payload1['subscriber'])
|
||||
self.assertEqual('2FBBC0DF97FDBF1E4B704EEDE39EF4FAC420BEB6', payload1['fingerprint'])
|
||||
self.assertEqual('test@schleuder.example.org', payload1['schleuder'])
|
||||
self.assertEqual('bar@example.org', payload1['chosen'])
|
||||
self.assertEqual('bar@example.org', payload2['subscriber'])
|
||||
self.assertEqual('2FBBC0DF97FDBF1E4B704EEDE39EF4FAC420BEB6', payload2['fingerprint'])
|
||||
self.assertEqual('test@schleuder.example.org', payload2['schleuder'])
|
||||
self.assertEqual('bar@example.org', payload2['chosen'])
|
||||
|
||||
def test_zigzag_conflict(self):
|
||||
date1 = datetime(2022, 4, 15, 5, 23, 42, 0, tzinfo=tzutc())
|
||||
date2 = datetime(2022, 4, 13, 5, 23, 42, 0, tzinfo=tzutc())
|
||||
|
||||
sch1 = SchleuderList(42, 'test-north@schleuder.example.org', '474777DA74528A7021184C8A0017324A6CFFBF92')
|
||||
key1 = SchleuderKey(_PRIVKEY_1.fingerprint.replace(' ', ''), 'foo@example.org', str(_PRIVKEY_1.pubkey), sch1.id)
|
||||
key2 = SchleuderKey(_PRIVKEY_2.fingerprint.replace(' ', ''), 'bar@example.org', str(_PRIVKEY_2.pubkey), sch1.id)
|
||||
sub1 = SchleuderSubscriber(1, 'foo@example.org', key1, sch1.id, date2)
|
||||
sub2 = SchleuderSubscriber(2, 'bar@example.org', key2, sch1.id, date2)
|
||||
|
||||
sch2 = SchleuderList(23, 'test-south@schleuder.example.org', 'AF586C0625CF77BBB659747515D41C5D84BF99D3')
|
||||
key3 = SchleuderKey(_PRIVKEY_1.fingerprint.replace(' ', ''), 'bar@example.org', str(_PRIVKEY_1.pubkey), sch2.id)
|
||||
sub3 = SchleuderSubscriber(7, 'bar@example.org', key3, sch2.id, date1)
|
||||
|
||||
kcr = KeyConflictResolution(3600, '/tmp/state.json', _KEY_TEMPLATE, _USER_TEMPLATE)
|
||||
contents = io.StringIO(_CONFLICT_STATE_NONE)
|
||||
contents.seek(io.SEEK_END) # Opened with 'a+'
|
||||
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(2, len(resolved))
|
||||
foo, bar = resolved
|
||||
if foo.email != 'foo@example.org':
|
||||
foo, bar = bar, foo
|
||||
self.assertEqual('foo@example.org', foo.email)
|
||||
self.assertEqual('2FBBC0DF97FDBF1E4B704EEDE39EF4FAC420BEB6', foo.key.fingerprint)
|
||||
self.assertEqual('bar@example.org', bar.email)
|
||||
self.assertEqual('135AFA0FB3FF584828911208B7913308392972A4', bar.key.fingerprint)
|
||||
# Different emails should trigger conflict messages
|
||||
self.assertEqual(2, len(messages))
|
||||
self.assertIsInstance(messages[0], UserConflictMessage)
|
||||
self.assertIsInstance(messages[1], UserConflictMessage)
|
||||
msg1 = messages[0].mime
|
||||
pgp1 = pgpy.PGPMessage.from_blob(msg1.get_payload()[1].get_payload(decode=True))
|
||||
msg2 = messages[1].mime
|
||||
pgp2 = pgpy.PGPMessage.from_blob(msg2.get_payload()[1].get_payload(decode=True))
|
||||
# Verify that the message is encrypted
|
||||
dec1 = _PRIVKEY_1.decrypt(pgp1)
|
||||
dec2 = _PRIVKEY_1.decrypt(pgp2)
|
||||
self.assertNotEqual(dec1.message, dec2.message)
|
||||
|
||||
payload1 = json.loads(dec1.message)
|
||||
payload2 = json.loads(dec2.message)
|
||||
if payload1['subscriber'] != 'foo@example.org':
|
||||
payload1, payload2 = payload2, payload1
|
||||
self.assertEqual('foo@example.org', payload1['subscriber'])
|
||||
self.assertEqual('2FBBC0DF97FDBF1E4B704EEDE39EF4FAC420BEB6', payload1['fingerprint'])
|
||||
self.assertEqual('test@schleuder.example.org', payload1['schleuder'])
|
||||
self.assertEqual('foo@example.org', payload1['chosen'])
|
||||
self.assertEqual('bar@example.org', payload2['subscriber'])
|
||||
self.assertEqual('2FBBC0DF97FDBF1E4B704EEDE39EF4FAC420BEB6', payload2['fingerprint'])
|
||||
self.assertEqual('test@schleuder.example.org', payload2['schleuder'])
|
||||
self.assertEqual('foo@example.org', payload2['chosen'])
|
||||
|
||||
def test_send_messages_nofile(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)
|
||||
|
@ -230,7 +365,7 @@ class TestKeyConflictResolution(unittest.TestCase):
|
|||
key2 = SchleuderKey(_PRIVKEY_2.fingerprint.replace(' ', ''), 'foo@example.org', str(_PRIVKEY_2.pubkey), sch2.id)
|
||||
sub2 = SchleuderSubscriber(7, 'foo@example.org', key2, sch2.id, date2)
|
||||
|
||||
kcr = KeyConflictResolution(3600, '/nonexistent/directory/state.json', _TEMPLATE)
|
||||
kcr = KeyConflictResolution(3600, '/nonexistent/directory/state.json', _KEY_TEMPLATE, _USER_TEMPLATE)
|
||||
resolved, msgs = kcr.resolve(
|
||||
target='test@schleuder.example.org',
|
||||
mail_from='test-owner@schleuder.example.org',
|
||||
|
@ -248,7 +383,7 @@ class TestKeyConflictResolution(unittest.TestCase):
|
|||
key2 = SchleuderKey(_PRIVKEY_2.fingerprint.replace(' ', ''), 'foo@example.org', str(_PRIVKEY_2.pubkey), sch2.id)
|
||||
sub2 = SchleuderSubscriber(7, 'foo@example.org', key2, sch2.id, date2)
|
||||
|
||||
kcr = KeyConflictResolution(3600, '/tmp/state.json', _TEMPLATE)
|
||||
kcr = KeyConflictResolution(3600, '/tmp/state.json', _KEY_TEMPLATE, _USER_TEMPLATE)
|
||||
contents = io.StringIO('[[intentionally/broken\\json]]')
|
||||
contents.seek(io.SEEK_END) # Opened with 'a+'
|
||||
with patch('builtins.open', mock_open(read_data='[[intentionally/broken\\json]]')) as mock_statefile:
|
||||
|
@ -270,7 +405,7 @@ class TestKeyConflictResolution(unittest.TestCase):
|
|||
key2 = SchleuderKey(_PRIVKEY_2.fingerprint.replace(' ', ''), 'foo@example.org', str(_PRIVKEY_2.pubkey), sch2.id)
|
||||
sub2 = SchleuderSubscriber(7, 'foo@example.org', key2, sch2.id, date2)
|
||||
|
||||
kcr = KeyConflictResolution(3600, '/tmp/state.json', _TEMPLATE)
|
||||
kcr = KeyConflictResolution(3600, '/tmp/state.json', _KEY_TEMPLATE, _USER_TEMPLATE)
|
||||
contents = io.StringIO()
|
||||
with patch('builtins.open', mock_open(read_data='')) as mock_statefile:
|
||||
mock_statefile().__enter__.return_value = contents
|
||||
|
@ -299,7 +434,7 @@ class TestKeyConflictResolution(unittest.TestCase):
|
|||
key2 = SchleuderKey(_PRIVKEY_2.fingerprint.replace(' ', ''), 'foo@example.org', str(_PRIVKEY_2.pubkey), sch2.id)
|
||||
sub2 = SchleuderSubscriber(7, 'foo@example.org', key2, sch2.id, date2)
|
||||
|
||||
kcr = KeyConflictResolution(3600, '/tmp/state.json', _TEMPLATE)
|
||||
kcr = KeyConflictResolution(3600, '/tmp/state.json', _KEY_TEMPLATE, _USER_TEMPLATE)
|
||||
contents = io.StringIO(_CONFLICT_STATE_NONE)
|
||||
contents.seek(io.SEEK_END) # Opened with 'a+'
|
||||
with patch('builtins.open', mock_open(read_data=_CONFLICT_STATE_NONE)) as mock_statefile:
|
||||
|
@ -318,12 +453,6 @@ class TestKeyConflictResolution(unittest.TestCase):
|
|||
self.assertIn(msgs[0].mime['X-MultiSchleuder-Digest'], state)
|
||||
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')
|
||||
key1 = SchleuderKey(_PRIVKEY_1.fingerprint.replace(' ', ''), 'foo@example.org', str(_PRIVKEY_1.pubkey), sch1.id)
|
||||
|
@ -335,7 +464,7 @@ 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(3600, '/tmp/state.json', _TEMPLATE)
|
||||
kcr = KeyConflictResolution(3600, '/tmp/state.json', _KEY_TEMPLATE, _USER_TEMPLATE)
|
||||
contents = io.StringIO(_CONFLICT_STATE_STALE)
|
||||
contents.seek(io.SEEK_END) # Opened with 'a+'
|
||||
with patch('builtins.open', mock_open(read_data=_CONFLICT_STATE_STALE)) as mock_statefile:
|
||||
|
@ -366,7 +495,7 @@ 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(86400, '/tmp/state.json', _TEMPLATE)
|
||||
kcr = KeyConflictResolution(86400, '/tmp/state.json', _KEY_TEMPLATE, _USER_TEMPLATE)
|
||||
contents = io.StringIO(_CONFLICT_STATE_RECENT)
|
||||
contents.seek(io.SEEK_END) # Opened with 'a+'
|
||||
with patch('builtins.open', mock_open(read_data=_CONFLICT_STATE_RECENT)) as mock_statefile:
|
||||
|
@ -398,7 +527,7 @@ 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(3600, '/tmp/state.json', _TEMPLATE)
|
||||
kcr = KeyConflictResolution(3600, '/tmp/state.json', _KEY_TEMPLATE, _USER_TEMPLATE)
|
||||
kcr.dry_run()
|
||||
contents = io.StringIO(_CONFLICT_STATE_STALE)
|
||||
contents.seek(io.SEEK_END) # Opened with 'a+'
|
||||
|
@ -431,7 +560,7 @@ 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)
|
||||
|
||||
kcr = KeyConflictResolution(3600, '/tmp/state.json', _TEMPLATE)
|
||||
kcr = KeyConflictResolution(3600, '/tmp/state.json', _KEY_TEMPLATE, _USER_TEMPLATE)
|
||||
contents = io.StringIO(_CONFLICT_STATE_NONE)
|
||||
contents.seek(io.SEEK_END) # Opened with 'a+'
|
||||
with patch('builtins.open', mock_open(read_data=_CONFLICT_STATE_NONE)) as mock_statefile:
|
||||
|
|
|
@ -8,13 +8,13 @@ from unittest.mock import MagicMock
|
|||
from dateutil.tz import tzutc
|
||||
|
||||
from multischleuder.processor import MultiList
|
||||
from multischleuder.reporting import ConflictMessage
|
||||
from multischleuder.reporting import Message
|
||||
from multischleuder.types import SchleuderKey, SchleuderList, SchleuderSubscriber
|
||||
|
||||
|
||||
def _resolve(target: str,
|
||||
mail_from: str,
|
||||
subscriptions: List[SchleuderSubscriber]) -> Tuple[List[SchleuderSubscriber], List[ConflictMessage]]:
|
||||
subscriptions: List[SchleuderSubscriber]) -> Tuple[List[SchleuderSubscriber], List[Message]]:
|
||||
# Mock conflict resolution that does not send or prepare messages
|
||||
subs: Dict[str, List[SchleuderSubscriber]] = {}
|
||||
for s in subscriptions:
|
||||
|
|
|
@ -3,13 +3,16 @@ import unittest
|
|||
|
||||
from datetime import datetime
|
||||
|
||||
from multischleuder.reporting import ConflictMessage, AdminReport, Reporter
|
||||
from multischleuder.reporting import KeyConflictMessage, AdminReport, Reporter, UserConflictMessage
|
||||
from multischleuder.types import SchleuderKey, SchleuderList, SchleuderSubscriber
|
||||
from multischleuder.test.test_conflict import _PRIVKEY_1
|
||||
|
||||
|
||||
def one_of_each_kind():
|
||||
sub = SchleuderSubscriber(1, 'foo@example.org', None, 1, datetime.utcnow())
|
||||
msg1 = ConflictMessage(
|
||||
key = SchleuderKey(_PRIVKEY_1.fingerprint.replace(' ', ''), 'foo@example.org', str(_PRIVKEY_1.pubkey), 1)
|
||||
sub2 = SchleuderSubscriber(2, 'bar@example.org', key, 1, datetime.utcnow())
|
||||
msg1 = KeyConflictMessage(
|
||||
schleuder='test@example.org',
|
||||
chosen=sub,
|
||||
affected=[sub],
|
||||
|
@ -26,7 +29,15 @@ def one_of_each_kind():
|
|||
updated={},
|
||||
added={},
|
||||
removed={})
|
||||
return [msg1, msg2]
|
||||
msg3 = UserConflictMessage(
|
||||
schleuder='test@example.org',
|
||||
subscriber='bar@example.org',
|
||||
chosen=sub2,
|
||||
affected=[sub2],
|
||||
digest='digest',
|
||||
mail_from='test-owner@example.org',
|
||||
template='averylongmessage')
|
||||
return [msg1, msg2, msg3]
|
||||
|
||||
|
||||
class TestReporting(unittest.TestCase):
|
||||
|
@ -36,11 +47,13 @@ class TestReporting(unittest.TestCase):
|
|||
r = Reporter(send_conflict_messages=True,
|
||||
send_admin_reports=True)
|
||||
r.add_messages(msgs)
|
||||
self.assertEquals(2, len(Reporter.get_messages()))
|
||||
self.assertIsInstance(Reporter.get_messages()[-2], ConflictMessage)
|
||||
self.assertEquals('foo@example.org', Reporter.get_messages()[-2]._to)
|
||||
self.assertIsInstance(Reporter.get_messages()[-1], AdminReport)
|
||||
self.assertEquals('admin@example.org', Reporter.get_messages()[-1]._to)
|
||||
self.assertEqual(3, len(Reporter.get_messages()))
|
||||
self.assertIsInstance(Reporter.get_messages()[0], KeyConflictMessage)
|
||||
self.assertEqual('foo@example.org', Reporter.get_messages()[0]._to)
|
||||
self.assertIsInstance(Reporter.get_messages()[1], AdminReport)
|
||||
self.assertEqual('admin@example.org', Reporter.get_messages()[1]._to)
|
||||
self.assertIsInstance(Reporter.get_messages()[2], UserConflictMessage)
|
||||
self.assertEqual('bar@example.org', Reporter.get_messages()[2]._to)
|
||||
Reporter.clear_messages()
|
||||
|
||||
def test_reporter_config_conflict_only(self):
|
||||
|
@ -48,9 +61,11 @@ class TestReporting(unittest.TestCase):
|
|||
r = Reporter(send_conflict_messages=True,
|
||||
send_admin_reports=False)
|
||||
r.add_messages(msgs)
|
||||
self.assertEquals(1, len(Reporter.get_messages()))
|
||||
self.assertIsInstance(Reporter.get_messages()[-1], ConflictMessage)
|
||||
self.assertEquals('foo@example.org', Reporter.get_messages()[-1]._to)
|
||||
self.assertEqual(2, len(Reporter.get_messages()))
|
||||
self.assertIsInstance(Reporter.get_messages()[0], KeyConflictMessage)
|
||||
self.assertEqual('foo@example.org', Reporter.get_messages()[0]._to)
|
||||
self.assertIsInstance(Reporter.get_messages()[1], UserConflictMessage)
|
||||
self.assertEqual('bar@example.org', Reporter.get_messages()[1]._to)
|
||||
Reporter.clear_messages()
|
||||
|
||||
def test_reporter_config_admin_only(self):
|
||||
|
@ -58,9 +73,9 @@ class TestReporting(unittest.TestCase):
|
|||
r = Reporter(send_conflict_messages=False,
|
||||
send_admin_reports=True)
|
||||
r.add_messages(msgs)
|
||||
self.assertEquals(1, len(Reporter.get_messages()))
|
||||
self.assertEqual(1, len(Reporter.get_messages()))
|
||||
self.assertIsInstance(Reporter.get_messages()[-1], AdminReport)
|
||||
self.assertEquals('admin@example.org', Reporter.get_messages()[-1]._to)
|
||||
self.assertEqual('admin@example.org', Reporter.get_messages()[-1]._to)
|
||||
Reporter.clear_messages()
|
||||
|
||||
def test_reporter_config_all_disabled(self):
|
||||
|
@ -68,11 +83,11 @@ class TestReporting(unittest.TestCase):
|
|||
r = Reporter(send_conflict_messages=False,
|
||||
send_admin_reports=False)
|
||||
r.add_messages(msgs)
|
||||
self.assertEquals(0, len(Reporter.get_messages()))
|
||||
self.assertEqual(0, len(Reporter.get_messages()))
|
||||
|
||||
def test_reporter_null_message(self):
|
||||
r = Reporter(send_conflict_messages=True,
|
||||
send_admin_reports=True)
|
||||
r.add_messages([None])
|
||||
self.assertEquals(0, len(Reporter.get_messages()))
|
||||
self.assertEqual(0, len(Reporter.get_messages()))
|
||||
Reporter.clear_messages()
|
||||
|
|
|
@ -7,7 +7,7 @@ from email.mime.text import MIMEText
|
|||
from aiosmtpd.controller import Controller
|
||||
from aiosmtpd.smtp import AuthResult, SMTP
|
||||
|
||||
from multischleuder.reporting import ConflictMessage, AdminReport
|
||||
from multischleuder.reporting import KeyConflictMessage, AdminReport
|
||||
from multischleuder.smtp import SmtpClient, TlsMode
|
||||
from multischleuder.types import SchleuderSubscriber
|
||||
|
||||
|
@ -170,7 +170,7 @@ class TestSmtpClient(unittest.TestCase):
|
|||
username='example',
|
||||
password='supersecurepassword')
|
||||
sub = SchleuderSubscriber(1, 'foo@example.org', None, 1, datetime.utcnow())
|
||||
msg1 = ConflictMessage(
|
||||
msg1 = KeyConflictMessage(
|
||||
schleuder='test@example.org',
|
||||
chosen=sub,
|
||||
affected=[sub],
|
||||
|
@ -203,7 +203,7 @@ class TestSmtpClient(unittest.TestCase):
|
|||
password='supersecurepassword')
|
||||
client.dry_run()
|
||||
sub = SchleuderSubscriber(1, 'foo@example.org', None, 1, datetime.utcnow())
|
||||
msg1 = ConflictMessage(
|
||||
msg1 = KeyConflictMessage(
|
||||
schleuder='test@example.org',
|
||||
chosen=sub,
|
||||
affected=[sub],
|
||||
|
|
|
@ -40,7 +40,7 @@ smtp:
|
|||
conflict:
|
||||
interval: 3600 # 1 hour - you don't want this in production
|
||||
statefile: /tmp/conflict.json
|
||||
template: |
|
||||
key_template: |
|
||||
Hi {subscriber},
|
||||
|
||||
While compiling the subscriber list of {schleuder}, your
|
||||
|
@ -65,8 +65,32 @@ conflict:
|
|||
message, please refer to your local Schleuder admin, or reply to this
|
||||
message.
|
||||
|
||||
Note that this automated message is unsigned, since MultiSchleuder does
|
||||
not have access to Schleuder private keys.
|
||||
Regards
|
||||
MultiSchleuder {schleuder}
|
||||
user_template: |
|
||||
Hi {subscriber},
|
||||
|
||||
While compiling the subscriber list of {schleuder}, your
|
||||
key {fingerprint} was used by subscribers on multiple sub-lists with
|
||||
different email adresses. There may be something fishy or malicious
|
||||
going on, or this may simply have been a mistake by you or a list admin.
|
||||
|
||||
You have only been subscribed to {schleuder} using the address you
|
||||
have been subscribed with for the *longest* time:
|
||||
|
||||
{chosen}
|
||||
|
||||
Please review the following adresses and talk to the admins of the
|
||||
corresponding sub-lists to resolve this issue:
|
||||
|
||||
Adress Sub-List
|
||||
------ --------
|
||||
{affected}
|
||||
|
||||
For your convenience, this message has been sent to *all* of the above
|
||||
adresses. If you have any questions, or do not understand this
|
||||
message, please refer to your local Schleuder admin, or reply to this
|
||||
message.
|
||||
|
||||
Regards
|
||||
MultiSchleuder {schleuder}
|
||||
|
|
Loading…
Reference in a new issue