Skip to content

Commit 35ab9bf

Browse files
refactor: adjust mail ingestion api (ietf-tools#7523)
1 parent 7541c21 commit 35ab9bf

2 files changed

Lines changed: 87 additions & 44 deletions

File tree

ietf/api/tests.py

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
from importlib import import_module
1212
from pathlib import Path
13+
from random import randrange
1314

1415
from django.apps import apps
1516
from django.conf import settings
@@ -1072,15 +1073,29 @@ def test_ingest_email(
10721073
self.assertEqual(r.status_code, 400)
10731074
self.assertFalse(any(m.called for m in mocks))
10741075

1075-
# test that valid requests call handlers appropriately
1076+
# bad destination
10761077
message_b64 = base64.b64encode(b"This is a message").decode()
1078+
r = self.client.post(
1079+
url,
1080+
{"dest": "not-a-destination", "message": message_b64},
1081+
content_type="application/json",
1082+
headers={"X-Api-Key": "valid-token"},
1083+
)
1084+
self.assertEqual(r.status_code, 200)
1085+
self.assertEqual(r.headers["Content-Type"], "application/json")
1086+
self.assertEqual(json.loads(r.content), {"result": "bad_dest"})
1087+
self.assertFalse(any(m.called for m in mocks))
1088+
1089+
# test that valid requests call handlers appropriately
10771090
r = self.client.post(
10781091
url,
10791092
{"dest": "iana-review", "message": message_b64},
10801093
content_type="application/json",
10811094
headers={"X-Api-Key": "valid-token"},
10821095
)
10831096
self.assertEqual(r.status_code, 200)
1097+
self.assertEqual(r.headers["Content-Type"], "application/json")
1098+
self.assertEqual(json.loads(r.content), {"result": "ok"})
10841099
self.assertTrue(mock_iana_ingest.called)
10851100
self.assertEqual(mock_iana_ingest.call_args, mock.call(b"This is a message"))
10861101
self.assertFalse(any(m.called for m in (mocks - {mock_iana_ingest})))
@@ -1093,20 +1108,44 @@ def test_ingest_email(
10931108
headers={"X-Api-Key": "valid-token"},
10941109
)
10951110
self.assertEqual(r.status_code, 200)
1111+
self.assertEqual(r.headers["Content-Type"], "application/json")
1112+
self.assertEqual(json.loads(r.content), {"result": "ok"})
10961113
self.assertTrue(mock_ipr_ingest.called)
10971114
self.assertEqual(mock_ipr_ingest.call_args, mock.call(b"This is a message"))
10981115
self.assertFalse(any(m.called for m in (mocks - {mock_ipr_ingest})))
10991116
mock_ipr_ingest.reset_mock()
11001117

1118+
# bad nomcom-feedback dest
1119+
for bad_nomcom_dest in [
1120+
"nomcom-feedback", # no suffix
1121+
"nomcom-feedback-", # no year
1122+
"nomcom-feedback-squid", # not a year,
1123+
"nomcom-feedback-2024-2025", # also not a year
1124+
]:
1125+
r = self.client.post(
1126+
url,
1127+
{"dest": bad_nomcom_dest, "message": message_b64},
1128+
content_type="application/json",
1129+
headers={"X-Api-Key": "valid-token"},
1130+
)
1131+
self.assertEqual(r.status_code, 200)
1132+
self.assertEqual(r.headers["Content-Type"], "application/json")
1133+
self.assertEqual(json.loads(r.content), {"result": "bad_dest"})
1134+
self.assertFalse(any(m.called for m in mocks))
1135+
1136+
# good nomcom-feedback dest
1137+
random_year = randrange(100000)
11011138
r = self.client.post(
11021139
url,
1103-
{"dest": "nomcom-feedback", "message": message_b64, "year": 2024}, # arbitrary year
1140+
{"dest": f"nomcom-feedback-{random_year}", "message": message_b64},
11041141
content_type="application/json",
11051142
headers={"X-Api-Key": "valid-token"},
11061143
)
11071144
self.assertEqual(r.status_code, 200)
1145+
self.assertEqual(r.headers["Content-Type"], "application/json")
1146+
self.assertEqual(json.loads(r.content), {"result": "ok"})
11081147
self.assertTrue(mock_nomcom_ingest.called)
1109-
self.assertEqual(mock_nomcom_ingest.call_args, mock.call(b"This is a message", 2024))
1148+
self.assertEqual(mock_nomcom_ingest.call_args, mock.call(b"This is a message", random_year))
11101149
self.assertFalse(any(m.called for m in (mocks - {mock_nomcom_ingest})))
11111150
mock_nomcom_ingest.reset_mock()
11121151

@@ -1118,7 +1157,9 @@ def test_ingest_email(
11181157
content_type="application/json",
11191158
headers={"X-Api-Key": "valid-token"},
11201159
)
1121-
self.assertEqual(r.status_code, 400)
1160+
self.assertEqual(r.status_code, 200)
1161+
self.assertEqual(r.headers["Content-Type"], "application/json")
1162+
self.assertEqual(json.loads(r.content), {"result": "bad_msg"})
11221163
self.assertTrue(mock_iana_ingest.called)
11231164
self.assertEqual(mock_iana_ingest.call_args, mock.call(b"This is a message"))
11241165
self.assertFalse(any(m.called for m in (mocks - {mock_iana_ingest})))
@@ -1138,7 +1179,9 @@ def test_ingest_email(
11381179
content_type="application/json",
11391180
headers={"X-Api-Key": "valid-token"},
11401181
)
1141-
self.assertEqual(r.status_code, 400)
1182+
self.assertEqual(r.status_code, 200)
1183+
self.assertEqual(r.headers["Content-Type"], "application/json")
1184+
self.assertEqual(json.loads(r.content), {"result": "bad_msg"})
11421185
self.assertTrue(mock_iana_ingest.called)
11431186
self.assertEqual(mock_iana_ingest.call_args, mock.call(b"This is a message"))
11441187
self.assertFalse(any(m.called for m in (mocks - {mock_iana_ingest})))
@@ -1167,7 +1210,9 @@ def test_ingest_email(
11671210
content_type="application/json",
11681211
headers={"X-Api-Key": "valid-token"},
11691212
)
1170-
self.assertEqual(r.status_code, 400)
1213+
self.assertEqual(r.status_code, 200)
1214+
self.assertEqual(r.headers["Content-Type"], "application/json")
1215+
self.assertEqual(json.loads(r.content), {"result": "bad_msg"})
11711216
self.assertTrue(mock_iana_ingest.called)
11721217
self.assertEqual(mock_iana_ingest.call_args, mock.call(b"This is a message"))
11731218
self.assertFalse(any(m.called for m in (mocks - {mock_iana_ingest})))
@@ -1192,7 +1237,9 @@ def test_ingest_email(
11921237
content_type="application/json",
11931238
headers={"X-Api-Key": "valid-token"},
11941239
)
1195-
self.assertEqual(r.status_code, 400)
1240+
self.assertEqual(r.status_code, 200)
1241+
self.assertEqual(r.headers["Content-Type"], "application/json")
1242+
self.assertEqual(json.loads(r.content), {"result": "bad_msg"})
11961243
self.assertTrue(mock_iana_ingest.called)
11971244
self.assertEqual(mock_iana_ingest.call_args, mock.call(b"This is a message"))
11981245
self.assertFalse(any(m.called for m in (mocks - {mock_iana_ingest})))

ietf/api/views.py

Lines changed: 33 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import pytz
99
import re
1010

11+
from contextlib import suppress
1112
from django.conf import settings
1213
from django.contrib.auth import authenticate
1314
from django.contrib.auth.decorators import login_required
@@ -533,32 +534,13 @@ def role_holder_addresses(request):
533534
"type": "object",
534535
"properties": {
535536
"dest": {
536-
"enum": [
537-
"iana-review",
538-
"ipr-response",
539-
"nomcom-feedback",
540-
]
537+
"type": "string",
541538
},
542539
"message": {
543540
"type": "string", # base64-encoded mail message
544541
},
545542
},
546543
"required": ["dest", "message"],
547-
"if": {
548-
# If dest == "nomcom-feedback"...
549-
"properties": {
550-
"dest": {"const": "nomcom-feedback"},
551-
}
552-
},
553-
"then": {
554-
# ... then also require year, an integer, be present
555-
"properties": {
556-
"year": {
557-
"type": "integer",
558-
},
559-
},
560-
"required": ["year"],
561-
},
562544
}
563545
)
564546

