Related to https://github.com/grafana/oncall-private/issues/2683 (when using mailgun backend, you can get multiple recipients, keep the first one matching the domain; other backends seem to just return the first one)
169 lines
7.4 KiB
Python
169 lines
7.4 KiB
Python
import logging
|
|
from typing import Optional, TypedDict
|
|
|
|
from anymail.exceptions import AnymailInvalidAddress, AnymailWebhookValidationFailure
|
|
from anymail.inbound import AnymailInboundMessage
|
|
from anymail.signals import AnymailInboundEvent
|
|
from anymail.webhooks import amazon_ses, mailgun, mailjet, mandrill, postal, postmark, sendgrid, sparkpost
|
|
from django.http import HttpResponse, HttpResponseNotAllowed
|
|
from django.utils import timezone
|
|
from rest_framework import status
|
|
from rest_framework.request import Request
|
|
from rest_framework.response import Response
|
|
from rest_framework.views import APIView
|
|
|
|
from apps.base.utils import live_settings
|
|
from apps.integrations.mixins import AlertChannelDefiningMixin
|
|
from apps.integrations.tasks import create_alert
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# {<ESP name>: (<django-anymail inbound webhook view class>, <webhook secret argument name to pass to the view>), ...}
|
|
INBOUND_EMAIL_ESP_OPTIONS = {
|
|
"amazon_ses": (amazon_ses.AmazonSESInboundWebhookView, None),
|
|
"mailgun": (mailgun.MailgunInboundWebhookView, "webhook_signing_key"),
|
|
"mailjet": (mailjet.MailjetInboundWebhookView, "webhook_secret"),
|
|
"mandrill": (mandrill.MandrillCombinedWebhookView, "webhook_key"),
|
|
"postal": (postal.PostalInboundWebhookView, "webhook_key"),
|
|
"postmark": (postmark.PostmarkInboundWebhookView, "webhook_secret"),
|
|
"sendgrid": (sendgrid.SendGridInboundWebhookView, "webhook_secret"),
|
|
"sparkpost": (sparkpost.SparkPostInboundWebhookView, "webhook_secret"),
|
|
}
|
|
|
|
|
|
class EmailAlertPayload(TypedDict):
|
|
subject: str
|
|
message: str
|
|
sender: str
|
|
|
|
|
|
class InboundEmailWebhookView(AlertChannelDefiningMixin, APIView):
|
|
def dispatch(self, request):
|
|
"""
|
|
Wrapper to parse integration_token from inbound email address and pass this token to
|
|
AlertChannelDefiningMixin
|
|
"""
|
|
|
|
# http_method_names can't be used due to how AlertChannelDefiningMixin is implemented
|
|
# todo: refactor AlertChannelDefiningMixin
|
|
if not request.method.lower() in ["head", "post"]:
|
|
return HttpResponseNotAllowed(permitted_methods=["head", "post"])
|
|
|
|
self.check_inbound_email_settings_set()
|
|
|
|
# Some ESPs verify the webhook with a HEAD request at configuration time
|
|
if request.method.lower() == "head":
|
|
return HttpResponse(status=status.HTTP_200_OK)
|
|
|
|
integration_token = self.get_integration_token_from_request(request)
|
|
if integration_token is None:
|
|
return HttpResponse(status=status.HTTP_400_BAD_REQUEST)
|
|
return super().dispatch(request, alert_channel_key=integration_token)
|
|
|
|
def post(self, request):
|
|
timestamp = timezone.now().isoformat()
|
|
for message in self.get_messages_from_esp_request(request):
|
|
payload = self.get_alert_payload_from_email_message(message)
|
|
create_alert.delay(
|
|
title=payload["subject"],
|
|
message=payload["message"],
|
|
alert_receive_channel_pk=request.alert_receive_channel.pk,
|
|
image_url=None,
|
|
link_to_upstream_details=None,
|
|
integration_unique_data=None,
|
|
raw_request_data=payload,
|
|
received_at=timestamp,
|
|
)
|
|
|
|
return Response("OK", status=status.HTTP_200_OK)
|
|
|
|
def get_integration_token_from_request(self, request) -> Optional[str]:
|
|
messages = self.get_messages_from_esp_request(request)
|
|
if not messages:
|
|
return None
|
|
message = messages[0]
|
|
# First try envelope_recipient field.
|
|
# According to AnymailInboundMessage it's provided not by all ESPs.
|
|
if message.envelope_recipient:
|
|
recipients = message.envelope_recipient.split(",")
|
|
for recipient in recipients:
|
|
# if there is more than one recipient, the first matching the expected domain will be used
|
|
try:
|
|
token, domain = recipient.strip().split("@")
|
|
except ValueError:
|
|
logger.error(
|
|
f"get_integration_token_from_request: envelope_recipient field has unexpected format: {message.envelope_recipient}"
|
|
)
|
|
continue
|
|
if domain == live_settings.INBOUND_EMAIL_DOMAIN:
|
|
return token
|
|
else:
|
|
logger.info("get_integration_token_from_request: message.envelope_recipient is not present")
|
|
"""
|
|
TODO: handle case when envelope_recipient is not provided.
|
|
Now we can't just compare to/cc domains one by one with INBOUND_EMAIL_DOMAIN
|
|
because this check will not work in case of OrganizationMovedException
|
|
"""
|
|
# for to in message.to:
|
|
# if to.domain == live_settings.INBOUND_EMAIL_DOMAIN:
|
|
# return to.address.split("@")[0]
|
|
# for cc in message.cc:
|
|
# if cc.domain == live_settings.INBOUND_EMAIL_DOMAIN:
|
|
# return cc.address.split("@")[0]
|
|
return None
|
|
|
|
def get_messages_from_esp_request(self, request: Request) -> list[AnymailInboundMessage]:
|
|
view_class, secret_name = INBOUND_EMAIL_ESP_OPTIONS[live_settings.INBOUND_EMAIL_ESP]
|
|
|
|
kwargs = {secret_name: live_settings.INBOUND_EMAIL_WEBHOOK_SECRET} if secret_name else {}
|
|
view = view_class(**kwargs)
|
|
|
|
try:
|
|
view.run_validators(request)
|
|
events = view.parse_events(request)
|
|
except AnymailWebhookValidationFailure as e:
|
|
logger.info(f"get_messages_from_esp_request: inbound email webhook validation failed: {e}")
|
|
return []
|
|
|
|
return [event.message for event in events if isinstance(event, AnymailInboundEvent)]
|
|
|
|
def check_inbound_email_settings_set(self):
|
|
"""
|
|
Guard method to checks if INBOUND_EMAIL settings present.
|
|
Returns InternalServerError if not.
|
|
"""
|
|
# TODO: These settings should be checked before app start.
|
|
if not live_settings.INBOUND_EMAIL_ESP:
|
|
logger.error("InboundEmailWebhookView: INBOUND_EMAIL_ESP env variable must be set.")
|
|
return HttpResponse(
|
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
)
|
|
|
|
if not live_settings.INBOUND_EMAIL_DOMAIN:
|
|
logger.error("InboundEmailWebhookView: INBOUND_EMAIL_DOMAIN env variable must be set.")
|
|
return HttpResponse(status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
|
|
|
def get_alert_payload_from_email_message(self, email: AnymailInboundMessage) -> EmailAlertPayload:
|
|
subject = email.subject or ""
|
|
subject = subject.strip()
|
|
message = email.text or ""
|
|
message = message.strip()
|
|
sender = self.get_sender_from_email_message(email)
|
|
|
|
return {"subject": subject, "message": message, "sender": sender}
|
|
|
|
def get_sender_from_email_message(self, email: AnymailInboundMessage) -> str:
|
|
try:
|
|
if isinstance(email.from_email, list):
|
|
sender = email.from_email[0].addr_spec
|
|
else:
|
|
sender = email.from_email.addr_spec
|
|
except AnymailInvalidAddress as e:
|
|
# wasn't able to parse email address from message, return raw value from "From" header
|
|
logger.warning(
|
|
f"get_sender_from_email_message: issue during parsing sender from email message, getting raw value "
|
|
f"instead. Exception: {e}"
|
|
)
|
|
sender = ", ".join(email.get_all("From"))
|
|
return sender
|