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:
parent
de2e66ede3
commit
9c56ba9a0a
|
@ -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
|
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -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,34 +749,46 @@ 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: {}
|
||||||
From: joe@test.com
|
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)
|
||||||
|
|
|
@ -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)
|
|
||||||
|
|
Loading…
Reference in a new issue