feat: email ingestion API (#7342)

* feat: IANA review email ingestor API

* refactor: Replace iana email api with generic one

* chore: Add type hint

* feat: Ingest ipr responses

* feat: Ingest nomcom feedback

* refactor: message -> msg

* fix: Typo

* feat: Send email on nomcom ingestion failure

* feat: Send email on IPR mail ingestion error

* feat: Check content type, handle more errs

* fix: drop additionalProperties: false

Unfortunately this does not mix well with
the conditional "year" property.

* test: Test ingest_email view

* Revert "test: Test ingest_email view"

This reverts commit e498022829f834a0d3cebcb0dafb0d5f5a5d162e.

* test: Test ingest_email view

* fix: pass new test

* test: Test ingest_review_email

* fix: Pass new test

* test: Test ipr ingest_response_email

* fix: pass new test

* test: test nomcom ingest_feedback_email

* chore: fix typo found in code reviw

* fix: De-lint
This commit is contained in:
Jennifer Richards 2024-04-23 11:07:50 -03:00 committed by GitHub
parent 18e98aa899
commit 0616b07d2d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 577 additions and 9 deletions

View file

@ -1,6 +1,6 @@
# Copyright The IETF Trust 2015-2020, All Rights Reserved # Copyright The IETF Trust 2015-2020, All Rights Reserved
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import base64
import datetime import datetime
import json import json
import html import html
@ -36,11 +36,12 @@ from ietf.person.factories import PersonFactory, random_faker, EmailFactory
from ietf.person.models import Email, User from ietf.person.models import Email, User
from ietf.person.models import PersonalApiKey from ietf.person.models import PersonalApiKey
from ietf.stats.models import MeetingRegistration from ietf.stats.models import MeetingRegistration
from ietf.utils.mail import outbox, get_payload_text from ietf.utils.mail import empty_outbox, outbox, get_payload_text
from ietf.utils.models import DumpInfo from ietf.utils.models import DumpInfo
from ietf.utils.test_utils import TestCase, login_testing_unauthorized, reload_db_objects from ietf.utils.test_utils import TestCase, login_testing_unauthorized, reload_db_objects
from .ietf_utils import is_valid_token, requires_api_token from .ietf_utils import is_valid_token, requires_api_token
from .views import EmailIngestionError
OMITTED_APPS = ( OMITTED_APPS = (
'ietf.secr.meetings', 'ietf.secr.meetings',
@ -1013,6 +1014,194 @@ class CustomApiTests(TestCase):
sorted(e.address for e in emails), sorted(e.address for e in emails),
) )
@override_settings(APP_API_TOKENS={"ietf.api.views.ingest_email": "valid-token"})
@mock.patch("ietf.api.views.iana_ingest_review_email")
@mock.patch("ietf.api.views.ipr_ingest_response_email")
@mock.patch("ietf.api.views.nomcom_ingest_feedback_email")
def test_ingest_email(
self, mock_nomcom_ingest, mock_ipr_ingest, mock_iana_ingest
):
mocks = {mock_nomcom_ingest, mock_ipr_ingest, mock_iana_ingest}
empty_outbox()
url = urlreverse("ietf.api.views.ingest_email")
# test various bad calls
r = self.client.get(url)
self.assertEqual(r.status_code, 403)
self.assertFalse(any(m.called for m in mocks))
r = self.client.post(url)
self.assertEqual(r.status_code, 403)
self.assertFalse(any(m.called for m in mocks))
r = self.client.get(url, headers={"X-Api-Key": "valid-token"})
self.assertEqual(r.status_code, 405)
self.assertFalse(any(m.called for m in mocks))
r = self.client.post(url, headers={"X-Api-Key": "valid-token"})
self.assertEqual(r.status_code, 415)
self.assertFalse(any(m.called for m in mocks))
r = self.client.post(
url, content_type="application/json", headers={"X-Api-Key": "valid-token"}
)
self.assertEqual(r.status_code, 400)
self.assertFalse(any(m.called for m in mocks))
r = self.client.post(
url,
"this is not JSON!",
content_type="application/json",
headers={"X-Api-Key": "valid-token"},
)
self.assertEqual(r.status_code, 400)
self.assertFalse(any(m.called for m in mocks))
r = self.client.post(
url,
{"json": "yes", "valid_schema": False},
content_type="application/json",
headers={"X-Api-Key": "valid-token"},
)
self.assertEqual(r.status_code, 400)
self.assertFalse(any(m.called for m in mocks))
# test that valid requests call handlers appropriately
message_b64 = base64.b64encode(b"This is a message").decode()
r = self.client.post(
url,
{"dest": "iana-review", "message": message_b64},
content_type="application/json",
headers={"X-Api-Key": "valid-token"},
)
self.assertEqual(r.status_code, 200)
self.assertTrue(mock_iana_ingest.called)
self.assertEqual(mock_iana_ingest.call_args, mock.call(b"This is a message"))
self.assertFalse(any(m.called for m in (mocks - {mock_iana_ingest})))
mock_iana_ingest.reset_mock()
r = self.client.post(
url,
{"dest": "ipr-response", "message": message_b64},
content_type="application/json",
headers={"X-Api-Key": "valid-token"},
)
self.assertEqual(r.status_code, 200)
self.assertTrue(mock_ipr_ingest.called)
self.assertEqual(mock_ipr_ingest.call_args, mock.call(b"This is a message"))
self.assertFalse(any(m.called for m in (mocks - {mock_ipr_ingest})))
mock_ipr_ingest.reset_mock()
r = self.client.post(
url,
{"dest": "nomcom-feedback", "message": message_b64, "year": 2024}, # arbitrary year
content_type="application/json",
headers={"X-Api-Key": "valid-token"},
)
self.assertEqual(r.status_code, 200)
self.assertTrue(mock_nomcom_ingest.called)
self.assertEqual(mock_nomcom_ingest.call_args, mock.call(b"This is a message", 2024))
self.assertFalse(any(m.called for m in (mocks - {mock_nomcom_ingest})))
mock_nomcom_ingest.reset_mock()
# test that exceptions lead to email being sent - assumes that iana-review handling is representative
mock_iana_ingest.side_effect = EmailIngestionError("Error: don't send email")
r = self.client.post(
url,
{"dest": "iana-review", "message": message_b64},
content_type="application/json",
headers={"X-Api-Key": "valid-token"},
)
self.assertEqual(r.status_code, 400)
self.assertTrue(mock_iana_ingest.called)
self.assertEqual(mock_iana_ingest.call_args, mock.call(b"This is a message"))
self.assertFalse(any(m.called for m in (mocks - {mock_iana_ingest})))
self.assertEqual(len(outbox), 0) # implicitly tests that _none_ of the earlier tests sent email
mock_iana_ingest.reset_mock()
# test default recipients and attached original message
mock_iana_ingest.side_effect = EmailIngestionError(
"Error: do send email",
email_body="This is my email\n",
email_original_message=b"This is the original message"
)
with override_settings(ADMINS=[("Some Admin", "admin@example.com")]):
r = self.client.post(
url,
{"dest": "iana-review", "message": message_b64},
content_type="application/json",
headers={"X-Api-Key": "valid-token"},
)
self.assertEqual(r.status_code, 400)
self.assertTrue(mock_iana_ingest.called)
self.assertEqual(mock_iana_ingest.call_args, mock.call(b"This is a message"))
self.assertFalse(any(m.called for m in (mocks - {mock_iana_ingest})))
self.assertEqual(len(outbox), 1)
self.assertIn("admin@example.com", outbox[0]["To"])
self.assertEqual("Error: do send email", outbox[0]["Subject"])
self.assertEqual("This is my email\n", get_payload_text(outbox[0].get_body()))
attachments = list(a for a in outbox[0].iter_attachments())
self.assertEqual(len(attachments), 1)
self.assertEqual(attachments[0].get_filename(), "original-message")
self.assertEqual(attachments[0].get_content_type(), "application/octet-stream")
self.assertEqual(attachments[0].get_content(), b"This is the original message")
mock_iana_ingest.reset_mock()
empty_outbox()
# test overridden recipients and no attached original message
mock_iana_ingest.side_effect = EmailIngestionError(
"Error: do send email",
email_body="This is my email\n",
email_recipients=("thatguy@example.com")
)
with override_settings(ADMINS=[("Some Admin", "admin@example.com")]):
r = self.client.post(
url,
{"dest": "iana-review", "message": message_b64},
content_type="application/json",
headers={"X-Api-Key": "valid-token"},
)
self.assertEqual(r.status_code, 400)
self.assertTrue(mock_iana_ingest.called)
self.assertEqual(mock_iana_ingest.call_args, mock.call(b"This is a message"))
self.assertFalse(any(m.called for m in (mocks - {mock_iana_ingest})))
self.assertEqual(len(outbox), 1)
self.assertNotIn("admin@example.com", outbox[0]["To"])
self.assertIn("thatguy@example.com", outbox[0]["To"])
self.assertEqual("Error: do send email", outbox[0]["Subject"])
self.assertEqual("This is my email\n", get_payload_text(outbox[0]))
mock_iana_ingest.reset_mock()
empty_outbox()
# test attached traceback
mock_iana_ingest.side_effect = EmailIngestionError(
"Error: do send email",
email_body="This is my email\n",
email_attach_traceback=True,
)
with override_settings(ADMINS=[("Some Admin", "admin@example.com")]):
r = self.client.post(
url,
{"dest": "iana-review", "message": message_b64},
content_type="application/json",
headers={"X-Api-Key": "valid-token"},
)
self.assertEqual(r.status_code, 400)
self.assertTrue(mock_iana_ingest.called)
self.assertEqual(mock_iana_ingest.call_args, mock.call(b"This is a message"))
self.assertFalse(any(m.called for m in (mocks - {mock_iana_ingest})))
self.assertEqual(len(outbox), 1)
self.assertIn("admin@example.com", outbox[0]["To"])
self.assertEqual("Error: do send email", outbox[0]["Subject"])
self.assertEqual("This is my email\n", get_payload_text(outbox[0].get_body()))
attachments = list(a for a in outbox[0].iter_attachments())
self.assertEqual(len(attachments), 1)
self.assertEqual(attachments[0].get_filename(), "traceback.txt")
self.assertEqual(attachments[0].get_content_type(), "text/plain")
self.assertIn("ietf.api.views.EmailIngestionError: Error: do send email", attachments[0].get_content())
mock_iana_ingest.reset_mock()
empty_outbox()
class DirectAuthApiTests(TestCase): class DirectAuthApiTests(TestCase):

View file

@ -24,7 +24,9 @@ urlpatterns = [
# --- Custom API endpoints, sorted alphabetically --- # --- Custom API endpoints, sorted alphabetically ---
# Email alias information for drafts # Email alias information for drafts
url(r'^doc/draft-aliases/$', api_views.draft_aliases), url(r'^doc/draft-aliases/$', api_views.draft_aliases),
# GPRD: export of personal information for the logged-in person # email ingestor
url(r'email/$', api_views.ingest_email),
# GDPR: export of personal information for the logged-in person
url(r'^export/personal-information/$', api_views.PersonalInformationExportView.as_view()), url(r'^export/personal-information/$', api_views.PersonalInformationExportView.as_view()),
# Email alias information for groups # Email alias information for groups
url(r'^group/group-aliases/$', api_views.group_aliases), url(r'^group/group-aliases/$', api_views.group_aliases),

View file

@ -1,10 +1,13 @@
# Copyright The IETF Trust 2017-2020, All Rights Reserved # Copyright The IETF Trust 2017-2020, All Rights Reserved
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import base64
import binascii
import json import json
import jsonschema
import pytz
import re import re
import pytz
from django.conf import settings from django.conf import settings
from django.contrib.auth import authenticate from django.contrib.auth import authenticate
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
@ -18,11 +21,15 @@ from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.gzip import gzip_page from django.views.decorators.gzip import gzip_page
from django.views.generic.detail import DetailView from django.views.generic.detail import DetailView
from email.message import EmailMessage
from jwcrypto.jwk import JWK from jwcrypto.jwk import JWK
from tastypie.exceptions import BadRequest from tastypie.exceptions import BadRequest
from tastypie.serializers import Serializer from tastypie.serializers import Serializer
from tastypie.utils import is_valid_jsonp_callback_value from tastypie.utils import is_valid_jsonp_callback_value
from tastypie.utils.mime import determine_format, build_content_type from tastypie.utils.mime import determine_format, build_content_type
from textwrap import dedent
from traceback import format_exception, extract_tb
from typing import Iterable, Optional
import ietf import ietf
from ietf.api import _api_list from ietf.api import _api_list
@ -32,12 +39,16 @@ from ietf.doc.utils import DraftAliasGenerator, fuzzy_find_documents
from ietf.group.utils import GroupAliasGenerator, role_holder_emails from ietf.group.utils import GroupAliasGenerator, role_holder_emails
from ietf.ietfauth.utils import role_required from ietf.ietfauth.utils import role_required
from ietf.ietfauth.views import send_account_creation_email from ietf.ietfauth.views import send_account_creation_email
from ietf.ipr.utils import ingest_response_email as ipr_ingest_response_email
from ietf.meeting.models import Meeting from ietf.meeting.models import Meeting
from ietf.nomcom.models import Volunteer, NomCom from ietf.nomcom.models import Volunteer, NomCom
from ietf.nomcom.utils import ingest_feedback_email as nomcom_ingest_feedback_email
from ietf.person.models import Person, Email from ietf.person.models import Person, Email
from ietf.stats.models import MeetingRegistration from ietf.stats.models import MeetingRegistration
from ietf.sync.iana import ingest_review_email as iana_ingest_review_email
from ietf.utils import log from ietf.utils import log
from ietf.utils.decorators import require_api_key from ietf.utils.decorators import require_api_key
from ietf.utils.mail import send_smtp
from ietf.utils.models import DumpInfo from ietf.utils.models import DumpInfo
@ -515,3 +526,153 @@ def role_holder_addresses(request):
} }
) )
return HttpResponse(status=405) return HttpResponse(status=405)
_response_email_json_validator = jsonschema.Draft202012Validator(
schema={
"type": "object",
"properties": {
"dest": {
"enum": [
"iana-review",
"ipr-response",
"nomcom-feedback",
]
},
"message": {
"type": "string", # base64-encoded mail message
},
},
"required": ["dest", "message"],
"if": {
# If dest == "nomcom-feedback"...
"properties": {
"dest": {"const": "nomcom-feedback"},
}
},
"then": {
# ... then also require year, an integer, be present
"properties": {
"year": {
"type": "integer",
},
},
"required": ["year"],
},
}
)
class EmailIngestionError(Exception):
"""Exception indicating ingestion failed"""
def __init__(
self,
msg="Message rejected",
*,
email_body: Optional[str] = None,
email_recipients: Optional[Iterable[str]] = None,
email_attach_traceback=False,
email_original_message: Optional[bytes]=None,
):
self.msg = msg
self.email_body = email_body
self.email_subject = msg
self.email_recipients = email_recipients
self.email_attach_traceback = email_attach_traceback
self.email_original_message = email_original_message
self.email_from = settings.SERVER_EMAIL
@staticmethod
def _summarize_error(error):
frame = extract_tb(error.__traceback__)[-1]
return dedent(f"""\
Error details:
Exception type: {type(error).__module__}.{type(error).__name__}
File: {frame.filename}
Line: {frame.lineno}""")
def as_emailmessage(self) -> Optional[EmailMessage]:
"""Generate an EmailMessage to report an error"""
if self.email_body is None:
return None
error = self if self.__cause__ is None else self.__cause__
format_values = dict(
error=error,
error_summary=self._summarize_error(error),
)
msg = EmailMessage()
if self.email_recipients is None:
msg["To"] = tuple(adm[1] for adm in settings.ADMINS)
else:
msg["To"] = self.email_recipients
msg["From"] = self.email_from
msg["Subject"] = self.msg
msg.set_content(
self.email_body.format(**format_values)
)
if self.email_attach_traceback:
msg.add_attachment(
"".join(format_exception(None, error, error.__traceback__)),
filename="traceback.txt",
)
if self.email_original_message is not None:
# Attach incoming message if it was provided. Send as a generic media
# type because we don't know for sure that it was actually a valid
# message.
msg.add_attachment(
self.email_original_message,
'application', 'octet-stream', # media type
filename='original-message',
)
return msg
@requires_api_token
@csrf_exempt
def ingest_email(request):
def _err(code, text):
return HttpResponse(text, status=code, content_type="text/plain")
if request.method != "POST":
return _err(405, "Method not allowed")
if request.content_type != "application/json":
return _err(415, "Content-Type must be application/json")
# Validate
try:
payload = json.loads(request.body)
_response_email_json_validator.validate(payload)
except json.decoder.JSONDecodeError as err:
return _err(400, f"JSON parse error at line {err.lineno} col {err.colno}: {err.msg}")
except jsonschema.exceptions.ValidationError as err:
return _err(400, f"JSON schema error at {err.json_path}: {err.message}")
except Exception:
return _err(400, "Invalid request format")
try:
message = base64.b64decode(payload["message"], validate=True)
except binascii.Error:
return _err(400, "Invalid message: bad base64 encoding")
dest = payload["dest"]
try:
if dest == "iana-review":
iana_ingest_review_email(message)
elif dest == "ipr-response":
ipr_ingest_response_email(message)
elif dest == "nomcom-feedback":
year = payload["year"]
nomcom_ingest_feedback_email(message, year)
else:
# Should never get here - json schema validation should enforce the enum
log.unreachable(date="2024-04-04")
return _err(400, "Invalid dest") # return something reasonable if we got here unexpectedly
except EmailIngestionError as err:
error_email = err.as_emailmessage()
if error_email is not None:
send_smtp(error_email)
return _err(400, err.msg)
return HttpResponse(status=200)