@@ -630,49 +612,63 @@ def as_emailmessage(self) -> Optional[EmailMessage]:
630612
@requires_api_token
631613
@csrf_exempt
632614
def ingest_email(request):
615+
"""Ingest incoming email
616+
617+
Returns a 4xx or 5xx status code if the HTTP request was invalid or something went
618+
wrong while processing it. If the request was valid, returns a 200. This may or may
619+
not indicate that the message was accepted.
620+
"""
633621

634-
def _err(code, text):
622+
def _http_err(code, text):
635623
return HttpResponse(text, status=code, content_type="text/plain")
636624

625+
def _api_response(result):
626+
return JsonResponse(data={"result": result})
627+
637628
if request.method != "POST":
638-
return _err(405, "Method not allowed")
629+
return _http_err(405, "Method not allowed")
639630

640631
if request.content_type != "application/json":
641-
return _err(415, "Content-Type must be application/json")
632+
return _http_err(415, "Content-Type must be application/json")
642633

643634
# Validate
644635
try:
645636
payload = json.loads(request.body)
646637
_response_email_json_validator.validate(payload)
647638
except json.decoder.JSONDecodeError as err:
648-
return _err(400, f"JSON parse error at line {err.lineno} col {err.colno}: {err.msg}")
639+
return _http_err(400, f"JSON parse error at line {err.lineno} col {err.colno}: {err.msg}")
649640
except jsonschema.exceptions.ValidationError as err:
650-
return _err(400, f"JSON schema error at {err.json_path}: {err.message}")
641+
return _http_err(400, f"JSON schema error at {err.json_path}: {err.message}")
651642
except Exception:
652-
return _err(400, "Invalid request format")
643+
return _http_err(400, "Invalid request format")
653644

