From e9d37e11aa76b5b76c6e5ce8fa1752c96ac1013a Mon Sep 17 00:00:00 2001
From: Alexander Polyankin <alexander.polyankin@metabase.com>
Date: Tue, 12 Oct 2021 13:21:23 +0300
Subject: [PATCH] Add approved domains FE validation (#18327)

---
 .../AuditNotificationEditModal.jsx            |  17 +-
 .../AuditNotificationEditModal.styled.jsx     |   6 +
 .../AuditAlertEditModal.jsx                   |   3 +
 .../AuditSubscriptionEditModal.jsx            |   3 +
 .../src/metabase/components/TokenField.jsx    |  18 +-
 .../metabase/components/TokenField.styled.jsx |  23 +++
 .../components/UserPicker/UserPicker.jsx      |   7 +-
 .../metabase/dashboard/components/Sidebar.jsx |  12 +-
 .../dashboard/components/Sidebar.styled.jsx   |  11 --
 frontend/src/metabase/lib/alert.js            |  21 +++
 frontend/src/metabase/lib/pulse.js            |  67 +++++---
 frontend/src/metabase/lib/settings.js         |   5 +
 frontend/src/metabase/lib/utils.js            |  10 +-
 .../metabase/pulse/components/PulseEdit.jsx   |   3 +
 .../pulse/components/PulseEditChannels.jsx    |   2 +
 .../pulse/components/RecipientPicker.jsx      | 102 ++++++-----
 .../components/RecipientPicker.styled.jsx     |   7 +
 .../query_builder/components/AlertModals.jsx  |  67 +++-----
 .../components/AlertModals.styled.jsx         |   5 -
 .../sharing/components/AddEditSidebar.jsx     |  21 +--
 .../sharing/components/SharingSidebar.jsx     |   9 +-
 frontend/test/metabase/lib/pulse.unit.spec.js | 159 +++++++++++-------
 .../sharing/approved-domains.cy.spec.js       |  81 ++++++---
 23 files changed, 406 insertions(+), 253 deletions(-)
 create mode 100644 frontend/src/metabase/components/TokenField.styled.jsx
 delete mode 100644 frontend/src/metabase/dashboard/components/Sidebar.styled.jsx
 create mode 100644 frontend/src/metabase/lib/alert.js
 create mode 100644 frontend/src/metabase/pulse/components/RecipientPicker.styled.jsx

diff --git a/enterprise/frontend/src/metabase-enterprise/audit_app/components/AuditNotificationEditModal/AuditNotificationEditModal.jsx b/enterprise/frontend/src/metabase-enterprise/audit_app/components/AuditNotificationEditModal/AuditNotificationEditModal.jsx
index e53fc697a4c..d3b0e02c642 100644
--- a/enterprise/frontend/src/metabase-enterprise/audit_app/components/AuditNotificationEditModal/AuditNotificationEditModal.jsx
+++ b/enterprise/frontend/src/metabase-enterprise/audit_app/components/AuditNotificationEditModal/AuditNotificationEditModal.jsx
@@ -1,15 +1,19 @@
 import React, { useState } from "react";
 import PropTypes from "prop-types";
 import { t } from "ttag";
+import MetabaseSettings from "metabase/lib/settings";
+import { recipientIsValid } from "metabase/lib/pulse";
 import Button from "metabase/components/Button";
 import FormMessage from "metabase/components/form/FormMessage";
 import ModalContent from "metabase/components/ModalContent";
 import UserPicker from "metabase/components/UserPicker";
+import { ModalErrorMessage } from "metabase-enterprise/audit_app/components/AuditNotificationEditModal/AuditNotificationEditModal.styled";
 
 const propTypes = {
   item: PropTypes.object.isRequired,
   type: PropTypes.oneOf(["alert", "pulse"]).isRequired,
   users: PropTypes.array.isRequired,
+  invalidRecipientText: PropTypes.func.isRequired,
   onUpdate: PropTypes.func,
   onDelete: PropTypes.func,
   onClose: PropTypes.func,
@@ -19,13 +23,18 @@ const AuditNotificationEditModal = ({
   item,
   type,
   users,
+  invalidRecipientText,
   onUpdate,
   onDelete,
   onClose,
 }) => {
   const [channels, setChannels] = useState(item.channels);
   const [error, setError] = useState();
-  const hasRecipients = channels.some(c => c.recipients.length > 0);
+  const domains = MetabaseSettings.subscriptionAllowedDomains().join(", ");
+  const recipients = channels.flatMap(c => c.recipients);
+  const hasRecipients = recipients.length > 0;
+  const hasValidDomains = recipients.every(recipientIsValid);
+  const hasValidRecipients = hasRecipients && hasValidDomains;
 
   const handleRecipientsChange = (recipients, index) => {
     const newChannels = [...channels];
@@ -59,7 +68,7 @@ const AuditNotificationEditModal = ({
     <Button
       key="update"
       primary
-      disabled={!hasRecipients}
+      disabled={!hasValidRecipients}
       onClick={handleUpdateClick}
     >
       {t`Update`}
@@ -76,10 +85,14 @@ const AuditNotificationEditModal = ({
         <UserPicker
           key={index}
           value={channel.recipients}
+          validateValue={recipientIsValid}
           users={users}
           onChange={recipients => handleRecipientsChange(recipients, index)}
         />
       ))}
+      {!hasValidDomains && (
+        <ModalErrorMessage>{invalidRecipientText(domains)}</ModalErrorMessage>
+      )}
     </ModalContent>
   );
 };
diff --git a/enterprise/frontend/src/metabase-enterprise/audit_app/components/AuditNotificationEditModal/AuditNotificationEditModal.styled.jsx b/enterprise/frontend/src/metabase-enterprise/audit_app/components/AuditNotificationEditModal/AuditNotificationEditModal.styled.jsx
index d1e323f6d07..d3390528bec 100644
--- a/enterprise/frontend/src/metabase-enterprise/audit_app/components/AuditNotificationEditModal/AuditNotificationEditModal.styled.jsx
+++ b/enterprise/frontend/src/metabase-enterprise/audit_app/components/AuditNotificationEditModal/AuditNotificationEditModal.styled.jsx
@@ -1,5 +1,6 @@
 import styled from "styled-components";
 import Button from "metabase/components/Button";
+import { color } from "metabase/lib/colors";
 import { space } from "metabase/styled-components/theme";
 
 export const ModalButton = styled(Button)`
@@ -9,3 +10,8 @@ export const ModalButton = styled(Button)`
     margin-left: ${space(2)};
   }
 `;
+
+export const ModalErrorMessage = styled.div`
+  color: ${color("error")};
+  margin-top: 0.5rem;
+`;
diff --git a/enterprise/frontend/src/metabase-enterprise/audit_app/containers/AuditAlertEditModal/AuditAlertEditModal.jsx b/enterprise/frontend/src/metabase-enterprise/audit_app/containers/AuditAlertEditModal/AuditAlertEditModal.jsx
index 11bf8424aa7..984cebbe715 100644
--- a/enterprise/frontend/src/metabase-enterprise/audit_app/containers/AuditAlertEditModal/AuditAlertEditModal.jsx
+++ b/enterprise/frontend/src/metabase-enterprise/audit_app/containers/AuditAlertEditModal/AuditAlertEditModal.jsx
@@ -1,6 +1,7 @@
 import { connect } from "react-redux";
 import { push } from "react-router-redux";
 import _ from "underscore";
+import { t } from "ttag";
 import Alerts from "metabase/entities/alerts";
 import Users from "metabase/entities/users";
 import AuditNotificationEditModal from "../../components/AuditNotificationEditModal";
@@ -8,6 +9,8 @@ import AuditNotificationEditModal from "../../components/AuditNotificationEditMo
 const mapStateToProps = (state, { alert }) => ({
   item: alert,
   type: "alert",
+  invalidRecipientText: domains =>
+    t`You're only allowed to email alerts to addresses ending in ${domains}`,
 });
 
 const mapDispatchToProps = {
diff --git a/enterprise/frontend/src/metabase-enterprise/audit_app/containers/AuditSubscriptionEditModal/AuditSubscriptionEditModal.jsx b/enterprise/frontend/src/metabase-enterprise/audit_app/containers/AuditSubscriptionEditModal/AuditSubscriptionEditModal.jsx
index 9598a0963c8..a162504f4ca 100644
--- a/enterprise/frontend/src/metabase-enterprise/audit_app/containers/AuditSubscriptionEditModal/AuditSubscriptionEditModal.jsx
+++ b/enterprise/frontend/src/metabase-enterprise/audit_app/containers/AuditSubscriptionEditModal/AuditSubscriptionEditModal.jsx
@@ -1,6 +1,7 @@
 import { connect } from "react-redux";
 import { push } from "react-router-redux";
 import _ from "underscore";
+import { t } from "ttag";
 import Pulses from "metabase/entities/pulses";
 import Users from "metabase/entities/users";
 import AuditNotificationEditModal from "../../components/AuditNotificationEditModal";
@@ -8,6 +9,8 @@ import AuditNotificationEditModal from "../../components/AuditNotificationEditMo
 const mapStateToProps = (state, { pulse }) => ({
   item: pulse,
   type: "pulse",
+  invalidRecipientText: domains =>
+    t`You're only allowed to email subscriptions to addresses ending in ${domains}`,
 });
 
 const mapDispatchToProps = {
diff --git a/frontend/src/metabase/components/TokenField.jsx b/frontend/src/metabase/components/TokenField.jsx
index 873baf80eb0..3f308e23d9b 100644
--- a/frontend/src/metabase/components/TokenField.jsx
+++ b/frontend/src/metabase/components/TokenField.jsx
@@ -8,6 +8,7 @@ import cx from "classnames";
 import OnClickOutsideWrapper from "metabase/components/OnClickOutsideWrapper";
 import Icon from "metabase/components/Icon";
 import Popover from "metabase/components/Popover";
+import { TokenFieldAddon, TokenFieldItem } from "./TokenField.styled";
 
 import {
   KEYCODE_ESCAPE,
@@ -62,6 +63,7 @@ type Props = {
   onFocus?: () => void,
   onBlur?: () => void,
 
+  validateValue: (value: Value) => boolean,
   updateOnInputChange?: boolean,
   updateOnInputBlur?: boolean,
   // if provided, parseFreeformValue parses the input string into a value,
@@ -121,6 +123,7 @@ export default class TokenField extends Component {
     valueRenderer: value => <span>{value}</span>,
     optionRenderer: option => <span>{option}</span>,
     layoutRenderer: props => <DefaultTokenFieldLayout {...props} />,
+    validateValue: () => true,
 
     color: "brand",
 
@@ -503,6 +506,7 @@ export default class TokenField extends Component {
       placeholder,
       multi,
 
+      validateValue,
       parseFreeformValue,
       updateOnInputChange,
 
@@ -568,11 +572,7 @@ export default class TokenField extends Component {
         onMouseDownCapture={this.onMouseDownCapture}
       >
         {value.map((v, index) => (
-          <li
-            key={index}
-            className={cx("flex align-center mr1 mb1 px1 rounded bg-medium")}
-            style={{ paddingTop: "12px", paddingBottom: "12px" }}
-          >
+          <TokenFieldItem key={index} isValid={validateValue(v)}>
             <span
               style={{ ...defaultStyleValue, ...valueStyle }}
               className={multi ? "pl1 pr0" : "px1"}
@@ -580,8 +580,8 @@ export default class TokenField extends Component {
               {valueRenderer(v)}
             </span>
             {multi && (
-              <a
-                className="text-medium flex align-center text-error-hover px1"
+              <TokenFieldAddon
+                isValid={validateValue(v)}
                 onClick={e => {
                   e.preventDefault();
                   this.removeValue(v);
@@ -589,9 +589,9 @@ export default class TokenField extends Component {
                 onMouseDown={e => e.preventDefault()}
               >
                 <Icon name="close" className="flex align-center" size={12} />
-              </a>
+              </TokenFieldAddon>
             )}
-          </li>
+          </TokenFieldItem>
         ))}
         <li className={cx("flex-full flex align-center mr1 mb1 p1")}>
           <input
diff --git a/frontend/src/metabase/components/TokenField.styled.jsx b/frontend/src/metabase/components/TokenField.styled.jsx
new file mode 100644
index 00000000000..18f3f8b073e
--- /dev/null
+++ b/frontend/src/metabase/components/TokenField.styled.jsx
@@ -0,0 +1,23 @@
+import styled from "styled-components";
+import { color } from "metabase/lib/colors";
+
+export const TokenFieldItem = styled.li`
+  display: flex;
+  align-items: center;
+  margin: 0 0.25rem 0.25rem 0;
+  padding: 0.75rem 0.5rem;
+  border-radius: 0.5rem;
+  color: ${({ isValid }) => (isValid ? "" : color("error"))};
+  background-color: ${color("bg-medium")};
+`;
+
+export const TokenFieldAddon = styled.a`
+  display: flex;
+  align-items: center;
+  padding: 0 0.5rem;
+  color: ${({ isValid }) => (isValid ? "" : color("error"))};
+
+  &:hover {
+    color: ${color("error")};
+  }
+`;
diff --git a/frontend/src/metabase/components/UserPicker/UserPicker.jsx b/frontend/src/metabase/components/UserPicker/UserPicker.jsx
index 1681ba4f3e4..9920e8ecd51 100644
--- a/frontend/src/metabase/components/UserPicker/UserPicker.jsx
+++ b/frontend/src/metabase/components/UserPicker/UserPicker.jsx
@@ -12,11 +12,12 @@ import {
 
 const propTypes = {
   value: PropTypes.array.isRequired,
+  validateValue: PropTypes.func,
   users: PropTypes.array.isRequired,
   onChange: PropTypes.func,
 };
 
-const UserPicker = ({ value, users, onChange }) => {
+const UserPicker = ({ value, validateValue, users, onChange }) => {
   const placeholder = !value.length
     ? t`Enter user names or email addresses`
     : null;
@@ -60,13 +61,15 @@ const UserPicker = ({ value, users, onChange }) => {
       <TokenField
         idKey={idKey}
         value={value}
+        validateValue={validateValue}
         valueRenderer={valueRenderer}
         options={options}
         optionRenderer={optionRenderer}
         filterOption={filterOption}
         parseFreeformValue={parseFreeformValue}
         placeholder={placeholder}
-        multi={true}
+        multi
+        updateOnInputBlur
         onChange={onChange}
       />
     </UserPickerRoot>
diff --git a/frontend/src/metabase/dashboard/components/Sidebar.jsx b/frontend/src/metabase/dashboard/components/Sidebar.jsx
index 0779f400586..a16c053d39e 100644
--- a/frontend/src/metabase/dashboard/components/Sidebar.jsx
+++ b/frontend/src/metabase/dashboard/components/Sidebar.jsx
@@ -1,23 +1,18 @@
-/* eslint-disable react/prop-types */
 import React from "react";
 import PropTypes from "prop-types";
 import { t } from "ttag";
-
 import Button from "metabase/components/Button";
-import { getErrorMessage } from "metabase/components/form/FormMessage";
-import { SidebarError, SidebarFooter } from "./Sidebar.styled";
 
 const WIDTH = 384;
 
 const propTypes = {
   closeIsDisabled: PropTypes.bool,
-  formError: PropTypes.object,
   children: PropTypes.node,
   onClose: PropTypes.func,
   onCancel: PropTypes.func,
 };
 
-function Sidebar({ closeIsDisabled, formError, children, onClose, onCancel }) {
+function Sidebar({ closeIsDisabled, children, onClose, onCancel }) {
   return (
     <aside
       style={{ width: WIDTH, minWidth: WIDTH }}
@@ -50,11 +45,6 @@ function Sidebar({ closeIsDisabled, formError, children, onClose, onCancel }) {
           )}
         </div>
       )}
-      {formError && (
-        <SidebarFooter>
-          <SidebarError>{getErrorMessage(formError)}</SidebarError>
-        </SidebarFooter>
-      )}
     </aside>
   );
 }
diff --git a/frontend/src/metabase/dashboard/components/Sidebar.styled.jsx b/frontend/src/metabase/dashboard/components/Sidebar.styled.jsx
deleted file mode 100644
index 82233ec8db6..00000000000
--- a/frontend/src/metabase/dashboard/components/Sidebar.styled.jsx
+++ /dev/null
@@ -1,11 +0,0 @@
-import styled from "styled-components";
-import { color } from "metabase/lib/colors";
-import { space } from "metabase/styled-components/theme";
-
-export const SidebarFooter = styled.div`
-  padding: 0 ${space(3)} ${space(2)};
-`;
-
-export const SidebarError = styled.div`
-  color: ${color("error")};
-`;
diff --git a/frontend/src/metabase/lib/alert.js b/frontend/src/metabase/lib/alert.js
new file mode 100644
index 00000000000..56e31f35e53
--- /dev/null
+++ b/frontend/src/metabase/lib/alert.js
@@ -0,0 +1,21 @@
+import { recipientIsValid, scheduleIsValid } from "metabase/lib/pulse";
+
+export function channelIsValid(channel) {
+  switch (channel.channel_type) {
+    case "email":
+      return (
+        channel.recipients &&
+        channel.recipients.length > 0 &&
+        channel.recipients.every(recipientIsValid) &&
+        scheduleIsValid(channel)
+      );
+    case "slack":
+      return channel.details && scheduleIsValid(channel);
+    default:
+      return false;
+  }
+}
+
+export function alertIsValid(alert) {
+  return alert.channels.length > 0 && alert.channels.every(channelIsValid);
+}
diff --git a/frontend/src/metabase/lib/pulse.js b/frontend/src/metabase/lib/pulse.js
index 71c8e2a28f0..a331891e4f1 100644
--- a/frontend/src/metabase/lib/pulse.js
+++ b/frontend/src/metabase/lib/pulse.js
@@ -1,4 +1,6 @@
 import _ from "underscore";
+import MetabaseSettings from "metabase/lib/settings";
+import MetabaseUtils from "metabase/lib/utils";
 import {
   hasDefaultParameterValue,
   hasParameterValue,
@@ -14,31 +16,28 @@ export const NEW_PULSE_TEMPLATE = {
 };
 
 export function channelIsValid(channel, channelSpec) {
-  if (!channelSpec) {
-    return false;
-  }
-
-  if (channelSpec.recipients) {
-    // default from formInput is an empty array, not a null array
-    // check for both
-    if (!channel.recipients || channel.recipients.length < 1) {
-      return false;
-    }
-  }
-
-  if (channelSpec.fields) {
-    for (const field of channelSpec.fields) {
-      if (
-        field.required &&
+  switch (channel.channel_type) {
+    case "email":
+      return (
+        channel.recipients &&
+        channel.recipients.length > 0 &&
+        channel.recipients.every(recipientIsValid) &&
+        fieldsAreValid(channel, channelSpec) &&
+        scheduleIsValid(channel)
+      );
+    case "slack":
+      return (
         channel.details &&
-        (channel.details[field.name] == null ||
-          channel.details[field.name] === "")
-      ) {
-        return false;
-      }
-    }
+        channel.details.channel &&
+        fieldsAreValid(channel, channelSpec) &&
+        scheduleIsValid(channel)
+      );
+    default:
+      return false;
   }
+}
 
+export function scheduleIsValid(channel) {
   switch (channel.schedule_type) {
     case "monthly":
       if (channel.schedule_frame != null && channel.schedule_hour != null) {
@@ -65,6 +64,20 @@ export function channelIsValid(channel, channelSpec) {
   return true;
 }
 
+export function fieldsAreValid(channel, channelSpec) {
+  if (!channelSpec) {
+    return false;
+  }
+
+  if (!channelSpec.fields) {
+    return true;
+  }
+
+  return channelSpec.fields
+    .filter(field => field.required)
+    .every(field => Boolean(channel.details?.[field.name]));
+}
+
 function pulseChannelsAreValid(pulse, channelSpecs) {
   return (
     pulse.channels.filter(c =>
@@ -73,6 +86,16 @@ function pulseChannelsAreValid(pulse, channelSpecs) {
   );
 }
 
+export function recipientIsValid(recipient) {
+  if (recipient.id) {
+    return true;
+  }
+
+  const recipientDomain = MetabaseUtils.getEmailDomain(recipient.email);
+  const allowedDomains = MetabaseSettings.subscriptionAllowedDomains();
+  return _.isEmpty(allowedDomains) || allowedDomains.includes(recipientDomain);
+}
+
 export function pulseIsValid(pulse, channelSpecs) {
   return (
     (pulse.name &&
diff --git a/frontend/src/metabase/lib/settings.js b/frontend/src/metabase/lib/settings.js
index 94d011e8e06..6195f55d349 100644
--- a/frontend/src/metabase/lib/settings.js
+++ b/frontend/src/metabase/lib/settings.js
@@ -248,6 +248,11 @@ class Settings {
       return null;
     }
   }
+
+  subscriptionAllowedDomains() {
+    const setting = this.get("subscription-allowed-domains") ?? "";
+    return setting ? setting.split(",") : [];
+  }
 }
 
 const n2w = n => MetabaseUtils.numberToWord(n);
diff --git a/frontend/src/metabase/lib/utils.js b/frontend/src/metabase/lib/utils.js
index 4f92bb0d4db..715e220773c 100644
--- a/frontend/src/metabase/lib/utils.js
+++ b/frontend/src/metabase/lib/utils.js
@@ -24,6 +24,8 @@ const LAYOUT_PROPS = [
   "bordered",
 ];
 
+const EMAIL_REGEX = /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
+
 export function stripLayoutProps(props) {
   return _.omit(props, LAYOUT_PROPS);
 }
@@ -144,8 +146,12 @@ const MetabaseUtils = {
   },
 
   isEmail(email) {
-    const re = /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
-    return re.test(email);
+    return EMAIL_REGEX.test(email);
+  },
+
+  getEmailDomain(email) {
+    const match = EMAIL_REGEX.exec(email);
+    return match && match[5];
   },
 
   equals(a, b) {
diff --git a/frontend/src/metabase/pulse/components/PulseEdit.jsx b/frontend/src/metabase/pulse/components/PulseEdit.jsx
index 64bb0919fe7..056994e1485 100644
--- a/frontend/src/metabase/pulse/components/PulseEdit.jsx
+++ b/frontend/src/metabase/pulse/components/PulseEdit.jsx
@@ -202,6 +202,9 @@ export default class PulseEdit extends Component {
               {...this.props}
               setPulse={this.setPulse}
               pulseIsValid={isValid}
+              invalidRecipientText={domains =>
+                t`You're only allowed to email pulses to addresses ending in ${domains}`
+              }
             />
           </div>
           <PulseEditSkip {...this.props} setPulse={this.setPulse} />
diff --git a/frontend/src/metabase/pulse/components/PulseEditChannels.jsx b/frontend/src/metabase/pulse/components/PulseEditChannels.jsx
index d9c513cd9c4..2c5d4d1e4c4 100644
--- a/frontend/src/metabase/pulse/components/PulseEditChannels.jsx
+++ b/frontend/src/metabase/pulse/components/PulseEditChannels.jsx
@@ -43,6 +43,7 @@ export default class PulseEditChannels extends Component {
     cardPreviews: PropTypes.object,
     hideSchedulePicker: PropTypes.bool,
     emailRecipientText: PropTypes.string,
+    invalidRecipientText: PropTypes.func.isRequired,
   };
   static defaultProps = {};
 
@@ -203,6 +204,7 @@ export default class PulseEditChannels extends Component {
               onRecipientsChange={recipients =>
                 this.onChannelPropertyChange(index, "recipients", recipients)
               }
+              invalidRecipientText={this.props.invalidRecipientText}
             />
           </div>
         )}
diff --git a/frontend/src/metabase/pulse/components/RecipientPicker.jsx b/frontend/src/metabase/pulse/components/RecipientPicker.jsx
index f0b2da53aa3..f5f3751718f 100644
--- a/frontend/src/metabase/pulse/components/RecipientPicker.jsx
+++ b/frontend/src/metabase/pulse/components/RecipientPicker.jsx
@@ -2,13 +2,13 @@
 import React, { Component } from "react";
 import PropTypes from "prop-types";
 import { t } from "ttag";
-
+import { recipientIsValid } from "metabase/lib/pulse";
+import MetabaseAnalytics from "metabase/lib/analytics";
+import MetabaseSettings from "metabase/lib/settings";
+import MetabaseUtils from "metabase/lib/utils";
 import TokenField from "metabase/components/TokenField";
 import UserAvatar from "metabase/components/UserAvatar";
-
-import MetabaseAnalytics from "metabase/lib/analytics";
-
-const VALID_EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+import { ErrorMessage } from "./RecipientPicker.styled";
 
 export default class RecipientPicker extends Component {
   static propTypes = {
@@ -18,6 +18,7 @@ export default class RecipientPicker extends Component {
     isNewPulse: PropTypes.bool.isRequired,
     onRecipientsChange: PropTypes.func.isRequired,
     autoFocus: PropTypes.bool,
+    invalidRecipientText: PropTypes.func.isRequired,
   };
 
   static defaultProps = {
@@ -50,49 +51,58 @@ export default class RecipientPicker extends Component {
   }
 
   render() {
-    const { recipients, users, autoFocus } = this.props;
+    const { recipients, users, autoFocus, invalidRecipientText } = this.props;
+    const isValid = recipients.every(r => recipientIsValid(r));
+    const domains = MetabaseSettings.subscriptionAllowedDomains().join(", ");
+
     return (
-      <div className="bordered rounded" style={{ padding: "2px" }}>
-        <TokenField
-          value={recipients}
-          options={
-            users
-              ? users.map(user => ({ label: user.common_name, value: user }))
-              : []
-          }
-          onChange={this.handleOnChange}
-          placeholder={
-            recipients.length === 0
-              ? t`Enter user names or email addresses`
-              : null
-          }
-          autoFocus={autoFocus && recipients.length === 0}
-          multi
-          valueRenderer={value => value.common_name || value.email}
-          optionRenderer={option => (
-            <div className="flex align-center">
-              <span className="text-white">
-                <UserAvatar user={option.value} />
-              </span>
-              <span className="ml1">{option.value.common_name}</span>
-            </div>
-          )}
-          filterOption={(option, filterString) =>
-            // case insensitive search of name or email
-            ~option.value.common_name
-              .toLowerCase()
-              .indexOf(filterString.toLowerCase()) ||
-            ~option.value.email
-              .toLowerCase()
-              .indexOf(filterString.toLowerCase())
-          }
-          parseFreeformValue={inputValue => {
-            if (VALID_EMAIL_REGEX.test(inputValue)) {
-              return { email: inputValue };
+      <div>
+        <div className="bordered rounded" style={{ padding: "2px" }}>
+          <TokenField
+            value={recipients}
+            options={
+              users
+                ? users.map(user => ({ label: user.common_name, value: user }))
+                : []
+            }
+            onChange={this.handleOnChange}
+            placeholder={
+              recipients.length === 0
+                ? t`Enter user names or email addresses`
+                : null
+            }
+            autoFocus={autoFocus && recipients.length === 0}
+            multi
+            valueRenderer={value => value.common_name || value.email}
+            optionRenderer={option => (
+              <div className="flex align-center">
+                <span className="text-white">
+                  <UserAvatar user={option.value} />
+                </span>
+                <span className="ml1">{option.value.common_name}</span>
+              </div>
+            )}
+            filterOption={(option, filterString) =>
+              // case insensitive search of name or email
+              ~option.value.common_name
+                .toLowerCase()
+                .indexOf(filterString.toLowerCase()) ||
+              ~option.value.email
+                .toLowerCase()
+                .indexOf(filterString.toLowerCase())
             }
-          }}
-          updateOnInputBlur
-        />
+            validateValue={value => recipientIsValid(value)}
+            parseFreeformValue={inputValue => {
+              if (MetabaseUtils.isEmail(inputValue)) {
+                return { email: inputValue };
+              }
+            }}
+            updateOnInputBlur
+          />
+        </div>
+        {!isValid && (
+          <ErrorMessage>{invalidRecipientText(domains)}</ErrorMessage>
+        )}
       </div>
     );
   }
diff --git a/frontend/src/metabase/pulse/components/RecipientPicker.styled.jsx b/frontend/src/metabase/pulse/components/RecipientPicker.styled.jsx
new file mode 100644
index 00000000000..c7be949655d
--- /dev/null
+++ b/frontend/src/metabase/pulse/components/RecipientPicker.styled.jsx
@@ -0,0 +1,7 @@
+import styled from "styled-components";
+import { color } from "metabase/lib/colors";
+
+export const ErrorMessage = styled.div`
+  color: ${color("error")};
+  margin-top: 0.5rem;
+`;
diff --git a/frontend/src/metabase/query_builder/components/AlertModals.jsx b/frontend/src/metabase/query_builder/components/AlertModals.jsx
index 03d0db7b68a..2b0a4df3fc2 100644
--- a/frontend/src/metabase/query_builder/components/AlertModals.jsx
+++ b/frontend/src/metabase/query_builder/components/AlertModals.jsx
@@ -15,8 +15,7 @@ import Icon from "metabase/components/Icon";
 import ChannelSetupModal from "metabase/components/ChannelSetupModal";
 import ButtonWithStatus from "metabase/components/ButtonWithStatus";
 import PulseEditChannels from "metabase/pulse/components/PulseEditChannels";
-import { getErrorMessage } from "metabase/components/form/FormMessage";
-import { AlertModalFooter, AlertModalError } from "./AlertModals.styled";
+import { AlertModalFooter } from "./AlertModals.styled";
 
 import User from "metabase/entities/users";
 
@@ -50,6 +49,7 @@ import MetabaseAnalytics from "metabase/lib/analytics";
 
 // types
 import type { AlertType } from "metabase-lib/lib/Alert";
+import { alertIsValid } from "metabase/lib/alert";
 
 const getScheduleFromChannel = channel =>
   _.pick(
@@ -89,7 +89,6 @@ export class CreateAlertModalContent extends Component {
     this.state = {
       hasSeenEducationalScreen: MetabaseCookies.getHasSeenAlertSplash(),
       alert: getDefaultAlert(question, user, visualizationSettings),
-      formError: null,
     };
   }
 
@@ -125,19 +124,12 @@ export class CreateAlertModalContent extends Component {
     } = this.props;
     const { alert } = this.state;
 
-    try {
-      this.setState({ formError: null });
+    await apiUpdateQuestion(question);
+    await createAlert(alert);
+    await updateUrl(question.card(), { dirty: false });
 
-      await apiUpdateQuestion(question);
-      await createAlert(alert);
-      await updateUrl(question.card(), { dirty: false });
-
-      onAlertCreated();
-      MetabaseAnalytics.trackEvent("Alert", "Create", alert.alert_condition);
-    } catch (e) {
-      this.setState({ formError: e });
-      throw e;
-    }
+    onAlertCreated();
+    MetabaseAnalytics.trackEvent("Alert", "Create", alert.alert_condition);
   };
 
   proceedFromEducationalScreen = () => {
@@ -156,11 +148,12 @@ export class CreateAlertModalContent extends Component {
       user,
       hasLoadedChannelInfo,
     } = this.props;
-    const { alert, hasSeenEducationalScreen, formError } = this.state;
+    const { alert, hasSeenEducationalScreen } = this.state;
 
     const channelRequirementsMet = isAdmin
       ? hasConfiguredAnyChannel
       : hasConfiguredEmailChannel;
+    const isValid = alertIsValid(alert);
 
     if (hasLoadedChannelInfo && !channelRequirementsMet) {
       return (
@@ -197,12 +190,10 @@ export class CreateAlertModalContent extends Component {
             onAlertChange={this.onAlertChange}
           />
           <AlertModalFooter>
-            {formError && (
-              <AlertModalError>{getErrorMessage(formError)}</AlertModalError>
-            )}
             <Button onClick={onCancel} className="mr2">{t`Cancel`}</Button>
             <ButtonWithStatus
               titleForState={{ default: t`Done` }}
+              disabled={!isValid}
               onClickOperation={this.onCreateAlert}
             />
           </AlertModalFooter>
@@ -318,7 +309,6 @@ export class UpdateAlertModalContent extends Component {
     super();
     this.state = {
       modifiedAlert: props.alert,
-      formError: null,
     };
   }
 
@@ -334,24 +324,16 @@ export class UpdateAlertModalContent extends Component {
     } = this.props;
     const { modifiedAlert } = this.state;
 
-    try {
-      this.setState({ formError: null });
-
-      await apiUpdateQuestion();
-      await updateAlert(modifiedAlert);
-      await updateUrl(question.card(), { dirty: false });
-
-      onAlertUpdated();
+    await apiUpdateQuestion();
+    await updateAlert(modifiedAlert);
+    await updateUrl(question.card(), { dirty: false });
+    onAlertUpdated();
 
-      MetabaseAnalytics.trackEvent(
-        "Alert",
-        "Update",
-        modifiedAlert.alert_condition,
-      );
-    } catch (e) {
-      this.setState({ formError: e });
-      throw e;
-    }
+    MetabaseAnalytics.trackEvent(
+      "Alert",
+      "Update",
+      modifiedAlert.alert_condition,
+    );
   };
 
   onDeleteAlert = async () => {
@@ -369,10 +351,12 @@ export class UpdateAlertModalContent extends Component {
       user,
       isAdmin,
     } = this.props;
-    const { modifiedAlert, formError } = this.state;
+    const { modifiedAlert } = this.state;
 
     const isCurrentUser = alert.creator.id === user.id;
     const title = isCurrentUser ? t`Edit your alert` : t`Edit alert`;
+    const isValid = alertIsValid(alert);
+
     // TODO: Remove PulseEdit css hack
     return (
       <ModalContent onClose={onCancel}>
@@ -394,12 +378,10 @@ export class UpdateAlertModalContent extends Component {
           )}
 
           <AlertModalFooter>
-            {formError && (
-              <AlertModalError>{getErrorMessage(formError)}</AlertModalError>
-            )}
             <Button onClick={onCancel} className="mr2">{t`Cancel`}</Button>
             <ButtonWithStatus
               titleForState={{ default: t`Save changes` }}
+              disabled={!isValid}
               onClickOperation={this.onUpdateAlert}
             />
           </AlertModalFooter>
@@ -680,6 +662,9 @@ export class AlertEditChannels extends Component {
             setPulse={this.onSetPulse}
             hideSchedulePicker={true}
             emailRecipientText={t`Email alerts to:`}
+            invalidRecipientText={domains =>
+              t`You're only allowed to email alerts to addresses ending in ${domains}`
+            }
           />
         </div>
       </div>
diff --git a/frontend/src/metabase/query_builder/components/AlertModals.styled.jsx b/frontend/src/metabase/query_builder/components/AlertModals.styled.jsx
index ed338861bfe..c8aa54390d1 100644
--- a/frontend/src/metabase/query_builder/components/AlertModals.styled.jsx
+++ b/frontend/src/metabase/query_builder/components/AlertModals.styled.jsx
@@ -1,6 +1,5 @@
 import styled from "styled-components";
 import { space } from "metabase/styled-components/theme";
-import { color } from "metabase/lib/colors";
 
 export const AlertModalFooter = styled.div`
   display: flex;
@@ -8,7 +7,3 @@ export const AlertModalFooter = styled.div`
   align-items: center;
   margin-top: ${space(3)};
 `;
-
-export const AlertModalError = styled.div`
-  color: ${color("error")};
-`;
diff --git a/frontend/src/metabase/sharing/components/AddEditSidebar.jsx b/frontend/src/metabase/sharing/components/AddEditSidebar.jsx
index 89674b075e8..a383c626d51 100644
--- a/frontend/src/metabase/sharing/components/AddEditSidebar.jsx
+++ b/frontend/src/metabase/sharing/components/AddEditSidebar.jsx
@@ -56,7 +56,6 @@ export const AddEditSlackSidebar = connect(mapStateToProps)(
 function _AddEditEmailSidebar({
   pulse,
   formInput,
-  formError,
   channel,
   channelSpec,
   users,
@@ -75,10 +74,11 @@ function _AddEditEmailSidebar({
   handleArchive,
   setPulseParameters,
 }) {
+  const isValid = dashboardPulseIsValid(pulse, formInput.channels);
+
   return (
     <Sidebar
-      closeIsDisabled={!dashboardPulseIsValid(pulse, formInput.channels)}
-      formError={formError}
+      closeIsDisabled={!isValid}
       onClose={handleSave}
       onCancel={onCancel}
     >
@@ -99,6 +99,9 @@ function _AddEditEmailSidebar({
             onRecipientsChange={recipients =>
               onChannelPropertyChange("recipients", recipients)
             }
+            invalidRecipientText={domains =>
+              t`You're only allowed to email subscriptions to addresses ending in ${domains}`
+            }
           />
         </div>
         {channelSpec.fields && (
@@ -132,7 +135,7 @@ function _AddEditEmailSidebar({
             testPulse={testPulse}
             normalText={t`Send email now`}
             successText={t`Email sent`}
-            disabled={channel.recipients.length === 0}
+            disabled={!isValid}
           />
         </div>
         {PLUGIN_DASHBOARD_SUBSCRIPTION_PARAMETERS_SECTION_OVERRIDE.Component ? (
@@ -188,7 +191,6 @@ function _AddEditEmailSidebar({
 _AddEditEmailSidebar.propTypes = {
   pulse: PropTypes.object.isRequired,
   formInput: PropTypes.object.isRequired,
-  formError: PropTypes.object,
   channel: PropTypes.object.isRequired,
   channelSpec: PropTypes.object.isRequired,
   users: PropTypes.array,
@@ -271,7 +273,6 @@ function getConfirmItems(pulse) {
 function _AddEditSlackSidebar({
   pulse,
   formInput,
-  formError,
   channel,
   channelSpec,
   parameters,
@@ -287,10 +288,11 @@ function _AddEditSlackSidebar({
   handleArchive,
   setPulseParameters,
 }) {
+  const isValid = dashboardPulseIsValid(pulse, formInput.channels);
+
   return (
     <Sidebar
-      closeIsDisabled={!dashboardPulseIsValid(pulse, formInput.channels)}
-      formError={formError}
+      closeIsDisabled={!isValid}
       onClose={handleSave}
       onCancel={onCancel}
     >
@@ -331,7 +333,7 @@ function _AddEditSlackSidebar({
             testPulse={testPulse}
             normalText={t`Send to Slack now`}
             successText={t`Slack sent`}
-            disabled={channel.details.channel === undefined}
+            disabled={!isValid}
           />
         </div>
         {PLUGIN_DASHBOARD_SUBSCRIPTION_PARAMETERS_SECTION_OVERRIDE.Component ? (
@@ -371,7 +373,6 @@ function _AddEditSlackSidebar({
 _AddEditSlackSidebar.propTypes = {
   pulse: PropTypes.object.isRequired,
   formInput: PropTypes.object.isRequired,
-  formError: PropTypes.object,
   channel: PropTypes.object.isRequired,
   channelSpec: PropTypes.object.isRequired,
   users: PropTypes.array,
diff --git a/frontend/src/metabase/sharing/components/SharingSidebar.jsx b/frontend/src/metabase/sharing/components/SharingSidebar.jsx
index 196d051e2b7..8390a664022 100644
--- a/frontend/src/metabase/sharing/components/SharingSidebar.jsx
+++ b/frontend/src/metabase/sharing/components/SharingSidebar.jsx
@@ -120,7 +120,6 @@ class SharingSidebar extends React.Component {
     // use this to know where to go "back" to
     returnMode: [],
     isSaving: false,
-    formError: null,
   };
 
   static propTypes = {
@@ -231,12 +230,10 @@ class SharingSidebar extends React.Component {
     );
 
     try {
-      this.setState({ isSaving: true, formError: null });
+      this.setState({ isSaving: true });
       await this.props.updateEditingPulse(cleanedPulse);
       await this.props.saveEditingPulse();
       this.setState({ editingMode: "list-pulses", returnMode: [] });
-    } catch (e) {
-      this.setState({ formError: e });
     } finally {
       this.setState({ isSaving: false });
     }
@@ -283,7 +280,7 @@ class SharingSidebar extends React.Component {
   };
 
   render() {
-    const { editingMode, formError } = this.state;
+    const { editingMode } = this.state;
     const {
       pulse,
       pulses,
@@ -329,7 +326,6 @@ class SharingSidebar extends React.Component {
         <AddEditEmailSidebar
           pulse={pulse}
           formInput={formInput}
-          formError={formError}
           channel={channel}
           channelSpec={channelSpec}
           handleSave={this.handleSave}
@@ -372,7 +368,6 @@ class SharingSidebar extends React.Component {
         <AddEditSlackSidebar
           pulse={pulse}
           formInput={formInput}
-          formError={formError}
           channel={channel}
           channelSpec={channelSpec}
           handleSave={this.handleSave}
diff --git a/frontend/test/metabase/lib/pulse.unit.spec.js b/frontend/test/metabase/lib/pulse.unit.spec.js
index bd61e13f8ce..748972bb6eb 100644
--- a/frontend/test/metabase/lib/pulse.unit.spec.js
+++ b/frontend/test/metabase/lib/pulse.unit.spec.js
@@ -1,82 +1,117 @@
+import MetabaseSettings from "metabase/lib/settings";
 import {
-  getPulseParameters,
   getActivePulseParameters,
+  getPulseParameters,
+  recipientIsValid,
 } from "metabase/lib/pulse";
 
-describe("metabase/lib/pulse", () => {
-  describe("getPulseParameters", () => {
-    it("returns a pulse's parameters", () => {
-      expect(
-        getPulseParameters({
-          parameters: [{ id: "foo", value: ["foo"] }],
-        }),
-      ).toEqual([{ id: "foo", value: ["foo"] }]);
-    });
+describe("recipientIsValid", () => {
+  let originalDomains;
 
-    it("defaults to an empty array", () => {
-      expect(getPulseParameters()).toEqual([]);
-      expect(getPulseParameters({})).toEqual([]);
-    });
+  beforeEach(() => {
+    originalDomains = MetabaseSettings.get("subscription-allowed-domains");
   });
 
-  describe("getActivePulseParameters", () => {
-    let pulse;
-    let parametersList;
-    beforeEach(() => {
-      pulse = {
-        parameters: [
-          {
-            id: "no default value",
-            value: ["foo"],
-          },
-          {
-            id: "overridden default value",
-            default: ["bar"],
-            value: ["baz"],
-          },
-          { id: "does not exist", value: ["does not exist"] },
-          { id: "null value that should be overridden", value: null },
-        ],
-      };
+  afterEach(() => {
+    MetabaseSettings.set("subscription-allowed-domains", originalDomains);
+  });
 
-      parametersList = [
-        {
-          id: "no default value",
-        },
-        { id: "unused", value: ["unused"] },
+  it("should be valid for every metabase user", () => {
+    const recipient = { id: 1, email: "user@metabase.example" };
+    MetabaseSettings.set("subscription-allowed-domains", "metabase.test");
+    expect(recipientIsValid(recipient)).toBeTruthy();
+  });
 
-        { id: "foo" },
-        { id: "overridden default value", default: ["bar"] },
-        { id: "unadded default value", default: [123] },
-        {
-          id: "null value that should be overridden",
-          default: ["not null value"],
-        },
-      ];
-    });
+  it("should be valid when approved domains are not set", () => {
+    const recipient = { email: "user@metabase.example" };
+    expect(recipientIsValid(recipient)).toBeTruthy();
+  });
+
+  it("should not be valid for a recipient with another domain", () => {
+    const recipient = { email: "user@metabase.example" };
+    MetabaseSettings.set("subscription-allowed-domains", "metabase.test");
+    expect(recipientIsValid(recipient)).toBeFalsy();
+  });
+
+  it("should be valid for a recipient with the specified domain", () => {
+    const recipient = { email: "user@metabase.test" };
+    MetabaseSettings.set("subscription-allowed-domains", "metabase.test");
+    expect(recipientIsValid(recipient)).toBeTruthy();
+  });
+});
 
-    it("should return a list of parameters that are applied to the pulse data", () => {
-      expect(getActivePulseParameters(pulse, parametersList)).toEqual([
+describe("getPulseParameters", () => {
+  it("returns a pulse's parameters", () => {
+    expect(
+      getPulseParameters({
+        parameters: [{ id: "foo", value: ["foo"] }],
+      }),
+    ).toEqual([{ id: "foo", value: ["foo"] }]);
+  });
+
+  it("defaults to an empty array", () => {
+    expect(getPulseParameters()).toEqual([]);
+    expect(getPulseParameters({})).toEqual([]);
+  });
+});
+
+describe("getActivePulseParameters", () => {
+  let pulse;
+  let parametersList;
+  beforeEach(() => {
+    pulse = {
+      parameters: [
         {
           id: "no default value",
           value: ["foo"],
         },
         {
-          default: ["bar"],
           id: "overridden default value",
+          default: ["bar"],
           value: ["baz"],
         },
-        {
-          default: [123],
-          id: "unadded default value",
-          value: [123],
-        },
-        {
-          default: ["not null value"],
-          id: "null value that should be overridden",
-          value: ["not null value"],
-        },
-      ]);
-    });
+        { id: "does not exist", value: ["does not exist"] },
+        { id: "null value that should be overridden", value: null },
+      ],
+    };
+
+    parametersList = [
+      {
+        id: "no default value",
+      },
+      { id: "unused", value: ["unused"] },
+
+      { id: "foo" },
+      { id: "overridden default value", default: ["bar"] },
+      { id: "unadded default value", default: [123] },
+      {
+        id: "null value that should be overridden",
+        default: ["not null value"],
+      },
+    ];
+  });
+
+  it("should return a list of parameters that are applied to the pulse data", () => {
+    expect(getActivePulseParameters(pulse, parametersList)).toEqual([
+      {
+        id: "no default value",
+        value: ["foo"],
+      },
+      {
+        default: ["bar"],
+        id: "overridden default value",
+        value: ["baz"],
+      },
+      {
+        default: [123],
+        id: "unadded default value",
+        value: [123],
+      },
+      {
+        default: ["not null value"],
+        id: "null value that should be overridden",
+        value: ["not null value"],
+      },
+    ]);
   });
 });
diff --git a/frontend/test/metabase/scenarios/sharing/approved-domains.cy.spec.js b/frontend/test/metabase/scenarios/sharing/approved-domains.cy.spec.js
index 765450aafce..3b12078d0ef 100644
--- a/frontend/test/metabase/scenarios/sharing/approved-domains.cy.spec.js
+++ b/frontend/test/metabase/scenarios/sharing/approved-domains.cy.spec.js
@@ -1,23 +1,24 @@
 import {
+  describeWithToken,
+  modal,
   restore,
   setupSMTP,
-  describeWithToken,
   sidebar,
 } from "__support__/e2e/cypress";
 
 const allowedDomain = "metabase.test";
 const deniedDomain = "metabase.example";
-const email = "mailer@" + deniedDomain;
+const allowedEmail = `mailer@${allowedDomain}`;
+const deniedEmail = `mailer@${deniedDomain}`;
+const subscriptionError = `You're only allowed to email subscriptions to addresses ending in ${allowedDomain}`;
+const alertError = `You're only allowed to email alerts to addresses ending in ${allowedDomain}`;
 
-const errorMessage = `You cannot create new subscriptions for the domain "${deniedDomain}". Allowed domains are: ${allowedDomain}`;
-
-describeWithToken("scenarios > alert (EE)", () => {
+describeWithToken("scenarios > sharing > approved domains (EE)", () => {
   beforeEach(() => {
     restore();
     cy.signInAsAdmin();
-
     setupSMTP();
-    setAllowedEmailDomains(allowedDomain);
+    setAllowedDomains();
   });
 
   it("should validate approved email domains for a question alert", () => {
@@ -26,46 +27,80 @@ describeWithToken("scenarios > alert (EE)", () => {
     cy.icon("bell").click();
     cy.findByText("Set up an alert").click();
 
-    addEmailRecipient(email);
+    cy.findByText("Email alerts to:")
+      .parent()
+      .within(() => addEmailRecipient(deniedEmail));
 
+    cy.button("Done").should("be.disabled");
+    cy.findByText(alertError);
+  });
+
+  it("should validate approved email domains for a question alert in the audit app", () => {
+    cy.visit("/question/1");
+    cy.icon("bell").click();
+    cy.findByText("Set up an alert").click();
     cy.button("Done").click();
-    cy.findByText(errorMessage);
+    cy.findByText("Your alert is all set up.");
+
+    cy.visit("/admin/audit/subscriptions/alerts");
+    cy.findByText("1").click();
+
+    modal().within(() => {
+      addEmailRecipient(deniedEmail);
+
+      cy.button("Update").should("be.disabled");
+      cy.findByText(alertError);
+    });
   });
 
   it("should validate approved email domains for a dashboard subscription (metabase#17977)", () => {
     cy.visit("/dashboard/1");
-
     cy.icon("share").click();
     cy.findByText("Dashboard subscriptions").click();
     cy.findByText("Email it").click();
 
-    cy.findByPlaceholderText("Enter user names or email addresses")
-      .click()
-      .type(`${email}`)
-      .blur();
-
     sidebar().within(() => {
+      addEmailRecipient(deniedEmail);
+
       // Reproduces metabase#17977
-      cy.findByText("Send email now").click();
-      cy.findByText("Sending failed");
+      cy.button("Send email now").should("be.disabled");
+      cy.button("Done").should("be.disabled");
+      cy.findByText(subscriptionError);
+    });
+  });
+
+  it("should validate approved email domains for a dashboard subscription in the audit app", () => {
+    cy.visit("/dashboard/1");
+    cy.icon("share").click();
+    cy.findByText("Dashboard subscriptions").click();
+    cy.findByText("Email it").click();
 
+    sidebar().within(() => {
+      addEmailRecipient(allowedEmail);
       cy.button("Done").click();
-      cy.findByText(errorMessage);
+    });
+
+    cy.visit("/admin/audit/subscriptions/subscriptions");
+    cy.findByText("1").click();
+
+    modal().within(() => {
+      addEmailRecipient(deniedEmail);
+
+      cy.button("Update").should("be.disabled");
+      cy.findByText(subscriptionError);
     });
   });
 });
 
 function addEmailRecipient(email) {
-  cy.findByText("Email alerts to:")
-    .parent()
-    .find("input")
+  cy.get("input")
     .click()
     .type(`${email}`)
     .blur();
 }
 
-function setAllowedEmailDomains(domains) {
+function setAllowedDomains() {
   cy.request("PUT", "/api/setting/subscription-allowed-domains", {
-    value: domains,
+    value: allowedDomain,
   });
 }
-- 
GitLab