View file

@ -3,18 +3,20 @@
import datetime import datetime
import mock
from pyquery import PyQuery from pyquery import PyQuery
from urllib.parse import quote, urlparse from urllib.parse import quote, urlparse
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from django.conf import settings from django.conf import settings
from django.test.utils import override_settings
from django.urls import reverse as urlreverse from django.urls import reverse as urlreverse
from django.utils import timezone from django.utils import timezone
import debug # pyflakes:ignore import debug # pyflakes:ignore
from ietf.api.views import EmailIngestionError
from ietf.doc.factories import ( from ietf.doc.factories import (
DocumentFactory, DocumentFactory,
WgDraftFactory, WgDraftFactory,
@ -34,8 +36,9 @@ from ietf.ipr.mail import (process_response_email, get_reply_to, get_update_subm
from ietf.ipr.models import (IprDisclosureBase,GenericIprDisclosure,HolderIprDisclosure, from ietf.ipr.models import (IprDisclosureBase,GenericIprDisclosure,HolderIprDisclosure,
ThirdPartyIprDisclosure) ThirdPartyIprDisclosure)
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 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
from ietf.message.factories import MessageFactory
from ietf.message.models import Message from ietf.message.models import Message
from ietf.utils.mail import outbox, empty_outbox, get_payload_text from ietf.utils.mail import outbox, empty_outbox, get_payload_text
from ietf.utils.test_utils import TestCase, login_testing_unauthorized from ietf.utils.test_utils import TestCase, login_testing_unauthorized
@ -769,6 +772,39 @@ Subject: test
result = process_response_email(message_bytes) result = process_response_email(message_bytes)
self.assertIsNone(result) self.assertIsNone(result)
@override_settings(ADMINS=(("Some Admin", "admin@example.com"),))
@mock.patch("ietf.ipr.utils.process_response_email")
def test_ingest_response_email(self, mock_process_response_email):
message = b"What a nice message"
mock_process_response_email.side_effect = ValueError("ouch!")
with self.assertRaises(EmailIngestionError) as context:
ingest_response_email(message)
self.assertIsNone(context.exception.email_recipients) # default recipients
self.assertIsNotNone(context.exception.email_body) # body set
self.assertIsNotNone(context.exception.email_original_message) # original message attached
self.assertEqual(context.exception.email_attach_traceback, True)
self.assertTrue(mock_process_response_email.called)
self.assertEqual(mock_process_response_email.call_args, mock.call(message))
mock_process_response_email.reset_mock()
mock_process_response_email.side_effect = None
mock_process_response_email.return_value = None # rejected message
with self.assertRaises(EmailIngestionError) as context:
ingest_response_email(message)
self.assertIsNone(context.exception.email_recipients) # default recipients
self.assertIsNotNone(context.exception.email_body) # body set
self.assertIsNotNone(context.exception.email_original_message) # original message attached
self.assertEqual(context.exception.email_attach_traceback, True)
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
mock_process_response_email.return_value = MessageFactory()
ingest_response_email(message)
self.assertTrue(mock_process_response_email.called)
self.assertEqual(mock_process_response_email.call_args, mock.call(message))
def test_ajax_search(self): def test_ajax_search(self):
url = urlreverse('ietf.ipr.views.ajax_search') url = urlreverse('ietf.ipr.views.ajax_search')
response=self.client.get(url+'?q=disclosure') response=self.client.get(url+'?q=disclosure')

View file

@ -1,6 +1,9 @@
# Copyright The IETF Trust 2014-2020, All Rights Reserved # Copyright The IETF Trust 2014-2020, All Rights Reserved
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from textwrap import dedent
from ietf.ipr.mail import process_response_email
from ietf.ipr.models import IprDocRel from ietf.ipr.models import IprDocRel
import debug # pyflakes:ignore import debug # pyflakes:ignore
@ -86,3 +89,30 @@ def generate_draft_recursive_txt():
f.write(data) f.write(data)
def ingest_response_email(message: bytes):
from ietf.api.views import EmailIngestionError # avoid circular import
try:
result = process_response_email(message)
except Exception as err:
raise EmailIngestionError(
"Datatracker IPR email ingestion error",
email_body=dedent("""\
An error occurred while ingesting IPR email into the Datatracker. The original message is attached.
{error_summary}
"""),
email_original_message=message,
email_attach_traceback=True,
) from err
if result is None:
raise EmailIngestionError(
"Datatracker IPR email ingestion rejected",
email_body=dedent("""\
A message was rejected while ingesting IPR email into the Datatracker. The original message is attached.
{error_summary}
"""),
email_original_message=message,
email_attach_traceback=True,
)

View file

@ -24,6 +24,7 @@ from django.utils.encoding import force_str
import debug # pyflakes:ignore import debug # pyflakes:ignore
from ietf.api.views import EmailIngestionError
from ietf.dbtemplate.factories import DBTemplateFactory from ietf.dbtemplate.factories import DBTemplateFactory
from ietf.dbtemplate.models import DBTemplate from ietf.dbtemplate.models import DBTemplate
from ietf.doc.factories import DocEventFactory, WgDocumentAuthorFactory, \ from ietf.doc.factories import DocEventFactory, WgDocumentAuthorFactory, \
@ -37,14 +38,15 @@ from ietf.nomcom.test_data import nomcom_test_data, generate_cert, check_comment
MEMBER_USER, SECRETARIAT_USER, EMAIL_DOMAIN, NOMCOM_YEAR MEMBER_USER, SECRETARIAT_USER, EMAIL_DOMAIN, NOMCOM_YEAR
from ietf.nomcom.models import NomineePosition, Position, Nominee, \ from ietf.nomcom.models import NomineePosition, Position, Nominee, \
NomineePositionStateName, Feedback, FeedbackTypeName, \ NomineePositionStateName, Feedback, FeedbackTypeName, \
Nomination, FeedbackLastSeen, TopicFeedbackLastSeen, ReminderDates Nomination, FeedbackLastSeen, TopicFeedbackLastSeen, ReminderDates, \
NomCom
from ietf.nomcom.management.commands.send_reminders import Command, is_time_to_send from ietf.nomcom.management.commands.send_reminders import Command, is_time_to_send
from ietf.nomcom.factories import NomComFactory, FeedbackFactory, TopicFactory, \ from ietf.nomcom.factories import NomComFactory, FeedbackFactory, TopicFactory, \
nomcom_kwargs_for_year, provide_private_key_to_test_client, \ nomcom_kwargs_for_year, provide_private_key_to_test_client, \
key key
from ietf.nomcom.utils import get_nomcom_by_year, make_nomineeposition, \ from ietf.nomcom.utils import get_nomcom_by_year, make_nomineeposition, \
get_hash_nominee_position, is_eligible, list_eligible, \ get_hash_nominee_position, is_eligible, list_eligible, \
get_eligibility_date, suggest_affiliation, \ get_eligibility_date, suggest_affiliation, ingest_feedback_email, \
decorate_volunteers_with_qualifications decorate_volunteers_with_qualifications
from ietf.person.factories import PersonFactory, EmailFactory from ietf.person.factories import PersonFactory, EmailFactory
from ietf.person.models import Email, Person from ietf.person.models import Email, Person
@ -1114,6 +1116,47 @@ class FeedbackTest(TestCase):
self.assertNotEqual(feedback.comments, comment_text) self.assertNotEqual(feedback.comments, comment_text)
self.assertEqual(check_comments(feedback.comments, comment_text, self.privatekey_file), True) self.assertEqual(check_comments(feedback.comments, comment_text, self.privatekey_file), True)
@mock.patch("ietf.nomcom.utils.create_feedback_email")
def test_ingest_feedback_email(self, mock_create_feedback_email):
message = b"This is nomcom feedback"
no_nomcom_year = date_today().year + 10 # a guess at a year with no nomcoms
while NomCom.objects.filter(group__acronym__icontains=no_nomcom_year).exists():
no_nomcom_year += 1
inactive_nomcom = NomComFactory(group__state_id="conclude", group__acronym=f"nomcom{no_nomcom_year + 1}")
# cases where the nomcom does not exist, so admins are notified
for bad_year in (no_nomcom_year, inactive_nomcom.year()):
with self.assertRaises(EmailIngestionError) as context:
ingest_feedback_email(message, bad_year)
self.assertIn("does not exist", context.exception.msg)
self.assertIsNotNone(context.exception.email_body) # error message to be sent
self.assertIsNone(context.exception.email_recipients) # default recipients (i.e., admin)
self.assertIsNone(context.exception.email_original_message) # no original message
self.assertFalse(context.exception.email_attach_traceback) # no traceback
self.assertFalse(mock_create_feedback_email.called)
# nomcom exists but an error occurs, so feedback goes to the nomcom chair
active_nomcom = NomComFactory(group__acronym=f"nomcom{no_nomcom_year + 2}")
mock_create_feedback_email.side_effect = ValueError("ouch!")
with self.assertRaises(EmailIngestionError) as context:
ingest_feedback_email(message, active_nomcom.year())
self.assertIn(f"Error ingesting nomcom {active_nomcom.year()}", context.exception.msg)
self.assertIsNotNone(context.exception.email_body) # error message to be sent
self.assertEqual(context.exception.email_recipients, active_nomcom.chair_emails())
self.assertEqual(context.exception.email_original_message, message)
self.assertFalse(context.exception.email_attach_traceback) # no traceback
self.assertTrue(mock_create_feedback_email.called)
self.assertEqual(mock_create_feedback_email.call_args, mock.call(active_nomcom, message))
mock_create_feedback_email.reset_mock()
# and, finally, success
mock_create_feedback_email.side_effect = None
mock_create_feedback_email.return_value = FeedbackFactory(author="someone@example.com")
ingest_feedback_email(message, active_nomcom.year())
self.assertTrue(mock_create_feedback_email.called)
self.assertEqual(mock_create_feedback_email.call_args, mock.call(active_nomcom, message))
class ReminderTest(TestCase): class ReminderTest(TestCase):
def setUp(self): def setUp(self):

View file

@ -16,6 +16,7 @@ from email.errors import HeaderParseError
from email.header import decode_header from email.header import decode_header
from email.iterators import typed_subpart_iterator from email.iterators import typed_subpart_iterator
from email.utils import parseaddr from email.utils import parseaddr
from textwrap import dedent
from django.db.models import Q, Count from django.db.models import Q, Count
from django.conf import settings from django.conf import settings
@ -715,3 +716,34 @@ def extract_volunteers(year):
decorate_volunteers_with_qualifications(volunteers,nomcom=nomcom) decorate_volunteers_with_qualifications(volunteers,nomcom=nomcom)
volunteers = sorted(volunteers,key=lambda v:(not v.eligible,v.person.last_name())) volunteers = sorted(volunteers,key=lambda v:(not v.eligible,v.person.last_name()))
return nomcom, volunteers return nomcom, volunteers
def ingest_feedback_email(message: bytes, year: int):
from ietf.api.views import EmailIngestionError # avoid circular import
from .models import NomCom
try:
nomcom = NomCom.objects.get(group__acronym__icontains=str(year),
group__state__slug='active')
except NomCom.DoesNotExist:
raise EmailIngestionError(
f"Error ingesting nomcom email: nomcom {year} does not exist or is not active",
email_body=dedent(f"""\
An email for nomcom {year} was posted to ingest_feedback_email, but no
active nomcom exists for that year.
"""),
)
try:
feedback = create_feedback_email(nomcom, message)
except Exception as err:
raise EmailIngestionError(
f"Error ingesting nomcom {year} feedback email",
email_recipients=nomcom.chair_emails(),
email_body=dedent(f"""\
An error occurred while ingesting feedback email for nomcom {year}.
{{error_summary}}
"""),
email_original_message=message,
) from err
log("Received nomcom email from %s" % feedback.author)

View file

@ -304,3 +304,22 @@ def add_review_comment(doc_name, review_time, by, comment):
e.by = by e.by = by
e.save() e.save()
def ingest_review_email(message: bytes):
from ietf.api.views import EmailIngestionError # avoid circular import
try:
doc_name, review_time, by, comment = parse_review_email(message)
except Exception as err:
raise EmailIngestionError("Unable to parse message as IANA review email") from err
log(f"Read IANA review email for {doc_name} at {review_time} by {by}")
if by.name == "(System)":
log("WARNING: person responsible for email does not have a IANA role") # (sic)
try:
add_review_comment(doc_name, review_time, by, comment)
except Document.DoesNotExist:
log(f"ERROR: unknown document {doc_name}")
raise EmailIngestionError(f"Unknown document {doc_name}")
except Exception as err:
raise EmailIngestionError("Error ingesting IANA review email") from err

View file

@ -19,10 +19,12 @@ from django.test.utils import override_settings
import debug # pyflakes:ignore import debug # pyflakes:ignore
from ietf.api.views import EmailIngestionError
from ietf.doc.factories import WgDraftFactory, RfcFactory, DocumentAuthorFactory, DocEventFactory from ietf.doc.factories import WgDraftFactory, RfcFactory, DocumentAuthorFactory, DocEventFactory
from ietf.doc.models import Document, DocEvent, DeletedEvent, DocTagName, RelatedDocument, State, StateDocEvent from ietf.doc.models import Document, DocEvent, DeletedEvent, DocTagName, RelatedDocument, State, StateDocEvent
from ietf.doc.utils import add_state_change_event from ietf.doc.utils import add_state_change_event
from ietf.group.factories import GroupFactory from ietf.group.factories import GroupFactory
from ietf.person.factories import PersonFactory
from ietf.person.models import Person from ietf.person.models import Person
from ietf.sync import iana, rfceditor, tasks from ietf.sync import iana, rfceditor, tasks
from ietf.utils.mail import outbox, empty_outbox from ietf.utils.mail import outbox, empty_outbox
@ -214,6 +216,61 @@ ICANN
iana.add_review_comment(doc_name, review_time, by, comment) iana.add_review_comment(doc_name, review_time, by, comment)
self.assertEqual(DocEvent.objects.filter(doc=draft, type="iana_review").count(), events_before+1) self.assertEqual(DocEvent.objects.filter(doc=draft, type="iana_review").count(), events_before+1)
@mock.patch("ietf.sync.iana.add_review_comment")
@mock.patch("ietf.sync.iana.parse_review_email")
def test_ingest_review_email(self, mock_parse_review_email, mock_add_review_comment):
mock_parse_review_email.side_effect = ValueError("ouch!")
message = b"message"
# Error parsing mail
with self.assertRaises(EmailIngestionError) as context:
iana.ingest_review_email(message)
self.assertIsNone(context.exception.as_emailmessage()) # no email
self.assertEqual("Unable to parse message as IANA review email", str(context.exception))
self.assertTrue(mock_parse_review_email.called)
self.assertEqual(mock_parse_review_email.call_args, mock.call(message))
self.assertFalse(mock_add_review_comment.called)
mock_parse_review_email.reset_mock()
args = (
"doc-name",
datetime.datetime.now(tz=datetime.timezone.utc),
PersonFactory(),
"yadda yadda yadda",
)
mock_parse_review_email.side_effect = None
mock_parse_review_email.return_value = args
mock_add_review_comment.side_effect = Document.DoesNotExist
with self.assertRaises(EmailIngestionError) as context:
iana.ingest_review_email(message)
self.assertIsNone(context.exception.as_emailmessage()) # no email
self.assertEqual(str(context.exception), "Unknown document doc-name")
self.assertTrue(mock_parse_review_email.called)
self.assertEqual(mock_parse_review_email.call_args, mock.call(message))
self.assertTrue(mock_add_review_comment.called)
self.assertEqual(mock_add_review_comment.call_args, mock.call(*args))
mock_parse_review_email.reset_mock()
mock_add_review_comment.reset_mock()
mock_add_review_comment.side_effect = ValueError("ouch!")
with self.assertRaises(EmailIngestionError) as context:
iana.ingest_review_email(message)
self.assertIsNone(context.exception.as_emailmessage()) # no email
self.assertEqual("Error ingesting IANA review email", str(context.exception))
self.assertTrue(mock_parse_review_email.called)
self.assertEqual(mock_parse_review_email.call_args, mock.call(message))
self.assertTrue(mock_add_review_comment.called)
self.assertEqual(mock_add_review_comment.call_args, mock.call(*args))
mock_parse_review_email.reset_mock()
mock_add_review_comment.reset_mock()
mock_add_review_comment.side_effect = None
iana.ingest_review_email(message)
self.assertTrue(mock_parse_review_email.called)
self.assertEqual(mock_parse_review_email.call_args, mock.call(message))
self.assertTrue(mock_add_review_comment.called)
self.assertEqual(mock_add_review_comment.call_args, mock.call(*args))
def test_notify_page(self): def test_notify_page(self):
# check that we can get the notify page # check that we can get the notify page
url = urlreverse("ietf.sync.views.notify", kwargs=dict(org="iana", notification="changes")) url = urlreverse("ietf.sync.views.notify", kwargs=dict(org="iana", notification="changes"))

View file

@ -1,7 +1,6 @@
# Copyright The IETF Trust 2012-2020, All Rights Reserved # Copyright The IETF Trust 2012-2020, All Rights Reserved
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import datetime import datetime
import subprocess import subprocess
import os import os