Skip to content

Commit 0616b07

Browse files
feat: email ingestion API (ietf-tools#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 e498022. * 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
1 parent 18e98aa commit 0616b07

10 files changed

Lines changed: 577 additions & 9 deletions

File tree

ietf/api/tests.py

Lines changed: 191 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Copyright The IETF Trust 2015-2020, All Rights Reserved
22
# -*- coding: utf-8 -*-
3-
3+
import base64
44
import datetime
55
import json
66
import html
@@ -36,11 +36,12 @@
3636
from ietf.person.models import Email, User
3737
from ietf.person.models import PersonalApiKey
3838
from ietf.stats.models import MeetingRegistration
39-
from ietf.utils.mail import outbox, get_payload_text
39+
from ietf.utils.mail import empty_outbox, outbox, get_payload_text
4040
from ietf.utils.models import DumpInfo
4141
from ietf.utils.test_utils import TestCase, login_testing_unauthorized, reload_db_objects
4242

4343
from .ietf_utils import is_valid_token, requires_api_token
44+
from .views import EmailIngestionError
4445

4546
OMITTED_APPS = (
4647
'ietf.secr.meetings',
@@ -1013,6 +1014,194 @@ def test_role_holder_addresses(self):
10131014
sorted(e.address for e in emails),
10141015
)
10151016

1017+
@override_settings(APP_API_TOKENS={"ietf.api.views.ingest_email": "valid-token"})
1018+
@mock.patch("ietf.api.views.iana_ingest_review_email")
1019+
@mock.patch("ietf.api.views.ipr_ingest_response_email")
1020+
@mock.patch("ietf.api.views.nomcom_ingest_feedback_email")
1021+
def test_ingest_email(
1022+
self, mock_nomcom_ingest, mock_ipr_ingest, mock_iana_ingest
1023+
):
1024+
mocks = {mock_nomcom_ingest, mock_ipr_ingest, mock_iana_ingest}
1025+
empty_outbox()
1026+
url = urlreverse("ietf.api.views.ingest_email")
1027+
1028+
# test various bad calls
1029+
r = self.client.get(url)
1030+
self.assertEqual(r.status_code, 403)
1031+
self.assertFalse(any(m.called for m in mocks))
1032+
1033+
r = self.client.post(url)
1034+
self.assertEqual(r.status_code, 403)
1035+
self.assertFalse(any(m.called for m in mocks))
1036+
1037+
r = self.client.get(url, headers={"X-Api-Key": "valid-token"})
1038+
self.assertEqual(r.status_code, 405)
1039+
self.assertFalse(any(m.called for m in mocks))
1040+
1041+
r = self.client.post(url, headers={"X-Api-Key": "valid-token"})
1042+
self.assertEqual(r.status_code, 415)
1043+
self.assertFalse(any(m.called for m in mocks))
1044+
1045+
r = self.client.post(
1046+
url, content_type="application/json", headers={"X-Api-Key": "valid-token"}
1047+
)
1048+
self.assertEqual(r.status_code, 400)
1049+
self.assertFalse(any(m.called for m in mocks))
1050+
1051+
r = self.client.post(
1052+
url,
1053+
"this is not JSON!",
1054+
content_type="application/json",
1055+
headers={"X-Api-Key": "valid-token"},
1056+
)
1057+
self.assertEqual(r.status_code, 400)
1058+
self.assertFalse(any(m.called for m in mocks))
1059+
1060+
r = self.client.post(
1061+
url,
1062+
{"json": "yes", "valid_schema": False},
1063+
content_type="application/json",
1064+
headers={"X-Api-Key": "valid-token"},
1065+
)
1066+
self.assertEqual(r.status_code, 400)
1067+
self.assertFalse(any(m.called for m in mocks))
1068+
1069+
# test that valid requests call handlers appropriately
1070+
message_b64 = base64.b64encode(b"This is a message").decode()
1071+
r = self.client.post(
1072+
url,
1073+
{"dest": "iana-review", "message": message_b64},
1074+
content_type="application/json",
1075+
headers={"X-Api-Key": "valid-token"},
1076+
)
1077+
self.assertEqual(r.status_code, 200)
1078+
self.assertTrue(mock_iana_ingest.called)
1079+
self.assertEqual(mock_iana_ingest.call_args, mock.call(b"This is a message"))
1080+
self.assertFalse(any(m.called for m in (mocks - {mock_iana_ingest})))
1081+
mock_iana_ingest.reset_mock()
1082+
1083+
r = self.client.post(
1084+
url,
1085+
{"dest": "ipr-response", "message": message_b64},
1086+
content_type="application/json",
1087+
headers={"X-Api-Key": "valid-token"},
1088+
)
1089+
self.assertEqual(r.status_code, 200)
1090+
self.assertTrue(mock_ipr_ingest.called)
1091+
self.assertEqual(mock_ipr_ingest.call_args, mock.call(b"This is a message"))
1092+
self.assertFalse(any(m.called for m in (mocks - {mock_ipr_ingest})))
1093+
mock_ipr_ingest.reset_mock()
1094+
1095+
r = self.client.post(
1096+
url,
1097+
{"dest": "nomcom-feedback", "message": message_b64, "year": 2024}, # arbitrary year
1098+
content_type="application/json",
1099+
headers={"X-Api-Key": "valid-token"},
1100+
)
1101+
self.assertEqual(r.status_code, 200)
1102+
self.assertTrue(mock_nomcom_ingest.called)
1103+
self.assertEqual(mock_nomcom_ingest.call_args, mock.call(b"This is a message", 2024))
1104+
self.assertFalse(any(m.called for m in (mocks - {mock_nomcom_ingest})))
1105+
mock_nomcom_ingest.reset_mock()
1106+
1107+
# test that exceptions lead to email being sent - assumes that iana-review handling is representative
1108+
mock_iana_ingest.side_effect = EmailIngestionError("Error: don't send email")
1109+
r = self.client.post(
1110+
url,
1111+
{"dest": "iana-review", "message": message_b64},
1112+
content_type="application/json",
1113+
headers={"X-Api-Key": "valid-token"},
1114+
)
1115+
self.assertEqual(r.status_code, 400)
1116+
self.assertTrue(mock_iana_ingest.called)
1117+
self.assertEqual(mock_iana_ingest.call_args, mock.call(b"This is a message"))
1118+
self.assertFalse(any(m.called for m in (mocks - {mock_iana_ingest})))
1119+
self.assertEqual(len(outbox), 0) # implicitly tests that _none_ of the earlier tests sent email
1120+
mock_iana_ingest.reset_mock()
1121+
1122+
# test default recipients and attached original message
1123+
mock_iana_ingest.side_effect = EmailIngestionError(
1124+
"Error: do send email",
1125+
email_body="This is my email\n",
1126+
email_original_message=b"This is the original message"
1127+
)
1128+
with override_settings(ADMINS=[("Some Admin", "admin@example.com")]):
1129+
r = self.client.post(
1130+
url,
1131+
{"dest": "iana-review", "message": message_b64},
1132+
content_type="application/json",
1133+
headers={"X-Api-Key": "valid-token"},
1134+
)
1135+
self.assertEqual(r.status_code, 400)
1136+
self.assertTrue(mock_iana_ingest.called)
1137+
self.assertEqual(mock_iana_ingest.call_args, mock.call(b"This is a message"))
1138+
self.assertFalse(any(m.called for m in (mocks - {mock_iana_ingest})))
1139+
self.assertEqual(len(outbox), 1)
1140+
self.assertIn("admin@example.com", outbox[0]["To"])
1141+
self.assertEqual("Error: do send email", outbox[0]["Subject"])
1142+
self.assertEqual("This is my email\n", get_payload_text(outbox[0].get_body()))
1143+
attachments = list(a for a in outbox[0].iter_attachments())
1144+
self.assertEqual(len(attachments), 1)
1145+
self.assertEqual(attachments[0].get_filename(), "original-message")
1146+
self.assertEqual(attachments[0].get_content_type(), "application/octet-stream")
1147+
self.assertEqual(attachments[0].get_content(), b"This is the original message")
1148+
mock_iana_ingest.reset_mock()
1149+
empty_outbox()
1150+
1151+
# test overridden recipients and no attached original message
1152+
mock_iana_ingest.side_effect = EmailIngestionError(
1153+
"Error: do send email",
1154+
email_body="This is my email\n",
1155+
email_recipients=("thatguy@example.com")
1156+
)
1157+
with override_settings(ADMINS=[("Some Admin", "admin@example.com")]):
1158+
r = self.client.post(
1159+
url,
1160+
{"dest": "iana-review", "message": message_b64},
1161+
content_type="application/json",
1162+
headers={"X-Api-Key": "valid-token"},
1163+
)
1164+
self.assertEqual(r.status_code, 400)
1165+
self.assertTrue(mock_iana_ingest.called)
1166+
self.assertEqual(mock_iana_ingest.call_args, mock.call(b"This is a message"))
1167+
self.assertFalse(any(m.called for m in (mocks - {mock_iana_ingest})))
1168+
self.assertEqual(len(outbox), 1)
1169+
self.assertNotIn("admin@example.com", outbox[0]["To"])
1170+
self.assertIn("thatguy@example.com", outbox[0]["To"])
1171+
self.assertEqual("Error: do send email", outbox[0]["Subject"])
1172+
self.assertEqual("This is my email\n", get_payload_text(outbox[0]))
1173+
mock_iana_ingest.reset_mock()
1174+
empty_outbox()
1175+
1176+
# test attached traceback
1177+
mock_iana_ingest.side_effect = EmailIngestionError(
1178+
"Error: do send email",
1179+
email_body="This is my email\n",
1180+
email_attach_traceback=True,
1181+
)
1182+
with override_settings(ADMINS=[("Some Admin", "admin@example.com")]):
1183+
r = self.client.post(
1184+
url,
1185+
{"dest": "iana-review", "message": message_b64},
1186+
content_type="application/json",
1187+
headers={"X-Api-Key": "valid-token"},
1188+
)
1189+
self.assertEqual(r.status_code, 400)
1190+
self.assertTrue(mock_iana_ingest.called)
1191+
self.assertEqual(mock_iana_ingest.call_args, mock.call(b"This is a message"))
1192+
self.assertFalse(any(m.called for m in (mocks - {mock_iana_ingest})))
1193+
self.assertEqual(len(outbox), 1)
1194+
self.assertIn("admin@example.com", outbox[0]["To"])
1195+
self.assertEqual("Error: do send email", outbox[0]["Subject"])
1196+
self.assertEqual("This is my email\n", get_payload_text(outbox[0].get_body()))
1197+
attachments = list(a for a in outbox[0].iter_attachments())
1198+
self.assertEqual(len(attachments), 1)
1199+
self.assertEqual(attachments[0].get_filename(), "traceback.txt")
1200+
self.assertEqual(attachments[0].get_content_type(), "text/plain")
1201+
self.assertIn("ietf.api.views.EmailIngestionError: Error: do send email", attachments[0].get_content())
1202+
mock_iana_ingest.reset_mock()
1203+
empty_outbox()
1204+
10161205

10171206
class DirectAuthApiTests(TestCase):
10181207

ietf/api/urls.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@
2424
# --- Custom API endpoints, sorted alphabetically ---
2525
# Email alias information for drafts
2626
url(r'^doc/draft-aliases/$', api_views.draft_aliases),
27-
# GPRD: export of personal information for the logged-in person
27+
# email ingestor
28+
url(r'email/$', api_views.ingest_email),
29+
# GDPR: export of personal information for the logged-in person
2830
url(r'^export/personal-information/$', api_views.PersonalInformationExportView.as_view()),
2931
# Email alias information for groups
3032
url(r'^group/group-aliases/$', api_views.group_aliases),

0 commit comments

Comments
 (0)