fix: drop, not bounce, uninteresting ipr emails (#8057)

* fix: drop, not bounce, uninteresting ipr emails

* chore: log to address

* chore: unused import
This commit is contained in:
Jennifer Richards 2024-10-17 18:23:34 -03:00 committed by GitHub
parent de2e66ede3
commit 9c56ba9a0a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 72 additions and 39 deletions

View file

@ -171,31 +171,44 @@ def message_from_message(message,by=None):
) )
return msg return msg
class UndeliverableIprResponseError(Exception):
"""Response email could not be delivered and should be treated as an error"""
def process_response_email(msg): def process_response_email(msg):
"""Saves an incoming message. msg=string. Message "To" field is expected to """Save an incoming IPR response email message
be in the format ietf-ipr+[identifier]@ietf.org. Expect to find a message with
a matching value in the reply_to field, associated to an IPR disclosure through Message "To" field is expected to be in the format ietf-ipr+[identifier]@ietf.org. If
IprEvent. Create a Message object for the incoming message and associate it to the address or identifier is missing, the message will be silently dropped.
the original message via new IprEvent"""
Expect to find a message with a matching value in the reply_to field, associated to an
IPR disclosure through IprEvent. If it cannot be matched, raises UndeliverableIprResponseError
Creates a Message object for the incoming message and associates it to
the original message via new IprEvent
"""
message = message_from_bytes(force_bytes(msg)) message = message_from_bytes(force_bytes(msg))
to = message.get('To', '') to = message.get('To', '')
# exit if this isn't a response we're interested in (with plus addressing) # exit if this isn't a response we're interested in (with plus addressing)
local, domain = get_base_ipr_request_address().split('@') local, domain = get_base_ipr_request_address().split('@')
if not re.match(r'^{}\+[a-zA-Z0-9_\-]{}@{}'.format(local,'{16}',domain),to): if not re.match(r'^{}\+[a-zA-Z0-9_\-]{}@{}'.format(local,'{16}',domain),to):
return None _from = message.get("From", "<unknown>")
log(f"Ignoring IPR email without a message identifier from {_from} to {to}")
return
try: try:
to_message = Message.objects.get(reply_to=to) to_message = Message.objects.get(reply_to=to)
except Message.DoesNotExist: except Message.DoesNotExist:
log('Error finding matching message ({})'.format(to)) log('Error finding matching message ({})'.format(to))
return None raise UndeliverableIprResponseError(f"Unable to find message matching {to}")
try: try:
disclosure = to_message.msgevents.first().disclosure disclosure = to_message.msgevents.first().disclosure
except: except:
log('Error processing message ({})'.format(to)) log('Error processing message ({})'.format(to))
return None raise UndeliverableIprResponseError("Error processing message for {to}")
ietf_message = message_from_message(message) ietf_message = message_from_message(message)
IprEvent.objects.create( IprEvent.objects.create(
@ -207,4 +220,4 @@ def process_response_email(msg):
) )
log("Received IPR email from %s" % ietf_message.frm) log("Received IPR email from %s" % ietf_message.frm)
return ietf_message

View file

@ -9,7 +9,7 @@ from textwrap import dedent
from django.core.management import CommandError from django.core.management import CommandError
from ietf.utils.management.base import EmailOnFailureCommand from ietf.utils.management.base import EmailOnFailureCommand
from ietf.ipr.mail import process_response_email from ietf.ipr.mail import process_response_email, UndeliverableIprResponseError
import debug # pyflakes:ignore import debug # pyflakes:ignore
@ -31,7 +31,7 @@ class Command(EmailOnFailureCommand):
self.msg_bytes = sys.stdin.buffer.read() self.msg_bytes = sys.stdin.buffer.read()
try: try:
process_response_email(self.msg_bytes) process_response_email(self.msg_bytes)
except ValueError as e: except (ValueError, UndeliverableIprResponseError) as e:
raise CommandError(e) raise CommandError(e)
failure_subject = 'Error during ipr email processing' failure_subject = 'Error during ipr email processing'

View file

