Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Inbound email integration #837

Merged
merged 25 commits into from
Mar 16, 2023
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
13c3d5b
Add inbound email functionality
vstpme Nov 11, 2022
008c093
Merge branch 'dev' into vadimkerr/inbound-email
vstpme Nov 11, 2022
7d62445
move inbound email view to email app
vstpme Nov 11, 2022
e78f382
use /integrations/v1/inbound_email_webhook
vstpme Nov 11, 2022
3245ca9
remove outdated email config from zabbix integration
vstpme Nov 11, 2022
73035a0
add test
vstpme Nov 14, 2022
8f1479f
Merge branch 'dev' into vadimkerr/inbound-email
vstpme Jan 31, 2023
b2dd277
Merge branch 'dev' into vadimkerr/inbound-email
vstpme Mar 7, 2023
d3465d8
Merge remote-tracking branch 'origin/dev' into vadimkerr/inbound-email
Konstantinov-Innokentii Mar 13, 2023
7b6e7a8
Polish inbound_email
Konstantinov-Innokentii Mar 13, 2023
18e2076
Use only one way of getting token from email address
Konstantinov-Innokentii Mar 13, 2023
b712b83
Inbound email docs
Konstantinov-Innokentii Mar 13, 2023
5a6aab2
Update CHANGELOG.md
Konstantinov-Innokentii Mar 13, 2023
b8fa1d4
Add OSS docs about Inbound Email
Konstantinov-Innokentii Mar 14, 2023
ef3f363
Merge branch 'dev' into vadimkerr/inbound-email
Konstantinov-Innokentii Mar 14, 2023
2d462b9
Polishing.
Konstantinov-Innokentii Mar 14, 2023
68293e6
Merge remote-tracking branch 'origin/vadimkerr/inbound-email' into va…
Konstantinov-Innokentii Mar 14, 2023
2b1d613
Handle several email addresses
Konstantinov-Innokentii Mar 14, 2023
f6a81ff
Handle bcc
Konstantinov-Innokentii Mar 14, 2023
fcaf7df
Remove test_inbound_email
Konstantinov-Innokentii Mar 14, 2023
d1dd54e
Remove check of to/bcc if envelope_recipient is not provided
Konstantinov-Innokentii Mar 16, 2023
002e0a6
fix
Konstantinov-Innokentii Mar 16, 2023
99ec063
Merge branch 'dev' into vadimkerr/inbound-email
Konstantinov-Innokentii Mar 16, 2023
c795810
lint docs
Konstantinov-Innokentii Mar 16, 2023
2b52b63
Merge remote-tracking branch 'origin/vadimkerr/inbound-email' into va…
Konstantinov-Innokentii Mar 16, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Unreleased

- Inbound email integration
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

May be refer to this PR? :)


## v1.1.35 (2023-03-09)

### Added
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
---
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you also please add OSS configuration instruction here? https://grafana.com/docs/oncall/latest/open-source/#email-setup

aliases:
- add-inbound-email/
- /docs/oncall/latest/integrations/available-integrations/configure-inbound-email/
canonical: https://grafana.com/docs/oncall/latest/integrations/available-integrations/configure-inbound-email/
keywords:
- Grafana Cloud
- Alerts
- Notifications
- on-call
- Email
title: Inbound Email integration for Grafana OnCall
weight: 500
---

# Inbound Email integration for Grafana OnCall

Inbound Email integration will consume emails from dedicated email address and make alert groups from them.

## Configure Inbound Email integration for Grafana OnCall

You must have an Admin role to create integrations in Grafana OnCall.

1. In the **Integrations** tab, click **+ New integration to receive alerts**.
2. Select **Inbound Email** from the list of available integrations.
3. Get your dedicated email address in the **How to connect** window.

## Grouping and auto-resolve

Alert groups will be grouped by email subject and auto-resolved if email message text equals "OK".
This behaviour can be modified via custom templates.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alert groups will be grouped by email subject and auto-resolved if the email message text equals "OK".