654645
try:
655646
message = base64.b64decode(payload["message"], validate=True)
656647
except binascii.Error:
657-
return _err(400, "Invalid message: bad base64 encoding")
648+
return _http_err(400, "Invalid message: bad base64 encoding")
658649

659650
dest = payload["dest"]
651+
valid_dest = False
660652
try:
661653
if dest == "iana-review":
654+
valid_dest = True
662655
iana_ingest_review_email(message)
663656
elif dest == "ipr-response":
657+
valid_dest = True
664658
ipr_ingest_response_email(message)
665-
elif dest == "nomcom-feedback":
666-
year = payload["year"]
667-
nomcom_ingest_feedback_email(message, year)
668-
else:
669-
# Should never get here - json schema validation should enforce the enum
670-
log.unreachable(date="2024-04-04")
671-
return _err(400, "Invalid dest") # return something reasonable if we got here unexpectedly
659+
elif dest.startswith("nomcom-feedback-"):
660+
maybe_year = dest[len("nomcom-feedback-"):]
661+
if maybe_year.isdecimal():
662+
valid_dest = True
663+
nomcom_ingest_feedback_email(message, int(maybe_year))
672664
except EmailIngestionError as err:
673665
error_email = err.as_emailmessage()
674666
if error_email is not None:
675-
send_smtp(error_email)
676-
return _err(400, err.msg)
667+
with suppress(Exception): # send_smtp logs its own exceptions, ignore them here
668+
send_smtp(error_email)
669+
return _api_response("bad_msg")
670+
671+
if not valid_dest:
672+
return _api_response("bad_dest")
677673

678-
return HttpResponse(status=200)
674+
return _api_response("ok")

0 commit comments

Comments
 (0)