diff --git a/CHANGELOG.md b/CHANGELOG.md index a31b8203..bcc2cbf9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ([837](https://github.com/grafana/oncall/pull/837)) + ## v1.1.38 (2023-03-14) ### Added @@ -27,6 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Direct user paging improvements ([1358](https://github.com/grafana/oncall/issues/1358)) - Added Schedule Score quality within the schedule view ([118](https://github.com/grafana/oncall/issues/118)) + ## v1.1.36 (2023-03-09) ### Fixed diff --git a/docs/sources/calendar-schedules/_index.md b/docs/sources/calendar-schedules/_index.md index bde33f23..8b7a3cb7 100644 --- a/docs/sources/calendar-schedules/_index.md +++ b/docs/sources/calendar-schedules/_index.md @@ -61,4 +61,4 @@ via Terraform are automatically added to your schedules in Grafana OnCall. Simil read-only and cannot be edited from the UI. To learn more, read our [Get started with Grafana OnCall and Terraform]( -https://grafana.com/blog/2022/08/29/get-started-with-grafana-oncall-and-terraform/) blog post. +) blog post. diff --git a/docs/sources/integrations/available-integrations/configure-inbound-email/index.md b/docs/sources/integrations/available-integrations/configure-inbound-email/index.md new file mode 100644 index 00000000..7af163d4 --- /dev/null +++ b/docs/sources/integrations/available-integrations/configure-inbound-email/index.md @@ -0,0 +1,41 @@ +--- +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 the email message text equals "OK". + This behaviour can be modified via custom templates. + +Alerts from Inbound Email integration have followng payload: + +```json +{ + "subject": "", + "message": "", + "sender": "" +} +``` diff --git a/docs/sources/open-source/_index.md b/docs/sources/open-source/_index.md index bd389f64..f13c3213 100644 --- a/docs/sources/open-source/_index.md +++ b/docs/sources/open-source/_index.md @@ -224,6 +224,15 @@ the following env variables with your SMTP server credentials: After enabling the email integration, it will be possible to use the `Notify by email` notification step in user settings. +Grafana OnCall is also capable of creating alert groups from +[Inbound Email integration]({{< relref "../integrations/available-integrations/configure-inbound-email" >}}). + +To configure Inbound Email integration for Grafana OnCall OSS populate env variables with your Email Service Provider data: + +- `INBOUND_EMAIL_ESP` - Inbound email ESP name. Available options: amazon_ses, mailgun, mailjet, mandrill, postal, postmark, sendgrid, sparkpost +- `INBOUND_EMAIL_DOMAIN` - Inbound email domain +- `INBOUND_EMAIL_WEBHOOK_SECRET` - Inbound email webhook secret + ## Limits By default, Grafana OnCall limits email and phone notifications (calls, SMS) to 200 per user per day. diff --git a/engine/apps/alerts/models/alert_receive_channel.py b/engine/apps/alerts/models/alert_receive_channel.py index ac04b37c..633e7f70 100644 --- a/engine/apps/alerts/models/alert_receive_channel.py +++ b/engine/apps/alerts/models/alert_receive_channel.py @@ -20,6 +20,7 @@ from apps.alerts.integration_options_mixin import IntegrationOptionsMixin 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 @@ -420,8 +421,7 @@ class AlertReceiveChannel(IntegrationOptionsMixin, MaintainableObject): @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): diff --git a/engine/apps/base/models/live_setting.py b/engine/apps/base/models/live_setting.py index b5409ddc..10aad4b9 100644 --- a/engine/apps/base/models/live_setting.py +++ b/engine/apps/base/models/live_setting.py @@ -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", @@ -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 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: + token, domain = message.envelope_recipient.split("@") + if domain == live_settings.INBOUND_EMAIL_DOMAIN: + return token + else: + logger.info(f"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(f"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 = email.from_email.addr_spec + + return {"subject": subject, "message": message, "sender": sender} diff --git a/engine/apps/integrations/templates/html/integration_inbound_email.html b/engine/apps/integrations/templates/html/integration_inbound_email.html index a9016c19..efe359b6 100644 --- a/engine/apps/integrations/templates/html/integration_inbound_email.html +++ b/engine/apps/integrations/templates/html/integration_inbound_email.html @@ -1,15 +1,5 @@ -

This integration will consume emails from dedicated email address and make incidents from them.

- -

It’s useful for:

-
    -
  1. Service desk.
  2. -
  3. Consuming alerts from other systems using emails as a message bus.
  4. -
-

Dedicated email address for incidents:

+

This is the dedicated email address to create alert groups:

{{ alert_receive_channel.inbound_email }}
-

Fields:

-
    -
  • email_subject - alert title;
  • -
  • email_body - alert details;
  • -
+ +
Docs \ No newline at end of file diff --git a/engine/apps/integrations/urls.py b/engine/apps/integrations/urls.py index aba3bab2..79dd96cf 100644 --- a/engine/apps/integrations/urls.py +++ b/engine/apps/integrations/urls.py @@ -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, ) @@ -28,12 +31,16 @@ urlpatterns = [ path("grafana//", GrafanaAPIView.as_view(), name="grafana"), path("grafana_alerting//", GrafanaAlertingAPIView.as_view(), name="grafana_alerting"), path("alertmanager//", AlertManagerAPIView.as_view(), name="alertmanager"), - path("inbound_webhook_email/", InboundWebhookEmailView.as_view(), name="inbound_email"), path("amazon_sns//", AmazonSNS.as_view(), name="amazon_sns"), path("heartbeat//", HeartBeatAPIView.as_view(), name="heartbeat"), path("//", 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( diff --git a/engine/apps/integrations/views.py b/engine/apps/integrations/views.py index 2092eeca..94d85781 100644 --- a/engine/apps/integrations/views.py +++ b/engine/apps/integrations/views.py @@ -381,11 +381,6 @@ class HeartBeatAPIView(AlertChannelDefiningMixin, APIView): 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) diff --git a/engine/config_integrations/inbound_email.py b/engine/config_integrations/inbound_email.py index 7ddfa0e7..42ecba23 100644 --- a/engine/config_integrations/inbound_email.py +++ b/engine/config_integrations/inbound_email.py @@ -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 %}""" @@ -22,7 +23,7 @@ slack_message = "{{ payload.message }}" 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 @@ -38,10 +39,10 @@ telegram_message = slack_message 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", "").upper() }}' -resolve_condition = '{{ payload.get("state", "").upper() == "OK" }}' +resolve_condition = '{{ payload.get("message", "").upper() == "OK" }}' acknowledge_condition = None diff --git a/engine/config_integrations/zabbix.py b/engine/config_integrations/zabbix.py index 63b3e3b5..9cce4030 100644 --- a/engine/config_integrations/zabbix.py +++ b/engine/config_integrations/zabbix.py @@ -32,10 +32,6 @@ sms_title = web_title phone_call_title = sms_title -email_title = web_title - -email_message = web_message - telegram_title = sms_title telegram_message = slack_message diff --git a/engine/engine/middlewares.py b/engine/engine/middlewares.py index 5422670d..04af088a 100644 --- a/engine/engine/middlewares.py +++ b/engine/engine/middlewares.py @@ -40,7 +40,14 @@ class RequestTimeLoggingMiddleware(MiddlewareMixin): 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) diff --git a/engine/requirements.txt b/engine/requirements.txt index 5e7d6ed5..5a8a1837 100644 --- a/engine/requirements.txt +++ b/engine/requirements.txt @@ -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 diff --git a/engine/settings/base.py b/engine/settings/base.py index b4548ef3..dc611872 100644 --- a/engine/settings/base.py +++ b/engine/settings/base.py @@ -58,6 +58,7 @@ FEATURE_EMAIL_INTEGRATION_ENABLED = getenv_boolean("FEATURE_EMAIL_INTEGRATION_EN FEATURE_SLACK_INTEGRATION_ENABLED = getenv_boolean("FEATURE_SLACK_INTEGRATION_ENABLED", default=True) FEATURE_WEB_SCHEDULES_ENABLED = getenv_boolean("FEATURE_WEB_SCHEDULES_ENABLED", default=False) FEATURE_MULTIREGION_ENABLED = getenv_boolean("FEATURE_MULTIREGION_ENABLED", default=False) +FEATURE_INBOUND_EMAIL_ENABLED = getenv_boolean("FEATURE_INBOUND_EMAIL_ENABLED", default=False) GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED = getenv_boolean("GRAFANA_CLOUD_ONCALL_HEARTBEAT_ENABLED", default=True) GRAFANA_CLOUD_NOTIFICATIONS_ENABLED = getenv_boolean("GRAFANA_CLOUD_NOTIFICATIONS_ENABLED", default=True) @@ -610,6 +611,11 @@ EMAIL_NOTIFICATIONS_LIMIT = getenv_integer("EMAIL_NOTIFICATIONS_LIMIT", 200) if FEATURE_EMAIL_INTEGRATION_ENABLED: EXTRA_MESSAGING_BACKENDS += [("apps.email.backend.EmailBackend", 8)] +# Inbound email settings +INBOUND_EMAIL_ESP = os.getenv("INBOUND_EMAIL_ESP") +INBOUND_EMAIL_DOMAIN = os.getenv("INBOUND_EMAIL_DOMAIN") +INBOUND_EMAIL_WEBHOOK_SECRET = os.getenv("INBOUND_EMAIL_WEBHOOK_SECRET") + INSTALLED_ONCALL_INTEGRATIONS = [ "config_integrations.alertmanager", "config_integrations.grafana", diff --git a/grafana-plugin/src/containers/IntegrationSettings/IntegrationSettings.tsx b/grafana-plugin/src/containers/IntegrationSettings/IntegrationSettings.tsx index f279fd36..ace4c65d 100644 --- a/grafana-plugin/src/containers/IntegrationSettings/IntegrationSettings.tsx +++ b/grafana-plugin/src/containers/IntegrationSettings/IntegrationSettings.tsx @@ -136,22 +136,26 @@ const IntegrationSettings = observer((props: IntegrationSettingsProps) => { {activeTab === IntegrationSettingsTab.HowToConnect && (
-

This is the unique webhook URL for the integration:

-
- { - openNotification('Unique webhook URL copied'); - }} - > -
+ {alertReceiveChannel.integration_url && ( +
+

This is the unique webhook URL for the integration:

+
+ { + openNotification('Unique webhook URL copied'); + }} + > +
+
+ )}