Alerts from Inbound Email integration have followng payload:

```json
{
"subject": "<your_email_subject>",
"message": "<your_email_message>",
"sender": "<your_email_sender_address>"
}
```
4 changes: 2 additions & 2 deletions engine/apps/alerts/models/alert_receive_channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from apps.alerts.models.maintainable_object import MaintainableObject
from apps.alerts.tasks import disable_maintenance, sync_grafana_alerting_contact_points
from apps.base.messaging import get_messaging_backend_from_id
from apps.base.utils import live_settings
from apps.integrations.metadata import heartbeat
from apps.integrations.tasks import create_alert, create_alertmanager_alerts
from apps.slack.constants import SLACK_RATE_LIMIT_DELAY, SLACK_RATE_LIMIT_TIMEOUT
Expand Down Expand Up @@ -420,8 +421,7 @@ def integration_url(self):

@property
def inbound_email(self):
# todo: implement inbound emails
pass
return f"{self.token}@{live_settings.INBOUND_EMAIL_DOMAIN}"

@property
def default_channel_filter(self):
Expand Down
10 changes: 10 additions & 0 deletions engine/apps/base/models/live_setting.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ class LiveSetting(models.Model):
"EMAIL_HOST_PASSWORD",
"EMAIL_USE_TLS",
"EMAIL_FROM_ADDRESS",
"INBOUND_EMAIL_ESP",
"INBOUND_EMAIL_DOMAIN",
"INBOUND_EMAIL_WEBHOOK_SECRET",
"TWILIO_ACCOUNT_SID",
"TWILIO_AUTH_TOKEN",
"TWILIO_API_KEY_SID",
Expand All @@ -65,6 +68,12 @@ class LiveSetting(models.Model):
"EMAIL_HOST_PASSWORD": "SMTP server password",
"EMAIL_USE_TLS": "SMTP enable/disable TLS",
"EMAIL_FROM_ADDRESS": "Email address used to send emails. If not specified, EMAIL_HOST_USER will be used.",
"INBOUND_EMAIL_DOMAIN": "Inbound email domain",
"INBOUND_EMAIL_ESP": (
"Inbound email ESP name. "
"Available options: amazon_ses, mailgun, mailjet, mandrill, postal, postmark, sendgrid, sparkpost"
),
"INBOUND_EMAIL_WEBHOOK_SECRET": "Inbound email webhook secret",
"SLACK_SIGNING_SECRET": (
"Check <a href='"
"https://grafana.com/docs/grafana-cloud/oncall/open-source/#slack-setup"
Expand Down Expand Up @@ -141,6 +150,7 @@ class LiveSetting(models.Model):

SECRET_SETTING_NAMES = (
"EMAIL_HOST_PASSWORD",
"INBOUND_EMAIL_WEBHOOK_SECRET",
"TWILIO_ACCOUNT_SID",
"TWILIO_AUTH_TOKEN",
"TWILIO_API_KEY_SID",
Expand Down
107 changes: 107 additions & 0 deletions engine/apps/email/inbound.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import logging

from anymail.exceptions import 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 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 InboundEmailWebhookView(AlertChannelDefiningMixin, APIView):
def dispatch(self, request):
# 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)

messages = get_messages_from_esp_request(request)
if not messages:
return HttpResponse(status=status.HTTP_400_BAD_REQUEST)

message = messages[0]
integration_token = message.to[0].address.split("@")[0]
return super().dispatch(request, alert_channel_key=integration_token)

def _check_inbound_email_settings_set(self):
"""
Checks if INBOUND_EMAIL settings set.
Returns BadRequest if not.
"""
# TODO: These settings should be checked before app start.
if not live_settings.INBOUND_EMAIL_ESP:
logger.error(f"InboundEmailWebhookView: INBOUND_EMAIL_ESP env variable must be set.")
return HttpResponse(
status=status.HTTP_400_BAD_REQUEST,
)

if not live_settings.INBOUND_EMAIL_DOMAIN:
logger.error("InboundEmailWebhookView: INBOUND_EMAIL_DOMAIN env variable must be set.")
return HttpResponse(status=status.HTTP_400_BAD_REQUEST)

def post(self, request, alert_receive_channel):
for email in get_messages_from_esp_request(request):
subject = email.subject
message = email.text.strip()
sender = email.from_email.addr_spec

payload = {"subject": subject, "message": message, "sender": sender}

create_alert.delay(
title=subject,
message=message,
alert_receive_channel_pk=alert_receive_channel.pk,
image_url=None,
link_to_upstream_details=None,
integration_unique_data=request.data,
raw_request_data=payload,
)

return Response("OK", status=status.HTTP_200_OK)


def get_messages_from_esp_request(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:
return []

return [event.message for event in events if isinstance(event, AnymailInboundEvent)]


def get_integration_token_from_email_message(message: AnymailInboundMessage):
return message.to[0].address.split("@")[0]
26 changes: 26 additions & 0 deletions engine/apps/email/tests/test_inbound_email.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import pytest
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APIClient


@pytest.mark.django_db
def test_inbound_email_webhook_head(
settings,
make_organization,
make_user_for_organization,
make_token_for_organization,
make_alert_receive_channel,
make_alert_group,
make_alert,
make_user_notification_policy,
):
settings.FEATURE_INBOUND_EMAIL_ENABLED = True
settings.INBOUND_EMAIL_ESP = "mailgun"
settings.INBOUND_EMAIL_DOMAIN = "test.test"
client = APIClient()

url = reverse("integrations:inbound_email_webhook")
response = client.head(url)

assert response.status_code == status.HTTP_200_OK
Original file line number Diff line number Diff line change
@@ -1,15 +1,5 @@
<p>This integration will consume emails from dedicated email address and make incidents from them.</p>

<p>It’s useful for:</p>
<ol>
<li>Service desk.</li>
<li>Consuming alerts from other systems using emails as a message bus.</li>
</ol>
<p>Dedicated email address for incidents:</p>
<h4>This is the dedicated email address to create alert groups:</h4>
<pre><code class='code-multiline'>{{ alert_receive_channel.inbound_email }}</code></pre>

<p><i>Fields:</i></p>
<ul>
<li><i>email_subject</i> - alert title;</li>
<li><i>email_body</i> - alert details;</li>
</ul>

<a href="https://grafana.com/docs/oncall/latest/integrations/available-integrations/configure-inbound-email/">Docs</a>
11 changes: 9 additions & 2 deletions engine/apps/integrations/urls.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
from pathlib import Path

from django.conf import settings
from django.urls import path

from apps.email.inbound import InboundEmailWebhookView
from common.api_helpers.optional_slash_router import optional_slash_path

from .views import (
AlertManagerAPIView,
AmazonSNS,
GrafanaAlertingAPIView,
GrafanaAPIView,
HeartBeatAPIView,
InboundWebhookEmailView,
IntegrationHeartBeatAPIView,
UniversalAPIView,
)
Expand All @@ -28,12 +31,16 @@
path("grafana/<str:alert_channel_key>/", GrafanaAPIView.as_view(), name="grafana"),
path("grafana_alerting/<str:alert_channel_key>/", GrafanaAlertingAPIView.as_view(), name="grafana_alerting"),
path("alertmanager/<str:alert_channel_key>/", AlertManagerAPIView.as_view(), name="alertmanager"),
path("inbound_webhook_email/", InboundWebhookEmailView.as_view(), name="inbound_email"),
path("amazon_sns/<str:alert_channel_key>/", AmazonSNS.as_view(), name="amazon_sns"),
path("heartbeat/<str:alert_channel_key>/", HeartBeatAPIView.as_view(), name="heartbeat"),
path("<str:integration_type>/<str:alert_channel_key>/", UniversalAPIView.as_view(), name="universal"),
]

if settings.FEATURE_INBOUND_EMAIL_ENABLED:
urlpatterns += [
optional_slash_path("inbound_email_webhook", InboundEmailWebhookView.as_view(), name="inbound_email_webhook"),
]


def create_heartbeat_path(integration_url):
return path(
Expand Down
5 changes: 0 additions & 5 deletions engine/apps/integrations/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -381,11 +381,6 @@ def post(self, request, alert_receive_channel):
return Response("Ok.")


class InboundWebhookEmailView(AlertChannelDefiningMixin, APIView):
# todo: implement inbound emails
pass


class IntegrationHeartBeatAPIView(AlertChannelDefiningMixin, IntegrationHeartBeatRateLimitMixin, APIView):
def get(self, request, alert_receive_channel):
self._process_heartbeat_signal(request, alert_receive_channel)
Expand Down
19 changes: 10 additions & 9 deletions engine/config_integrations/inbound_email.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
from django.conf import settings

# Main
enabled = True
title = "Inboubd Email"
title = "Inbound Email"
slug = "inbound_email"
short_description = None
description = None
is_displayed_on_web = False
is_displayed_on_web = settings.FEATURE_INBOUND_EMAIL_ENABLED
is_featured = False
is_able_to_autoresolve = False
is_able_to_autoresolve = True
is_demo_alert_enabled = False

description = None

# Default templates
slack_title = """\
*<{{ grafana_oncall_link }}|#{{ grafana_oncall_incident_id }} {{ payload.get("title", "Title undefined (check Slack Title Template)") }}>* via {{ integration_name }}
*<{{ grafana_oncall_link }}|#{{ grafana_oncall_incident_id }} {{ payload.get("subject", "Title undefined (check Slack Title Template)") }}>* via {{ integration_name }}
{% if source_link %}
(*<{{ source_link }}|source>*)
{%- endif %}"""
Expand All @@ -22,7 +23,7 @@

slack_image_url = "{{ payload.image_url }}"

web_title = '{{ payload.get("title", "Title undefined (check Web Title Template)") }}'
web_title = '{{ payload.get("subject", "Title undefined (check Web Title Template)") }}'

web_message = slack_message

Expand All @@ -38,10 +39,10 @@

telegram_image_url = slack_image_url

source_link = "{{ payload.link_to_upstream_details }}"
source_link = None

grouping_id = '{{ payload.get("title", "")}}'
grouping_id = '{{ payload.get("subject", "")}}'

resolve_condition = '{{ payload.get("state", "").upper() == "OK" }}'
resolve_condition = '{{ payload.get("message", "").upper() == "OK" }}'

acknowledge_condition = None
4 changes: 0 additions & 4 deletions engine/config_integrations/zabbix.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,6 @@

phone_call_title = sms_title

email_title = web_title

email_message = web_message

telegram_title = sms_title

telegram_message = slack_message
Expand Down
9 changes: 8 additions & 1 deletion engine/engine/middlewares.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,14 @@ def log_message(request, response, tag, message=""):
if request.path.startswith("/integrations/v1"):
split_path = request.path.split("/")
integration_type = split_path[3]
integration_token = split_path[4]

# integration token is not always present in the URL,
# e.g. for inbound emails integration token is passed in the request payload
if len(split_path) >= 5:
integration_token = split_path[4]
else:
integration_token = None

message += f"integration_type={integration_type} integration_token={integration_token} "
logging.info(message)

Expand Down
1 change: 1 addition & 0 deletions engine/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,4 @@ opentelemetry-exporter-otlp-proto-grpc==1.15.0
pyroscope-io==0.8.1
django-dbconn-retry==0.1.7
django-ipware==4.0.2
django-anymail==8.6
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

License ok 👍

Loading