@ -4,6 +4,7 @@
import datetime import datetime
import mock import mock
import re
from pyquery import PyQuery from pyquery import PyQuery
from urllib.parse import quote, urlparse from urllib.parse import quote, urlparse
@ -35,9 +36,9 @@ from ietf.ipr.factories import (
) )
from ietf.ipr.forms import DraftForm, HolderIprDisclosureForm from ietf.ipr.forms import DraftForm, HolderIprDisclosureForm
from ietf.ipr.mail import (process_response_email, get_reply_to, get_update_submitter_emails, from ietf.ipr.mail import (process_response_email, get_reply_to, get_update_submitter_emails,
get_pseudo_submitter, get_holders, get_update_cc_addrs) get_pseudo_submitter, get_holders, get_update_cc_addrs, UndeliverableIprResponseError)
from ietf.ipr.models import (IprDisclosureBase, GenericIprDisclosure, HolderIprDisclosure, from ietf.ipr.models import (IprDisclosureBase, GenericIprDisclosure, HolderIprDisclosure,
ThirdPartyIprDisclosure) ThirdPartyIprDisclosure, IprEvent)
from ietf.ipr.templatetags.ipr_filters import no_revisions_message from ietf.ipr.templatetags.ipr_filters import no_revisions_message
from ietf.ipr.utils import get_genitive, get_ipr_summary, ingest_response_email from ietf.ipr.utils import get_genitive, get_ipr_summary, ingest_response_email
from ietf.mailtrigger.utils import gather_address_lists from ietf.mailtrigger.utils import gather_address_lists
@ -712,7 +713,7 @@ I would like to revoke this declaration.
) )
self.assertIn(f'{settings.IDTRACKER_BASE_URL}{urlreverse("ietf.ipr.views.showlist")}', get_payload_text(outbox[1]).replace('\n',' ')) self.assertIn(f'{settings.IDTRACKER_BASE_URL}{urlreverse("ietf.ipr.views.showlist")}', get_payload_text(outbox[1]).replace('\n',' '))
def send_ipr_email_helper(self): def send_ipr_email_helper(self) -> tuple[str, IprEvent, HolderIprDisclosure]:
ipr = HolderIprDisclosureFactory() ipr = HolderIprDisclosureFactory()
url = urlreverse('ietf.ipr.views.email',kwargs={ "id": ipr.id }) url = urlreverse('ietf.ipr.views.email',kwargs={ "id": ipr.id })
self.client.login(username="secretary", password="secretary+password") self.client.login(username="secretary", password="secretary+password")
@ -730,10 +731,11 @@ I would like to revoke this declaration.
q = Message.objects.filter(reply_to=data['reply_to']) q = Message.objects.filter(reply_to=data['reply_to'])
self.assertEqual(q.count(),1) self.assertEqual(q.count(),1)
event = q[0].msgevents.first() event = q[0].msgevents.first()
assert event is not None
self.assertTrue(event.response_past_due()) self.assertTrue(event.response_past_due())
self.assertEqual(len(outbox), 1) self.assertEqual(len(outbox), 1)
self.assertTrue('joe@test.com' in outbox[0]['To']) self.assertTrue('joe@test.com' in outbox[0]['To'])
return data['reply_to'], event return data['reply_to'], event, ipr
uninteresting_ipr_message_strings = [ uninteresting_ipr_message_strings = [
("To: {to}\nCc: {cc}\nFrom: joe@test.com\nDate: {date}\nSubject: test\n"), ("To: {to}\nCc: {cc}\nFrom: joe@test.com\nDate: {date}\nSubject: test\n"),
@ -747,19 +749,18 @@ I would like to revoke this declaration.
def test_process_response_email(self): def test_process_response_email(self):
# first send a mail # first send a mail
reply_to, event = self.send_ipr_email_helper() reply_to, event, _ = self.send_ipr_email_helper()
# test process response uninteresting messages # test process response uninteresting messages
addrs = gather_address_lists('ipr_disclosure_submitted').as_strings() addrs = gather_address_lists('ipr_disclosure_submitted').as_strings()
for message_string in self.uninteresting_ipr_message_strings: for message_string in self.uninteresting_ipr_message_strings:
result = process_response_email( process_response_email(
message_string.format( message_string.format(
to=addrs.to, to=addrs.to,
cc=addrs.cc, cc=addrs.cc,
date=timezone.now().ctime() date=timezone.now().ctime()
) )
) )
self.assertIsNone(result)
# test process response # test process response
message_string = """To: {} message_string = """To: {}
@ -767,14 +768,27 @@ From: joe@test.com
Date: {} Date: {}
Subject: test Subject: test
""".format(reply_to, timezone.now().ctime()) """.format(reply_to, timezone.now().ctime())
result = process_response_email(message_string) process_response_email(message_string)
self.assertIsInstance(result, Message)
self.assertFalse(event.response_past_due()) self.assertFalse(event.response_past_due())
# test with an unmatchable message identifier
bad_reply_to = re.sub(
r"\+.{16}@",
'+0123456789abcdef@',
reply_to,
)
self.assertNotEqual(reply_to, bad_reply_to)
message_string = f"""To: {bad_reply_to}
From: joe@test.com
Date: {timezone.now().ctime()}
Subject: test
"""
with self.assertRaises(UndeliverableIprResponseError):
process_response_email(message_string)
def test_process_response_email_with_invalid_encoding(self): def test_process_response_email_with_invalid_encoding(self):
"""Interesting emails with invalid encoding should be handled""" """Interesting emails with invalid encoding should be handled"""
reply_to, _ = self.send_ipr_email_helper() reply_to, _, disclosure = self.send_ipr_email_helper()
# test process response # test process response
message_string = """To: {} message_string = """To: {}
From: joe@test.com From: joe@test.com
@ -782,8 +796,8 @@ Date: {}
Subject: test Subject: test
""".format(reply_to, timezone.now().ctime()) """.format(reply_to, timezone.now().ctime())
message_bytes = message_string.encode('utf8') + b'\nInvalid stuff: \xfe\xff\n' message_bytes = message_string.encode('utf8') + b'\nInvalid stuff: \xfe\xff\n'
result = process_response_email(message_bytes) process_response_email(message_bytes)
self.assertIsInstance(result, Message) result = IprEvent.objects.filter(disclosure=disclosure).first().message # newest
# \ufffd is a rhombus character with an inverse ?, used to replace invalid characters # \ufffd is a rhombus character with an inverse ?, used to replace invalid characters
self.assertEqual(result.body, 'Invalid stuff: \ufffd\ufffd\n\n', # not sure where the extra \n is from self.assertEqual(result.body, 'Invalid stuff: \ufffd\ufffd\n\n', # not sure where the extra \n is from
'Invalid characters should be replaced with \ufffd characters') 'Invalid characters should be replaced with \ufffd characters')
@ -798,8 +812,7 @@ Subject: test
cc=addrs.cc, cc=addrs.cc,
date=timezone.now().ctime(), date=timezone.now().ctime(),
).encode('utf8') + b'\nInvalid stuff: \xfe\xff\n' ).encode('utf8') + b'\nInvalid stuff: \xfe\xff\n'
result = process_response_email(message_bytes) process_response_email(message_bytes)
self.assertIsNone(result)
@override_settings(ADMINS=(("Some Admin", "admin@example.com"),)) @override_settings(ADMINS=(("Some Admin", "admin@example.com"),))
@mock.patch("ietf.ipr.utils.process_response_email") @mock.patch("ietf.ipr.utils.process_response_email")
@ -816,8 +829,8 @@ Subject: test
self.assertEqual(mock_process_response_email.call_args, mock.call(message)) self.assertEqual(mock_process_response_email.call_args, mock.call(message))
mock_process_response_email.reset_mock() mock_process_response_email.reset_mock()
mock_process_response_email.side_effect = None mock_process_response_email.side_effect = UndeliverableIprResponseError
mock_process_response_email.return_value = None # rejected message mock_process_response_email.return_value = None
with self.assertRaises(EmailIngestionError) as context: with self.assertRaises(EmailIngestionError) as context:
ingest_response_email(message) ingest_response_email(message)
self.assertIsNone(context.exception.as_emailmessage()) # should not send an email on a clean rejection self.assertIsNone(context.exception.as_emailmessage()) # should not send an email on a clean rejection
@ -825,6 +838,14 @@ Subject: test
self.assertEqual(mock_process_response_email.call_args, mock.call(message)) self.assertEqual(mock_process_response_email.call_args, mock.call(message))
mock_process_response_email.reset_mock() mock_process_response_email.reset_mock()
mock_process_response_email.side_effect = None
mock_process_response_email.return_value = None # ignored message
ingest_response_email(message) # should not raise an exception
self.assertIsNone(context.exception.as_emailmessage()) # should not send an email on ignored message
self.assertTrue(mock_process_response_email.called)
self.assertEqual(mock_process_response_email.call_args, mock.call(message))
mock_process_response_email.reset_mock()
# successful operation # successful operation
mock_process_response_email.return_value = MessageFactory() mock_process_response_email.return_value = MessageFactory()
ingest_response_email(message) ingest_response_email(message)

