refactor: adjust mail ingestion api (#7523)
This commit is contained in:
parent
7541c21486
commit
35ab9bf4e4
|
@ -10,6 +10,7 @@ import sys
|
||||||
|
|
||||||
from importlib import import_module
|
from importlib import import_module
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from random import randrange
|
||||||
|
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
@ -1072,8 +1073,20 @@ class CustomApiTests(TestCase):
|
||||||
self.assertEqual(r.status_code, 400)
|
self.assertEqual(r.status_code, 400)
|
||||||
self.assertFalse(any(m.called for m in mocks))
|
self.assertFalse(any(m.called for m in mocks))
|
||||||
|
|
||||||
# test that valid requests call handlers appropriately
|
# bad destination
|
||||||
message_b64 = base64.b64encode(b"This is a message").decode()
|
message_b64 = base64.b64encode(b"This is a message").decode()
|
||||||
|
r = self.client.post(
|
||||||
|
url,
|
||||||
|
{"dest": "not-a-destination", "message": message_b64},
|
||||||
|
content_type="application/json",
|
||||||
|
headers={"X-Api-Key": "valid-token"},
|
||||||
|
)
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
self.assertEqual(r.headers["Content-Type"], "application/json")
|
||||||
|
self.assertEqual(json.loads(r.content), {"result": "bad_dest"})
|
||||||
|
self.assertFalse(any(m.called for m in mocks))
|
||||||
|
|
||||||
|
# test that valid requests call handlers appropriately
|
||||||
r = self.client.post(
|
r = self.client.post(
|
||||||
url,
|
url,
|
||||||
{"dest": "iana-review", "message": message_b64},
|
{"dest": "iana-review", "message": message_b64},
|
||||||
|
@ -1081,6 +1094,8 @@ class CustomApiTests(TestCase):
|
||||||
headers={"X-Api-Key": "valid-token"},
|
headers={"X-Api-Key": "valid-token"},
|
||||||
)
|
)
|
||||||
self.assertEqual(r.status_code, 200)
|
self.assertEqual(r.status_code, 200)
|
||||||
|
self.assertEqual(r.headers["Content-Type"], "application/json")
|
||||||
|
self.assertEqual(json.loads(r.content), {"result": "ok"})
|
||||||
self.assertTrue(mock_iana_ingest.called)
|
self.assertTrue(mock_iana_ingest.called)
|
||||||
self.assertEqual(mock_iana_ingest.call_args, mock.call(b"This is a message"))
|
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.assertFalse(any(m.called for m in (mocks - {mock_iana_ingest})))
|
||||||
|
@ -1093,20 +1108,44 @@ class CustomApiTests(TestCase):
|
||||||
headers={"X-Api-Key": "valid-token"},
|
headers={"X-Api-Key": "valid-token"},
|
||||||
)
|
)
|
||||||
self.assertEqual(r.status_code, 200)
|
self.assertEqual(r.status_code, 200)
|
||||||
|
self.assertEqual(r.headers["Content-Type"], "application/json")
|
||||||
|
self.assertEqual(json.loads(r.content), {"result": "ok"})
|
||||||
self.assertTrue(mock_ipr_ingest.called)
|
self.assertTrue(mock_ipr_ingest.called)
|
||||||
self.assertEqual(mock_ipr_ingest.call_args, mock.call(b"This is a message"))
|
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})))
|
self.assertFalse(any(m.called for m in (mocks - {mock_ipr_ingest})))
|
||||||
mock_ipr_ingest.reset_mock()
|
mock_ipr_ingest.reset_mock()
|
||||||
|
|
||||||
|
# bad nomcom-feedback dest
|
||||||
|
for bad_nomcom_dest in [
|
||||||
|
"nomcom-feedback", # no suffix
|
||||||
|
"nomcom-feedback-", # no year
|
||||||
|
"nomcom-feedback-squid", # not a year,
|
||||||
|
"nomcom-feedback-2024-2025", # also not a year
|
||||||
|
]:
|
||||||
|
r = self.client.post(
|
||||||
|
url,
|
||||||
|
{"dest": bad_nomcom_dest, "message": message_b64},
|
||||||
|
content_type="application/json",
|
||||||
|
headers={"X-Api-Key": "valid-token"},
|
||||||
|
)
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
self.assertEqual(r.headers["Content-Type"], "application/json")
|
||||||
|
self.assertEqual(json.loads(r.content), {"result": "bad_dest"})
|
||||||
|
self.assertFalse(any(m.called for m in mocks))
|
||||||
|
|
||||||
|
# good nomcom-feedback dest
|
||||||
|
random_year = randrange(100000)
|
||||||
r = self.client.post(
|
r = self.client.post(
|
||||||
url,
|
url,
|
||||||
{"dest": "nomcom-feedback", "message": message_b64, "year": 2024}, # arbitrary year
|
{"dest": f"nomcom-feedback-{random_year}", "message": message_b64},
|
||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
headers={"X-Api-Key": "valid-token"},
|
headers={"X-Api-Key": "valid-token"},
|
||||||
)
|
)
|
||||||
self.assertEqual(r.status_code, 200)
|
self.assertEqual(r.status_code, 200)
|
||||||
|
self.assertEqual(r.headers["Content-Type"], "application/json")
|
||||||
|
self.assertEqual(json.loads(r.content), {"result": "ok"})
|
||||||
self.assertTrue(mock_nomcom_ingest.called)
|
self.assertTrue(mock_nomcom_ingest.called)
|
||||||
self.assertEqual(mock_nomcom_ingest.call_args, mock.call(b"This is a message", 2024))
|
self.assertEqual(mock_nomcom_ingest.call_args, mock.call(b"This is a message", random_year))
|
||||||
self.assertFalse(any(m.called for m in (mocks - {mock_nomcom_ingest})))
|
self.assertFalse(any(m.called for m in (mocks - {mock_nomcom_ingest})))
|
||||||
mock_nomcom_ingest.reset_mock()
|
mock_nomcom_ingest.reset_mock()
|
||||||
|
|
||||||
|
@ -1118,7 +1157,9 @@ class CustomApiTests(TestCase):
|
||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
headers={"X-Api-Key": "valid-token"},
|
headers={"X-Api-Key": "valid-token"},
|
||||||
)
|
)
|
||||||
self.assertEqual(r.status_code, 400)
|
self.assertEqual(r.status_code, 200)
|
||||||
|
self.assertEqual(r.headers["Content-Type"], "application/json")
|
||||||
|
self.assertEqual(json.loads(r.content), {"result": "bad_msg"})
|
||||||
self.assertTrue(mock_iana_ingest.called)
|
self.assertTrue(mock_iana_ingest.called)
|
||||||
self.assertEqual(mock_iana_ingest.call_args, mock.call(b"This is a message"))
|
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.assertFalse(any(m.called for m in (mocks - {mock_iana_ingest})))
|
||||||
|
@ -1138,7 +1179,9 @@ class CustomApiTests(TestCase):
|
||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
headers={"X-Api-Key": "valid-token"},
|
headers={"X-Api-Key": "valid-token"},
|
||||||
)
|
)
|
||||||
self.assertEqual(r.status_code, 400)
|
self.assertEqual(r.status_code, 200)
|
||||||
|
self.assertEqual(r.headers["Content-Type"], "application/json")
|
||||||
|
self.assertEqual(json.loads(r.content), {"result": "bad_msg"})
|
||||||
self.assertTrue(mock_iana_ingest.called)
|
self.assertTrue(mock_iana_ingest.called)
|
||||||
self.assertEqual(mock_iana_ingest.call_args, mock.call(b"This is a message"))
|
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.assertFalse(any(m.called for m in (mocks - {mock_iana_ingest})))
|
||||||
|
@ -1167,7 +1210,9 @@ class CustomApiTests(TestCase):
|
||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
headers={"X-Api-Key": "valid-token"},
|
headers={"X-Api-Key": "valid-token"},
|
||||||
)
|
)
|
||||||
self.assertEqual(r.status_code, 400)
|
self.assertEqual(r.status_code, 200)
|
||||||
|
self.assertEqual(r.headers["Content-Type"], "application/json")
|
||||||
|
self.assertEqual(json.loads(r.content), {"result": "bad_msg"})
|
||||||
self.assertTrue(mock_iana_ingest.called)
|
self.assertTrue(mock_iana_ingest.called)
|
||||||
self.assertEqual(mock_iana_ingest.call_args, mock.call(b"This is a message"))
|
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.assertFalse(any(m.called for m in (mocks - {mock_iana_ingest})))
|
||||||
|
@ -1192,7 +1237,9 @@ class CustomApiTests(TestCase):
|
||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
headers={"X-Api-Key": "valid-token"},
|
headers={"X-Api-Key": "valid-token"},
|
||||||
)
|
)
|
||||||
self.assertEqual(r.status_code, 400)
|
self.assertEqual(r.status_code, 200)
|
||||||
|
self.assertEqual(r.headers["Content-Type"], "application/json")
|
||||||
|
self.assertEqual(json.loads(r.content), {"result": "bad_msg"})
|
||||||
self.assertTrue(mock_iana_ingest.called)
|
self.assertTrue(mock_iana_ingest.called)
|
||||||
self.assertEqual(mock_iana_ingest.call_args, mock.call(b"This is a message"))
|
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.assertFalse(any(m.called for m in (mocks - {mock_iana_ingest})))
|
||||||
|
|
|
@ -8,6 +8,7 @@ import jsonschema
|
||||||
import pytz
|
import pytz
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
from contextlib import suppress
|
||||||
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
|
||||||
|
@ -533,32 +534,13 @@ _response_email_json_validator = jsonschema.Draft202012Validator(
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"dest": {
|
"dest": {
|
||||||
"enum": [
|
"type": "string",
|
||||||
"iana-review",
|
|
||||||
"ipr-response",
|
|
||||||
"nomcom-feedback",
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"type": "string", # base64-encoded mail message
|
"type": "string", # base64-encoded mail message
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"required": ["dest", "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"],
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -630,49 +612,63 @@ class EmailIngestionError(Exception):
|
||||||
@requires_api_token
|
@requires_api_token
|
||||||
@csrf_exempt
|
@csrf_exempt
|
||||||
def ingest_email(request):
|
def ingest_email(request):
|
||||||
|
"""Ingest incoming email
|
||||||
|
|
||||||
|
Returns a 4xx or 5xx status code if the HTTP request was invalid or something went
|
||||||
|
wrong while processing it. If the request was valid, returns a 200. This may or may
|
||||||
|
not indicate that the message was accepted.
|
||||||
|
"""
|
||||||
|
|
||||||
def _err(code, text):
|
def _http_err(code, text):
|
||||||
return HttpResponse(text, status=code, content_type="text/plain")
|
return HttpResponse(text, status=code, content_type="text/plain")
|
||||||
|
|
||||||
|
def _api_response(result):
|
||||||
|
return JsonResponse(data={"result": result})
|
||||||
|
|
||||||
if request.method != "POST":
|
if request.method != "POST":
|
||||||
return _err(405, "Method not allowed")
|
return _http_err(405, "Method not allowed")
|
||||||
|
|
||||||
if request.content_type != "application/json":
|
if request.content_type != "application/json":
|
||||||
return _err(415, "Content-Type must be application/json")
|
return _http_err(415, "Content-Type must be application/json")
|
||||||
|
|
||||||
# Validate
|
# Validate
|
||||||
try:
|
try:
|
||||||
payload = json.loads(request.body)
|
payload = json.loads(request.body)
|
||||||
_response_email_json_validator.validate(payload)
|
_response_email_json_validator.validate(payload)
|
||||||
except json.decoder.JSONDecodeError as err:
|
except json.decoder.JSONDecodeError as err:
|
||||||
return _err(400, f"JSON parse error at line {err.lineno} col {err.colno}: {err.msg}")
|
return _http_err(400, f"JSON parse error at line {err.lineno} col {err.colno}: {err.msg}")
|
||||||
except jsonschema.exceptions.ValidationError as err:
|
except jsonschema.exceptions.ValidationError as err:
|
||||||
return _err(400, f"JSON schema error at {err.json_path}: {err.message}")
|
return _http_err(400, f"JSON schema error at {err.json_path}: {err.message}")
|
||||||
except Exception:
|
except Exception:
|
||||||
return _err(400, "Invalid request format")
|
return _http_err(400, "Invalid request format")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
message = base64.b64decode(payload["message"], validate=True)
|
message = base64.b64decode(payload["message"], validate=True)
|
||||||
except binascii.Error:
|
except binascii.Error:
|
||||||
return _err(400, "Invalid message: bad base64 encoding")
|
return _http_err(400, "Invalid message: bad base64 encoding")
|
||||||
|
|
||||||
dest = payload["dest"]
|
dest = payload["dest"]
|
||||||
|
valid_dest = False
|
||||||
try:
|
try:
|
||||||
if dest == "iana-review":
|
if dest == "iana-review":
|
||||||
|
valid_dest = True
|
||||||
iana_ingest_review_email(message)
|
iana_ingest_review_email(message)
|
||||||
elif dest == "ipr-response":
|
elif dest == "ipr-response":
|
||||||
|
valid_dest = True
|
||||||
ipr_ingest_response_email(message)
|
ipr_ingest_response_email(message)
|
||||||
elif dest == "nomcom-feedback":
|
elif dest.startswith("nomcom-feedback-"):
|
||||||
year = payload["year"]
|
maybe_year = dest[len("nomcom-feedback-"):]
|
||||||
nomcom_ingest_feedback_email(message, year)
|
if maybe_year.isdecimal():
|
||||||
else:
|
valid_dest = True
|
||||||
# Should never get here - json schema validation should enforce the enum
|
nomcom_ingest_feedback_email(message, int(maybe_year))
|
||||||
log.unreachable(date="2024-04-04")
|
|
||||||
return _err(400, "Invalid dest") # return something reasonable if we got here unexpectedly
|
|
||||||
except EmailIngestionError as err:
|
except EmailIngestionError as err:
|
||||||
error_email = err.as_emailmessage()
|
error_email = err.as_emailmessage()
|
||||||
if error_email is not None:
|
if error_email is not None:
|
||||||
send_smtp(error_email)
|
with suppress(Exception): # send_smtp logs its own exceptions, ignore them here
|
||||||
return _err(400, err.msg)
|
send_smtp(error_email)
|
||||||
|
return _api_response("bad_msg")
|
||||||
|
|
||||||
return HttpResponse(status=200)
|
if not valid_dest:
|
||||||
|
return _api_response("bad_dest")
|
||||||
|
|
||||||
|
return _api_response("ok")
|
||||||
|
|
Loading…
Reference in a new issue