# Copyright The IETF Trust 2017-2020, All Rights Reserved # -*- coding: utf-8 -*- import base64 import binascii import datetime import json from pathlib import Path from tempfile import NamedTemporaryFile import jsonschema import pytz import re from contextlib import suppress from django.conf import settings from django.contrib.auth import authenticate from django.contrib.auth.decorators import login_required from django.contrib.auth.models import User from django.core.exceptions import ValidationError from django.core.validators import validate_email from django.http import HttpResponse, Http404, JsonResponse from django.shortcuts import render, get_object_or_404 from django.urls import reverse from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt from django.views.decorators.gzip import gzip_page from django.views.generic.detail import DetailView from email.message import EmailMessage from importlib.metadata import version as metadata_version from jwcrypto.jwk import JWK from tastypie.exceptions import BadRequest from tastypie.serializers import Serializer from tastypie.utils import is_valid_jsonp_callback_value 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, Literal import ietf from ietf.api import _api_list from ietf.api.ietf_utils import is_valid_token, requires_api_token from ietf.api.serializer import JsonExportMixin from ietf.doc.utils import DraftAliasGenerator, fuzzy_find_documents from ietf.group.utils import GroupAliasGenerator, role_holder_emails from ietf.ietfauth.utils import role_required 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.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.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.decorators import require_api_key from ietf.utils.mail import send_smtp from ietf.utils.models import DumpInfo def top_level(request): available_resources = {} apitop = reverse('ietf.api.views.top_level') for name in sorted([ name for name, api in _api_list if len(api._registry) > 0 ]): available_resources[name] = { 'list_endpoint': '%s/%s/' % (apitop, name), } serializer = Serializer() desired_format = determine_format(request, serializer) options = {} if 'text/javascript' in desired_format: callback = request.GET.get('callback', 'callback') if not is_valid_jsonp_callback_value(callback): raise BadRequest('JSONP callback name is invalid.') options['callback'] = callback serialized = serializer.serialize(available_resources, desired_format, options) return HttpResponse(content=serialized, content_type=build_content_type(desired_format)) def api_help(request): key = JWK() # import just public part here, for display in info page key.import_from_pem(settings.API_PUBLIC_KEY_PEM) return render(request, "api/index.html", {'key': key, 'settings':settings, }) @method_decorator((login_required, gzip_page), name='dispatch') class PersonalInformationExportView(DetailView, JsonExportMixin): model = Person def get(self, request): person = get_object_or_404(self.model, user=request.user) expand = ['searchrule', 'documentauthor', 'ad_document_set', 'ad_dochistory_set', 'docevent', 'ballotpositiondocevent', 'deletedevent', 'email_set', 'groupevent', 'role', 'rolehistory', 'iprdisclosurebase', 'iprevent', 'liaisonstatementevent', 'allowlisted', 'schedule', 'constraint', 'schedulingevent', 'message', 'sendqueue', 'nominee', 'topicfeedbacklastseen', 'alias', 'email', 'apikeys', 'personevent', 'reviewersettings', 'reviewsecretarysettings', 'unavailableperiod', 'reviewwish', 'nextreviewerinteam', 'reviewrequest', 'meetingregistration', 'submissionevent', 'preapproval', 'user', 'communitylist', 'personextresource_set', ] return self.json_view(request, filter={'id':person.id}, expand=expand) @method_decorator((csrf_exempt, require_api_key, role_required('Robot')), name='dispatch') class ApiV2PersonExportView(DetailView, JsonExportMixin): model = Person def err(self, code, text): return HttpResponse(text, status=code, content_type='text/plain') def post(self, request): querydict = request.POST.copy() querydict.pop('apikey', None) expand = querydict.pop('_expand', []) if not querydict: return self.err(400, "No filters provided") return self.json_view(request, filter=querydict.dict(), expand=expand) # @require_api_key # @csrf_exempt # def person_access_token(request): # person = get_object_or_404(Person, user=request.user) # # if request.method == 'POST': # client_id = request.POST.get('client_id', None) # client_secret = request.POST.get('client_secret', None) # client = get_object_or_404(ClientRecord, client_id=client_id, client_secret=client_secret) # # return HttpResponse(json.dumps({ # 'name' : person.plain_name(), # 'email': person.email().address, # 'roles': { # 'chair': list(person.role_set.filter(name='chair', group__state__in=['active', 'bof', 'proposed']).values_list('group__acronym', flat=True)), # 'secr': list(person.role_set.filter(name='secr', group__state__in=['active', 'bof', 'proposed']).values_list('group__acronym', flat=True)), # } # }), content_type='application/json') # else: # return HttpResponse(status=405) @require_api_key @role_required('Robot') @csrf_exempt def api_new_meeting_registration(request): '''REST API to notify the datatracker about a new meeting registration''' def err(code, text): return HttpResponse(text, status=code, content_type='text/plain') required_fields = [ 'meeting', 'first_name', 'last_name', 'affiliation', 'country_code', 'email', 'reg_type', 'ticket_type', 'checkedin', 'is_nomcom_volunteer'] fields = required_fields + [] if request.method == 'POST': # parameters: # apikey: # meeting # name # email # reg_type (In Person, Remote, Hackathon Only) # ticket_type (full_week, one_day, student) # data = {'attended': False, } missing_fields = [] for item in fields: value = request.POST.get(item, None) if value is None and item in required_fields: missing_fields.append(item) data[item] = value if missing_fields: return err(400, "Missing parameters: %s" % ', '.join(missing_fields)) number = data['meeting'] try: meeting = Meeting.objects.get(number=number) except Meeting.DoesNotExist: return err(400, "Invalid meeting value: '%s'" % (number, )) reg_type = data['reg_type'] email = data['email'] try: validate_email(email) except ValidationError: return err(400, "Invalid email value: '%s'" % (email, )) if request.POST.get('cancelled', 'false') == 'true': MeetingRegistration.objects.filter( meeting_id=meeting.pk, email=email, reg_type=reg_type).delete() return HttpResponse('OK', status=200, content_type='text/plain') else: object, created = MeetingRegistration.objects.get_or_create( meeting_id=meeting.pk, email=email, reg_type=reg_type) try: # Update attributes for key in set(data.keys())-set(['attended', 'apikey', 'meeting', 'email']): if key == 'checkedin': new = bool(data.get(key).lower() == 'true') else: new = data.get(key) setattr(object, key, new) person = Person.objects.filter(email__address=email) if person.exists(): object.person = person.first() object.save() except ValueError as e: return err(400, "Unexpected POST data: %s" % e) response = "Accepted, New registration" if created else "Accepted, Updated registration" if User.objects.filter(username__iexact=email).exists() or Email.objects.filter(address=email).exists(): pass else: send_account_creation_email(request, email) response += ", Email sent" # handle nomcom volunteer if request.POST.get('is_nomcom_volunteer', 'false').lower() == 'true' and object.person: try: nomcom = NomCom.objects.get(is_accepting_volunteers=True) except (NomCom.DoesNotExist, NomCom.MultipleObjectsReturned): nomcom = None if nomcom: Volunteer.objects.get_or_create( nomcom=nomcom, person=object.person, defaults={ "affiliation": data["affiliation"], "origin": "registration" } ) return HttpResponse(response, status=202, content_type='text/plain') else: return HttpResponse(status=405) def version(request): dumpdate = None dumpinfo = DumpInfo.objects.order_by('-date').first() if dumpinfo: dumpdate = dumpinfo.date if dumpinfo.tz != "UTC": dumpdate = pytz.timezone(dumpinfo.tz).localize(dumpinfo.date.replace(tzinfo=None)) dumptime = dumpdate.strftime('%Y-%m-%d %H:%M:%S %z') if dumpinfo else None # important libraries __version_extra__ = {} for lib in settings.ADVERTISE_VERSIONS: __version_extra__[lib] = metadata_version(lib) return HttpResponse( json.dumps({ 'version': ietf.__version__+ietf.__patch__, 'other': __version_extra__, 'dumptime': dumptime, }), content_type='application/json', ) @require_api_key @csrf_exempt def app_auth(request, app: Literal["authortools", "bibxml"]): return HttpResponse( json.dumps({'success': True}), content_type='application/json') @requires_api_token @csrf_exempt def nfs_metrics(request): with NamedTemporaryFile(dir=settings.NFS_METRICS_TMP_DIR,delete=False) as fp: fp.close() mark = datetime.datetime.now() with open(fp.name, mode="w") as f: f.write("whyioughta"*1024) write_latency = (datetime.datetime.now() - mark).total_seconds() mark = datetime.datetime.now() with open(fp.name, "r") as f: _=f.read() read_latency = (datetime.datetime.now() - mark).total_seconds() Path(f.name).unlink() response=f'nfs_latency_seconds{{operation="write"}} {write_latency}\nnfs_latency_seconds{{operation="read"}} {read_latency}\n' return HttpResponse(response) def find_doc_for_rfcdiff(name, rev): """rfcdiff lookup heuristics Returns a tuple with: [0] - condition string [1] - document found (or None) [2] - historic version [3] - revision actually found (may differ from :rev: input) """ found = fuzzy_find_documents(name, rev) condition = 'no such document' if found.documents.count() != 1: return (condition, None, None, rev) doc = found.documents.get() if found.matched_rev is None or doc.rev == found.matched_rev: condition = 'current version' return (condition, doc, None, found.matched_rev) else: candidate = doc.history_set.filter(rev=found.matched_rev).order_by("-time").first() if candidate: condition = 'historic version' return (condition, doc, candidate, found.matched_rev) else: condition = 'version dochistory not found' return (condition, doc, None, found.matched_rev) # This is a proof of concept of a service that would redirect to the current revision # def rfcdiff_latest(request, name, rev=None): # condition, doc, history = find_doc_for_rfcdiff(name, rev) # if not doc: # raise Http404 # if history: # return redirect(history.get_href()) # else: # return redirect(doc.get_href()) HAS_TOMBSTONE = [ 2821, 2822, 2873, 2919, 2961, 3023, 3029, 3031, 3032, 3033, 3034, 3035, 3036, 3037, 3038, 3042, 3044, 3050, 3052, 3054, 3055, 3056, 3057, 3059, 3060, 3061, 3062, 3063, 3064, 3067, 3068, 3069, 3070, 3071, 3072, 3073, 3074, 3075, 3076, 3077, 3078, 3080, 3081, 3082, 3084, 3085, 3086, 3087, 3088, 3089, 3090, 3094, 3095, 3096, 3097, 3098, 3101, 3102, 3103, 3104, 3105, 3106, 3107, 3108, 3109, 3110, 3111, 3112, 3113, 3114, 3115, 3116, 3117, 3118, 3119, 3120, 3121, 3123, 3124, 3126, 3127, 3128, 3130, 3131, 3132, 3133, 3134, 3135, 3136, 3137, 3138, 3139, 3140, 3141, 3142, 3143, 3144, 3145, 3147, 3149, 3150, 3151, 3152, 3153, 3154, 3155, 3156, 3157, 3158, 3159, 3160, 3161, 3162, 3163, 3164, 3165, 3166, 3167, 3168, 3169, 3170, 3171, 3172, 3173, 3174, 3176, 3179, 3180, 3181, 3182, 3183, 3184, 3185, 3186, 3187, 3188, 3189, 3190, 3191, 3192, 3193, 3194, 3197, 3198, 3201, 3202, 3203, 3204, 3205, 3206, 3207, 3208, 3209, 3210, 3211, 3212, 3213, 3214, 3215, 3216, 3217, 3218, 3220, 3221, 3222, 3224, 3225, 3226, 3227, 3228, 3229, 3230, 3231, 3232, 3233, 3234, 3235, 3236, 3237, 3238, 3240, 3241, 3242, 3243, 3244, 3245, 3246, 3247, 3248, 3249, 3250, 3253, 3254, 3255, 3256, 3257, 3258, 3259, 3260, 3261, 3262, 3263, 3264, 3265, 3266, 3267, 3268, 3269, 3270, 3271, 3272, 3273, 3274, 3275, 3276, 3278, 3279, 3280, 3281, 3282, 3283, 3284, 3285, 3286, 3287, 3288, 3289, 3290, 3291, 3292, 3293, 3294, 3295, 3296, 3297, 3298, 3301, 3302, 3303, 3304, 3305, 3307, 3308, 3309, 3310, 3311, 3312, 3313, 3315, 3317, 3318, 3319, 3320, 3321, 3322, 3323, 3324, 3325, 3326, 3327, 3329, 3330, 3331, 3332, 3334, 3335, 3336, 3338, 3340, 3341, 3342, 3343, 3346, 3348, 3349, 3351, 3352, 3353, 3354, 3355, 3356, 3360, 3361, 3362, 3363, 3364, 3366, 3367, 3368, 3369, 3370, 3371, 3372, 3374, 3375, 3377, 3378, 3379, 3383, 3384, 3385, 3386, 3387, 3388, 3389, 3390, 3391, 3394, 3395, 3396, 3397, 3398, 3401, 3402, 3403, 3404, 3405, 3406, 3407, 3408, 3409, 3410, 3411, 3412, 3413, 3414, 3415, 3416, 3417, 3418, 3419, 3420, 3421, 3422, 3423, 3424, 3425, 3426, 3427, 3428, 3429, 3430, 3431, 3433, 3434, 3435, 3436, 3437, 3438, 3439, 3440, 3441, 3443, 3444, 3445, 3446, 3447, 3448, 3449, 3450, 3451, 3452, 3453, 3454, 3455, 3458, 3459, 3460, 3461, 3462, 3463, 3464, 3465, 3466, 3467, 3468, 3469, 3470, 3471, 3472, 3473, 3474, 3475, 3476, 3477, 3480, 3481, 3483, 3485, 3488, 3494, 3495, 3496, 3497, 3498, 3501, 3502, 3503, 3504, 3505, 3506, 3507, 3508, 3509, 3511, 3512, 3515, 3516, 3517, 3518, 3520, 3521, 3522, 3523, 3524, 3525, 3527, 3529, 3530, 3532, 3533, 3534, 3536, 3537, 3538, 3539, 3541, 3543, 3544, 3545, 3546, 3547, 3548, 3549, 3550, 3551, 3552, 3555, 3556, 3557, 3558, 3559, 3560, 3562, 3563, 3564, 3565, 3568, 3569, 3570, 3571, 3572, 3573, 3574, 3575, 3576, 3577, 3578, 3579, 3580, 3581, 3582, 3583, 3584, 3588, 3589, 3590, 3591, 3592, 3593, 3594, 3595, 3597, 3598, 3601, 3607, 3609, 3610, 3612, 3614, 3615, 3616, 3625, 3627, 3630, 3635, 3636, 3637, 3638 ] def get_previous_url(name, rev=None): '''Return previous url''' condition, document, history, found_rev = find_doc_for_rfcdiff(name, rev) previous_url = '' if condition in ('historic version', 'current version'): doc = history if history else document previous_url = doc.get_href() elif condition == 'version dochistory not found': document.rev = found_rev previous_url = document.get_href() return previous_url def rfcdiff_latest_json(request, name, rev=None): response = dict() condition, document, history, found_rev = find_doc_for_rfcdiff(name, rev) if document and document.type_id == "rfc": draft = document.came_from_draft() if condition == 'no such document': raise Http404 elif condition in ('historic version', 'current version'): doc = history if history else document if doc.type_id == "rfc": response['content_url'] = doc.get_href() response['name']=doc.name if draft: prev_rev = draft.rev if doc.rfc_number in HAS_TOMBSTONE and prev_rev != '00': prev_rev = f'{(int(draft.rev)-1):02d}' response['previous'] = f'{draft.name}-{prev_rev}' response['previous_url'] = get_previous_url(draft.name, prev_rev) elif doc.type_id == "draft" and not found_rev and doc.relateddocument_set.filter(relationship_id="became_rfc").exists(): rfc = doc.related_that_doc("became_rfc")[0] response['content_url'] = rfc.get_href() response['name']=rfc.name prev_rev = doc.rev if rfc.rfc_number in HAS_TOMBSTONE and prev_rev != '00': prev_rev = f'{(int(doc.rev)-1):02d}' response['previous'] = f'{doc.name}-{prev_rev}' response['previous_url'] = get_previous_url(doc.name, prev_rev) else: response['content_url'] = doc.get_href() response['rev'] = doc.rev response['name'] = doc.name if doc.rev == '00': replaces_docs = (history.doc if condition=='historic version' else doc).related_that_doc('replaces') if replaces_docs: replaces = replaces_docs[0] response['previous'] = f'{replaces.name}-{replaces.rev}' response['previous_url'] = get_previous_url(replaces.name, replaces.rev) else: match = re.search("-(rfc)?([0-9][0-9][0-9]+)bis(-.*)?$", name) if match and match.group(2): response['previous'] = f'rfc{match.group(2)}' response['previous_url'] = get_previous_url(f'rfc{match.group(2)}') else: # not sure what to do if non-numeric values come back, so at least log it log.assertion('doc.rev.isdigit()') prev_rev = f'{(int(doc.rev)-1):02d}' response['previous'] = f'{doc.name}-{prev_rev}' response['previous_url'] = get_previous_url(doc.name, prev_rev) elif condition == 'version dochistory not found': response['warning'] = 'History for this version not found - these results are speculation' response['name'] = document.name response['rev'] = found_rev document.rev = found_rev response['content_url'] = document.get_href() # not sure what to do if non-numeric values come back, so at least log it log.assertion('found_rev.isdigit()') if int(found_rev) > 0: prev_rev = f'{(int(found_rev)-1):02d}' response['previous'] = f'{document.name}-{prev_rev}' response['previous_url'] = get_previous_url(document.name, prev_rev) else: match = re.search("-(rfc)?([0-9][0-9][0-9]+)bis(-.*)?$", name) if match and match.group(2): response['previous'] = f'rfc{match.group(2)}' response['previous_url'] = get_previous_url(f'rfc{match.group(2)}') if not response: raise Http404 return HttpResponse(json.dumps(response), content_type='application/json') @csrf_exempt def directauth(request): if request.method == "POST": raw_data = request.POST.get("data", None) if raw_data: try: data = json.loads(raw_data) except json.decoder.JSONDecodeError: data = None if raw_data is None or data is None: log.log("Request body is either missing or invalid") return HttpResponse(json.dumps(dict(result="failure",reason="invalid post")), content_type='application/json') authtoken = data.get('authtoken', None) username = data.get('username', None) password = data.get('password', None) if any([item is None for item in (authtoken, username, password)]): log.log("One or more mandatory fields are missing: authtoken, username, password") return HttpResponse(json.dumps(dict(result="failure",reason="invalid post")), content_type='application/json') if not is_valid_token("ietf.api.views.directauth", authtoken): log.log("Auth token provided is invalid") return HttpResponse(json.dumps(dict(result="failure",reason="invalid authtoken")), content_type='application/json') user_query = User.objects.filter(username__iexact=username) # Matching email would be consistent with auth everywhere else in the app, but until we can map users well # in the imap server, people's annotations are associated with a very specific login. # If we get a second user of this API, add an "allow_any_email" argument. # Note well that we are using user.username, not what was passed to the API. user_count = user_query.count() if user_count == 1 and authenticate(username = user_query.first().username, password = password): user = user_query.get() if user_query.filter(person__isnull=True).count() == 1: # Can't inspect user.person direclty here log.log(f"Direct auth success (personless user): {user.pk}:{user.username}") else: log.log(f"Direct auth success: {user.pk}:{user.person.plain_name()}") return HttpResponse(json.dumps(dict(result="success")), content_type='application/json') log.log(f"Direct auth failure: {username} ({user_count} user(s) found)") return HttpResponse(json.dumps(dict(result="failure", reason="authentication failed")), content_type='application/json') else: log.log(f"Request must be POST: {request.method} received") return HttpResponse(status=405) @requires_api_token @csrf_exempt def draft_aliases(request): if request.method == "GET": return JsonResponse( { "aliases": [ { "alias": alias, "domains": ["ietf"], "addresses": address_list, } for alias, address_list in DraftAliasGenerator() ] } ) return HttpResponse(status=405) @requires_api_token @csrf_exempt def group_aliases(request): if request.method == "GET": return JsonResponse( { "aliases": [ { "alias": alias, "domains": domains, "addresses": address_list, } for alias, domains, address_list in GroupAliasGenerator() ] } ) return HttpResponse(status=405) @requires_api_token @csrf_exempt def active_email_list(request): if request.method == "GET": return JsonResponse( { "addresses": list(Email.objects.filter(active=True).values_list("address", flat=True)), } ) return HttpResponse(status=405) @requires_api_token def role_holder_addresses(request): if request.method == "GET": return JsonResponse( { "addresses": list( role_holder_emails() .order_by("address") .values_list("address", flat=True) ) } ) return HttpResponse(status=405) _response_email_json_validator = jsonschema.Draft202012Validator( schema={ "type": "object", "properties": { "dest": { "type": "string", }, "message": { "type": "string", # base64-encoded mail message }, }, "required": ["dest", "message"], } ) 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 def ingest_email_handler(request, test_mode=False): """Ingest incoming email - handler 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. If test_mode is true, actual processing of a valid message will be skipped. In this mode, a valid request with a valid destination will be treated as accepted. The "bad_dest" error may still be returned. """ def _http_err(code, text): return HttpResponse(text, status=code, content_type="text/plain") def _api_response(result): return JsonResponse(data={"result": result}) if request.method != "POST": return _http_err(405, "Method not allowed") if request.content_type != "application/json": return _http_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 _http_err(400, f"JSON parse error at line {err.lineno} col {err.colno}: {err.msg}") except jsonschema.exceptions.ValidationError as err: return _http_err(400, f"JSON schema error at {err.json_path}: {err.message}") except Exception: return _http_err(400, "Invalid request format") try: message = base64.b64decode(payload["message"], validate=True) except binascii.Error: return _http_err(400, "Invalid message: bad base64 encoding") dest = payload["dest"] valid_dest = False try: if dest == "iana-review": valid_dest = True if not test_mode: iana_ingest_review_email(message) elif dest == "ipr-response": valid_dest = True if not test_mode: ipr_ingest_response_email(message) elif dest.startswith("nomcom-feedback-"): maybe_year = dest[len("nomcom-feedback-"):] if maybe_year.isdecimal(): valid_dest = True if not test_mode: nomcom_ingest_feedback_email(message, int(maybe_year)) except EmailIngestionError as err: error_email = err.as_emailmessage() if error_email is not None: with suppress(Exception): # send_smtp logs its own exceptions, ignore them here send_smtp(error_email) return _api_response("bad_msg") if not valid_dest: return _api_response("bad_dest") return _api_response("ok") @requires_api_token @csrf_exempt def ingest_email(request): """Ingest incoming email Hands off to ingest_email_handler() with test_mode=False. This allows @requires_api_token to give the test endpoint a distinct token from the real one. """ return ingest_email_handler(request, test_mode=False) @requires_api_token @csrf_exempt def ingest_email_test(request): """Ingest incoming email test endpoint Hands off to ingest_email_handler() with test_mode=True. This allows @requires_api_token to give the test endpoint a distinct token from the real one. """ return ingest_email_handler(request, test_mode=True)