View file

@ -3,7 +3,7 @@
from textwrap import dedent from textwrap import dedent
from ietf.ipr.mail import process_response_email from ietf.ipr.mail import process_response_email, UndeliverableIprResponseError
from ietf.ipr.models import IprDocRel from ietf.ipr.models import IprDocRel
import debug # pyflakes:ignore import debug # pyflakes:ignore
@ -92,7 +92,11 @@ def generate_draft_recursive_txt():
def ingest_response_email(message: bytes): def ingest_response_email(message: bytes):
from ietf.api.views import EmailIngestionError # avoid circular import from ietf.api.views import EmailIngestionError # avoid circular import
try: try:
result = process_response_email(message) process_response_email(message)
except UndeliverableIprResponseError:
# Message was rejected due to some problem the sender can fix, so bounce but don't send
# an email to the admins
raise EmailIngestionError("IPR response rejected", email_body=None)
except Exception as err: except Exception as err:
# Message was rejected due to an unhandled exception. This is likely something # Message was rejected due to an unhandled exception. This is likely something
# the admins need to address, so send them a copy of the email. # the admins need to address, so send them a copy of the email.
@ -106,8 +110,3 @@ def ingest_response_email(message: bytes):
email_original_message=message, email_original_message=message,
email_attach_traceback=True, email_attach_traceback=True,
) from err ) from err
if result is None:
# Message was rejected due to some problem the sender can fix, so bounce but don't send
# an email to the admins
raise EmailIngestionError("IPR response rejected", email_body=None)