From a43bc0ec94bd44688f48e7c0f50b10317872a667 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Tue, 14 Mar 2023 18:25:41 +0000 Subject: [PATCH 01/16] Release oncall Helm chart 1.1.38 --- helm/oncall/Chart.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/helm/oncall/Chart.yaml b/helm/oncall/Chart.yaml index 5431284c..8232c5bd 100644 --- a/helm/oncall/Chart.yaml +++ b/helm/oncall/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: oncall description: Developer-friendly incident response with brilliant Slack integration type: application -version: 1.1.37 -appVersion: v1.1.37 +version: 1.1.38 +appVersion: v1.1.38 dependencies: - name: cert-manager version: v1.8.0 From b58c6f63b5b211dcb6fdb740c7d9220a14b7cb7e Mon Sep 17 00:00:00 2001 From: Innokentii Konstantinov Date: Thu, 16 Mar 2023 15:19:30 +0800 Subject: [PATCH 02/16] 1.1.39 (#1558) Co-authored-by: Joey Orlando Co-authored-by: Vadim Stepanov --- CHANGELOG.md | 6 + docs/sources/calendar-schedules/_index.md | 2 +- .../configure-inbound-email/index.md | 41 +++++ docs/sources/open-source/_index.md | 9 ++ .../alerts/models/alert_receive_channel.py | 4 +- engine/apps/base/models/live_setting.py | 10 ++ engine/apps/email/inbound.py | 142 ++++++++++++++++++ .../html/integration_inbound_email.html | 16 +- engine/apps/integrations/urls.py | 11 +- engine/apps/integrations/views.py | 5 - engine/config_integrations/inbound_email.py | 19 +-- engine/config_integrations/zabbix.py | 4 - engine/engine/middlewares.py | 9 +- engine/requirements.txt | 1 + engine/settings/base.py | 6 + grafana-plugin/.env.example | 1 + .../utils/configurePlugin.ts | 7 +- .../integration-tests/utils/constants.ts | 1 + .../src/assets/img/inbound-email.png | Bin 0 -> 7381 bytes .../IntegrationLogo.module.css | 5 + .../IntegrationSettings.tsx | 36 +++-- 21 files changed, 281 insertions(+), 54 deletions(-) create mode 100644 docs/sources/integrations/available-integrations/configure-inbound-email/index.md create mode 100644 engine/apps/email/inbound.py create mode 100644 grafana-plugin/src/assets/img/inbound-email.png diff --git a/CHANGELOG.md b/CHANGELOG.md index a31b8203..dfb8758c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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). +## v1.1.39 (2023-03-16) + +### Added + +- Inbound email integration ([837](https://github.com/grafana/oncall/pull/837)) + ## v1.1.38 (2023-03-14) ### Added 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/.env.example b/grafana-plugin/.env.example index 07915852..89211452 100644 --- a/grafana-plugin/.env.example +++ b/grafana-plugin/.env.example @@ -5,3 +5,4 @@ BASE_URL=http://localhost:3000 ONCALL_API_URL=http://host.docker.internal:8080/ GRAFANA_USERNAME=oncall GRAFANA_PASSWORD=oncall +IS_OPEN_SOURCE=True diff --git a/grafana-plugin/integration-tests/utils/configurePlugin.ts b/grafana-plugin/integration-tests/utils/configurePlugin.ts index 5f2869c5..d838cf15 100644 --- a/grafana-plugin/integration-tests/utils/configurePlugin.ts +++ b/grafana-plugin/integration-tests/utils/configurePlugin.ts @@ -1,5 +1,5 @@ import type { Page } from '@playwright/test'; -import { ONCALL_API_URL } from './constants'; +import { ONCALL_API_URL, IS_OPEN_SOURCE } from './constants'; import { clickButton, getInputByName } from './forms'; import { goToGrafanaPage } from './navigation'; @@ -7,6 +7,11 @@ import { goToGrafanaPage } from './navigation'; * go to config page and wait for plugin icon to be available on left-hand navigation */ export const configureOnCallPlugin = async (page: Page): Promise => { + // plugin configuration can safely be skipped for non open-source environments + if (!IS_OPEN_SOURCE) { + return; + } + /** * go to the oncall plugin configuration page and wait for the page to be loaded */ diff --git a/grafana-plugin/integration-tests/utils/constants.ts b/grafana-plugin/integration-tests/utils/constants.ts index aa62ee5a..7e9a5998 100644 --- a/grafana-plugin/integration-tests/utils/constants.ts +++ b/grafana-plugin/integration-tests/utils/constants.ts @@ -3,3 +3,4 @@ export const ONCALL_API_URL = process.env.ONCALL_API_URL || 'http://host.docker. export const GRAFANA_USERNAME = process.env.GRAFANA_USERNAME || 'oncall'; export const GRAFANA_PASSWORD = process.env.GRAFANA_PASSWORD || 'oncall'; export const MAILSLURP_API_KEY = process.env.MAILSLURP_API_KEY; +export const IS_OPEN_SOURCE = (process.env.IS_OPEN_SOURCE || 'true').toLowerCase() === 'true'; diff --git a/grafana-plugin/src/assets/img/inbound-email.png b/grafana-plugin/src/assets/img/inbound-email.png new file mode 100644 index 0000000000000000000000000000000000000000..fbd81ee419a930c5f8ba5830691f3ac20f7fad94 GIT binary patch literal 7381 zcmZ`ecQ71Y(8v))Jw%Uoy69b^oO;R;98sbM(M5|O>gk=}M2TKb4T%=Li{87_38J@C zKfn3r`}5m3`}Xa=*_~}OZ)RVF7F3xKp9UWT1A`EvqM-dqbN)kIoX5TL4XXB$;JK(6 zxM5)MsQrhS1+P;|9)*~0+R9*zieb9Hj|{*@PD2g@<44@HTXSp-3_3fAf}E}w=D&B2 z(Z>3p{ZPLdF6N;Q@+BGp@1rxdVO%C;Wrgwa9{HdxN~$Y^Zypci`Ctb;5F@J;twJeM z*Fe7{l54W;umAonlAh}on$ZJdyMMnMr=_V?0On5cfK)FprQKY-zRY-B%S&E1o;FQx zhZ*e0zKe%*H-o2sSwa}3|34TJG|m0FDSGh@M@71u?^0HwYio=yuwWua$x@E5>-?h8 zdDn|R(3+9i!tDcar9YL1lBSztFLBh&Zr7`v&^^L*nvpqIjifE@&08cTh1Mu39`+jw zmc}R4M|Hn>`_0-V2@@U^(6H@LzJzI9K-LXeqZP zmn=I$!r@4&oc8yP)dXI-War%bb)S8GcN26Vg^^S`67u+DzD<+$UihK`S@EN7C2K-k ztO^8V%L3=)oZd?RnOkl5E=h%sxdCb3!qmU7b^6mVBf~XHzbh$q#A&=1PV49c*AZ=c z*Zc2Y3D4p-;*i4HKl3(cE|y)}MkB%u|E(O8wz++pB6@k(gsvGFWw~ua&nFX4ANZvN zTnM7rnR2`OwE0Z8SJvHY=9(~Hw@pq?W=YvIT%W4rz02zma(ZW3&3rD^+aZmC( zAkVPL0@L6+|(t)b95ThBjY=Myb6ZV=8vs2wm-=GW^DY134HeL{}B zJuy@*9(xaa^&Iw*V{BvPq-!9C!6fiSJD`knR6=h$!#<^~#`Gm}?WjH9MEpv8Tn(%G z`gKpf-!B!};PbCqEJ%y^|Mo9cz38jwy~zma}>7 zztom*GG8@xRFY?*D5hFW3j2YT%|o9`_m>q@V>blv!(`>U-JDYLe>VS)ei=b|^Y5m1 z)Kar~&SVPH!oV@o$hC3O^-@`d5i{@m=PXG$Ur#Q5o%_(wWt+@7ob;}NW>_6bhqfel$8;ESMQ8}FE4TTS2;&evsgc_06>X)SHQu<#;*0#*O z?_uZ?O7d*P;b&cDV-)B|b zj1`iGKHP=$xV?5XDPGZKH5xyi(R`ox?*K`kK^Gv<;WH?*2$RzNo#%Nu^MH8Q+t@SFC>S&jUPfnF;apYZ)(nQUeL11)^_thl>{Dn%*~>tr|*A&>ID&ss~J zsOtVtuv5zAm+iSDnRII+-Rnu^GP|16&u;2uyg%pVlH|=qg;u5_8K{~H?DyVL132bF zDr6W|QwgcwBD&n1!;EOoxryrN00hX#lIc*45an_;X0=*AEY^ErdIKh|F!`nIGd2!v z_54e549{)q3p8HIT9!5SzR<`b!Y;d`g)c70%(`jYFjKGngS>r7-9(scS%toX@* zlSJqbi)w>GAFDA=}S; z1YMQhDge3YmY0J567zIy9Ec|M^L$ho$Ql%~0-OzCE9~O^D|EVyM#NTyvEdRq8HJAA z_6Xi4CZF82(uI{e$8_-NhZ-@3Wx6Jf)5`?u0|6-7DKM)E?>5vWkxlufOUt-)ZrsZ5&JA9 z9{4*iZgT*-vwbCnOn*Cl^j30~xR2yTCr}TlRhD(FM3+S8>%31(W;F}=k;#UvMj
    |BUh+M}ZY!t3rZMdek62~4qjJUm6lIOwWR8kQK$@{Rmsy81 zz(+TX@l%IA0L7tKL(of_CAX~T4bM7&SlH}{^>x%u%Btn>`J>E-Wl?gZxG>YaU#xEH z<=EcQ+@%rLmM$?p1RwKhd7|)!3{@g3tnF@Fz1|9FenE_7Pk(be7IjhrwG?9)Z_2|) ztup>yT`;)g?IW6l;DHPMz#&`6rVXNXkZa{IOvQC{LUgCR+h!* zVfPtd;S05QdA6Zj9pw|FKAnWqO@=2@YCdiG6uq&Xp17fElEP{S|`(PxPU)gIcdnS{bMv- zc&lNJtYbwy%47q(8{gl_armWu0lN!SxpY!*;y8P?#CR>;8guop^cHfztz&1# zFEpVG21%7H^-O+uOI28owy9)jX&x#`r??7>+yHVbg5}$UE^kfsA_tZ%3e@Cc_y;t% zad)UAIi$Z$`gO9sci0C@b(4|#do;YMhoif7E~k}AMvkFj{ZgMmtKz%%RogCq_O3W7 z4}_#DgnCVXxpCf`9SR!1Q(r+*DrfH(f0d9%w=2%d$YGPw;;LXT?3HJ})wj=UmZ-f6 zuS|`|(J4Ngr2!1`3w&xoKY#hc(y5tkYpHeT0`7FGCh5R}{;|L5aYR#PvA z5FPrEmW4x#OTJ_M6OYwH*ZaBOe5xPnJ_^WsQrafZhZ6xe@(ciwLU9>azgs@jhQFHN z{mkFhZS5nFBPL$R2k;sjqY9X|8$$RQ?0Vc783h)nZ*=u!%~1B$MBjC)#*ZRalb$+& z_MHo>KbP8fkY@-Gs1Z;C1B?#|C=4yGrVMs4%EAeyFZR}+&8x{(hE3CZG)sm?+X}2H zjVAJ7!mi>krotp1(4&r<6-eD6I6HAQ^>PAIp%OEcK!sNthcci33w{~%mA>FBx0 zPt+PM^n0K*w19oJUq0$0WVml5oYGw(ADSC@Yj^kx`IG<&!N8>ruu45lIz}s$ER~cJ zq_K>rW=uFFCgxQSTK5-ys#AN^XIs6G5cN%jX|eKeg!uCruDkbCeAK|nwr}1c`;$|R zsjc;dmW0u>zf-sF(q@}odnQr>0*}v!c06s*VBb02FvKJDw7_RqMLwF!ImT|@HP?z~ zbOL@2A`aaG_1Rk&qK^Az}j7(^W{uIRGf0lmAuJ*M7z6X05RMC#k8}GYbPX*wzAe}#oaQjrkaL#2~ zY8U8HVLSdcb@T339T`N=;8v`c>C48j9H?*#BD%#)y1fD64i;WWv?;$dZ}+Ju_kH&% zbP;yPyG=M?ou~!@TSmq;*<-s?KT8f(@ls1P$^qw-Ec!7`R7~y0~vOQ3W zB;K6fo}MnmfRncjIV_g0>vyu?i_a$ZvzJyv{ju=bj|cW7E|-pus2qn(iSKy*(@`l6 zGaQl3fk?y$NaI%QmI4h9(gUIL174b8v>Mkr<={_4;&D30rQr}S`9UV+q`LSO z^20BddS`rWWwK66r5b`JK9z<-qr^}~O{gn_=$kC$^7e`s-q5jlnK0V&s}dpc(sIyL z;h%P$-fxSHX3R0rrUJyO=Mp1i$GVM{E>2 z+tFiZ*vnsY5L`KzTLA!l1=j9S0mP~q>%5mb+%G_9IHP?vvxRKz0Cuzz^yJuX`SMhP z&b;^wsvKyYgLn8nk~aHRa@>z;&BM<(xvu`3p@{V0UXiQ_ndGplAiHst8va$^pDzd1 z983?6!(Kp98p>~%n)EV5-`A+A%$h!h(KmAXJ8UZk4yMT3+of;-e$(MiDqG*5tAC8z z?DapK#BC#M+FS-}q(<7t53gE!exi79>9L7@QjPv+X;~B%LmFjTU)N*$mEl3;C;OBjd|wQKt2=|^X&AN6dG_HQ6|@UA2Deo79RBPeCf1UNI=3lVm_c( z$=XDsX8L1q>m8pP1l-$4@@w=Ry>O9KK4Uw9?duv_sy%{X=7OF>%Y|w7{^!XXr3WVCYuZ z(Iox5{$`9FKjYP8W>j1nd*VJpgF*~-uHM-k$=y2(@Zcv`U-_<0XS&xtVNG4$$>?5J z@_j=>STZyr#NaLJ{!FcA6f>Hc_x|_`AyEi}i!FNbQeE0YJmJeP?t#a~;b-KRk!_$) zd9F@kY+P6I!L%0c&=IimE^bk%e2S4J?0Cz7$itB#qZKMjO^Pe6(e zHCo&e1mx-SWk8FreZ)W#){kX2kxg+w|b+9 zljMJ+go*BnjH9<4&*vK7LviF4I?kw2^Vox|Dve=kW3rJ~?`HsTW3=Y|DFdhbU)7;B z73EbcS+t5uW0{gPYp^KH-17N|^g>6s$M2;oOe*F=gmfQ!qf@HGMvLKTKWpt=L~Ba} zu~(Ai^soV+V$4b@0+8eHcD~-{sjr-jA32 zbBJ{-Me0dNIHVO_Z-fGm)0nlrqfI>(yC(H=_Q>35b!6q+Xr1|H&@5B$4R)jk9c^JG z^8Q2!IXzZ6yrCb|1A9GYDq(6D{<-5E7KLt?ks9-}tdhUvnD4taE41eixaJ+??VT2k zFQv2Pl-g*^CH*U?lX(+=c@Oa5$}>c&x#(q?1e_%BYgE?XI$x~VR~bb11V+Ar>)`W_ z6#PyLr-gNSSetNI%ou-Kct(JkN{ESPJ?JCyS^I-8F4`H5Xed-93aWSvOd41rZdx=t zmbMrF+%z6vgxDvEEkYF9$H&|~lT8QJby_YHj!~u0admO#lW)U)9S2^~ zFXJl?;?Cj&+*6$`Zx+jxF$Fnf08G%;H*|! z_QU7PslYwBYbUw3>yp?8t)>GXAWgULePbubnyzWY4h6pezKQ}(r8sT5@_?dNSF;d{4+PZbG)Ad(tl?iww@uRea@AaH35f^WvE27IT`rog!g2xNEa5Iy zgOhrX-Ldx4P&>5t>F8Cq5KD2+`#*2o?2VMVnsqDX>-Yf<23W%wAL4Wy+(Vw7B{MLK zB=KCwKqwOwsY=e3wX}6DF8p{Mr#$TBx5pfIs6?^T7)21$hX#RaehZe3HhEBpQWrxz zIFFmz8UWz!W*U-JDG4VW`T{$HIA(FC5`j{?Kg!fT=@$OL`9)4nM5Ql55S7`mPq?}C zMdpD4{zhx{BCLf&8)wl#sgCrNX=rd`b-K17Hev@4S2z#LSWJsXmrLo>4crXw+vngW zxZ{>y+I32kzZn%Sf;_FUD4*s*)fHauxY{vYF14OpYdB-PL9GT z!2S-Vu0+<+lNgC#x!F$n(+xiiFuvBDH{5mNlk1aeOtFW^Zbid6jXN*?1Y3!92C3ek z-1Le0d*jzMxM%bhtywNz{ne3>Bas)@ShZna`&Dy)@<5W1m|>;&!E+4l4%{Cb;St z)wO6kroM+7wuGRMgUf~avwK!kysAs2eM9N%C9M4)cmRs7(3eqjs}*wN6?B+{$Wk~_ z-no7unr)bw+*g2uE0Kr$eVyAK56h4DjyD#4VrPlVsezeo0QjL{4my$`;Y&~%IuPs#e2sV<1@5D zjN=6;=m1o6Mlkh$Jtk|R*77V1`8aGp*?JqidpTWC;~Rn2t}$1tNLll{BFQjrID^*0 zf&^=&^C2D5+2b5sN(Mr%(+QO(*7rhgg=%(TBX)&SpIv)VcV0wXu{m1cz_dIPb2Kh$ zy9l>R3#p|5rhjgRkZ`-W zT&$D)NJQaZ;;UtbEt%ju!9+d9G%^%73A&JXfk1C000TxAu0$;hwXm2{X;(J z-iVsNuj`bPZXZq58ok~cvt?TpOH+Ot4}MZ6)?ipaP9jG>R1*8X(+|V^Llwxmj|+)# zu}`C{$8xjw^}QW5Oe)r=m)8Wh|M~9cj?aV^+#4&(v{lsJ6i#OV$Z$le*xZ-dLTf@A zarOzMDUkeTL;S6+9co$h4D`?wJZ8LSZ*S+De { {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'); + }} + > +
    +
    + )}