diff --git a/enterprise/backend/test/metabase_enterprise/advanced_permissions/api/setting_test.clj b/enterprise/backend/test/metabase_enterprise/advanced_permissions/api/setting_test.clj index 2f5cbbd18d5097bd2d2bd5277ec0756c75232bf6..69eb5807bf0b3278e33e52f8c51f554fdff6a46a 100644 --- a/enterprise/backend/test/metabase_enterprise/advanced_permissions/api/setting_test.clj +++ b/enterprise/backend/test/metabase_enterprise/advanced_permissions/api/setting_test.clj @@ -73,8 +73,9 @@ :email-smtp-security :tls :email-smtp-username "munchkin" :email-smtp-password "gobble gobble" - :email-from-address "eating@hungry.com"})))) - + :email-from-address "eating@hungry.com" + :email-from-name "Eating" + :email-reply-to ["reply-to@hungry.com"]})))) (delete-email-setting [user status] (testing (format "delete email setting with %s user" (mt/user-descriptor user)) (mt/user-http-request user :delete status "email"))) diff --git a/frontend/src/metabase/admin/settings/components/SettingsBatchForm.jsx b/frontend/src/metabase/admin/settings/components/SettingsBatchForm.jsx index b96230fbae82cea5a6e5c324868b9ec31b930965..8c41e55c5c629a489b9c4661dc91c4b46d8c95f8 100644 --- a/frontend/src/metabase/admin/settings/components/SettingsBatchForm.jsx +++ b/frontend/src/metabase/admin/settings/components/SettingsBatchForm.jsx @@ -19,6 +19,10 @@ const VALIDATIONS = { validate: value => MetabaseUtils.isEmail(value), message: t`That's not a valid email address`, }, + email_list: { + validate: value => value.every(MetabaseUtils.isEmail), + message: t`That's not a valid list of email addresses`, + }, integer: { validate: value => !isNaN(parseInt(value)), message: t`That's not a valid integer`, diff --git a/frontend/src/metabase/admin/settings/components/SettingsEmailForm.jsx b/frontend/src/metabase/admin/settings/components/SettingsEmailForm.jsx index 604dcade6b4904dfb8072850fc72f80e1ff68c8c..64e5394b0ca58d9e31d756cc77d673b48028a331 100644 --- a/frontend/src/metabase/admin/settings/components/SettingsEmailForm.jsx +++ b/frontend/src/metabase/admin/settings/components/SettingsEmailForm.jsx @@ -74,12 +74,14 @@ class SettingsEmailForm extends Component { render() { const { sendingEmail } = this.state; - + const { elements } = this.props; + const visibleElements = elements.filter(setting => !setting.getHidden?.()); return ( <EmailFormRoot> <SettingsBatchForm ref={form => (this._form = form && form.getWrappedInstance())} {...this.props} + elements={visibleElements} updateSettings={this.props.updateEmailSettings} disable={sendingEmail !== "default"} renderExtraButtons={({ disabled, valid, pristine, submitting }) => ( diff --git a/frontend/src/metabase/admin/settings/components/widgets/SettingCommaDelimitedInput.jsx b/frontend/src/metabase/admin/settings/components/widgets/SettingCommaDelimitedInput.jsx new file mode 100644 index 0000000000000000000000000000000000000000..a254db13a0b27a68036b1942b0e0c184c75d8d18 --- /dev/null +++ b/frontend/src/metabase/admin/settings/components/widgets/SettingCommaDelimitedInput.jsx @@ -0,0 +1,35 @@ +/* eslint-disable react/prop-types */ +import React from "react"; +import InputBlurChange from "metabase/components/InputBlurChange"; +import cx from "classnames"; + +const SettingCommaDelimitedInput = ({ + setting, + onChange, + disabled, + autoFocus, + errorMessage, + fireOnChange, + id, + type = "text", +}) => { + return ( + <InputBlurChange + className={cx("Form-input", { + SettingsInput: true, + "border-error bg-error-input": errorMessage, + })} + id={id} + type={type} + // TOOD: change this to support multiple email addresses + // https://github.com/metabase/metabase/issues/22540 + value={setting.value ? setting.value[0] : ""} + placeholder={setting.placeholder} + onChange={fireOnChange ? e => onChange([e.target.value]) : null} + onBlurChange={!fireOnChange ? e => onChange([e.target.value]) : null} + autoFocus={autoFocus} + /> + ); +}; + +export default SettingCommaDelimitedInput; diff --git a/frontend/src/metabase/admin/settings/selectors.js b/frontend/src/metabase/admin/settings/selectors.js index d4751a1940d7814c6cda63deca4a9b07163a523b..979c379438eb06671ac5eafd39475735305188c9 100644 --- a/frontend/src/metabase/admin/settings/selectors.js +++ b/frontend/src/metabase/admin/settings/selectors.js @@ -4,6 +4,7 @@ import _ from "underscore"; import { createSelector } from "reselect"; import { t, jt } from "ttag"; import ExternalLink from "metabase/core/components/ExternalLink"; +import SettingCommaDelimitedInput from "./components/widgets/SettingCommaDelimitedInput"; import MetabaseSettings from "metabase/lib/settings"; import CustomGeoJSONWidget from "./components/widgets/CustomGeoJSONWidget"; import SettingsLicense from "./components/SettingsLicense"; @@ -155,6 +156,7 @@ const SECTIONS = updateSectionsWithPlugins({ type: "string", required: true, autoFocus: true, + getHidden: () => MetabaseSettings.isHosted(), }, { key: "email-smtp-port", @@ -163,6 +165,7 @@ const SECTIONS = updateSectionsWithPlugins({ type: "number", required: true, validations: [["integer", t`That's not a valid port number`]], + getHidden: () => MetabaseSettings.isHosted(), }, { key: "email-smtp-security", @@ -171,6 +174,7 @@ const SECTIONS = updateSectionsWithPlugins({ type: "radio", options: { none: "None", ssl: "SSL", tls: "TLS", starttls: "STARTTLS" }, defaultValue: "none", + getHidden: () => MetabaseSettings.isHosted(), }, { key: "email-smtp-username", @@ -178,6 +182,7 @@ const SECTIONS = updateSectionsWithPlugins({ description: null, placeholder: "nicetoseeyou", type: "string", + getHidden: () => MetabaseSettings.isHosted(), }, { key: "email-smtp-password", @@ -185,6 +190,14 @@ const SECTIONS = updateSectionsWithPlugins({ description: null, placeholder: "Shhh...", type: "password", + getHidden: () => MetabaseSettings.isHosted(), + }, + { + key: "email-from-name", + display_name: t`From Name`, + placeholder: "Metabase", + type: "string", + required: false, }, { key: "email-from-address", @@ -194,6 +207,15 @@ const SECTIONS = updateSectionsWithPlugins({ required: true, validations: [["email", t`That's not a valid email address`]], }, + { + key: "email-reply-to", + display_name: t`Reply-To Address`, + placeholder: "metabase-replies@yourcompany.com", + type: "string", + required: false, + widget: SettingCommaDelimitedInput, + validations: [["email_list", t`That's not a valid email addresses`]], + }, ], }, slack: { diff --git a/frontend/src/metabase/plugins/builtin/settings/hosted.js b/frontend/src/metabase/plugins/builtin/settings/hosted.js index 609ab7a6f072116b6ee7f773b723aa7fad507a0a..39395fa310bc6b5becab63b4a9944a16e19df637 100644 --- a/frontend/src/metabase/plugins/builtin/settings/hosted.js +++ b/frontend/src/metabase/plugins/builtin/settings/hosted.js @@ -6,9 +6,7 @@ import { PLUGIN_ADMIN_SETTINGS_UPDATES } from "metabase/plugins"; import { SettingsCloudStoreLink } from "../../components/SettingsCloudStoreLink"; if (MetabaseSettings.isHosted()) { - PLUGIN_ADMIN_SETTINGS_UPDATES.push(sections => - _.omit(sections, ["email", "updates"]), - ); + PLUGIN_ADMIN_SETTINGS_UPDATES.push(sections => _.omit(sections, ["updates"])); PLUGIN_ADMIN_SETTINGS_UPDATES.push(sections => updateIn(sections, ["general", "settings"], settings => diff --git a/frontend/test/__support__/e2e/helpers/e2e-email-helpers.js b/frontend/test/__support__/e2e/helpers/e2e-email-helpers.js index 1318c84d341b21236f431fdab296556e9e8c48a3..38f172328ce4e12ea8a131b07fac4248e81343c7 100644 --- a/frontend/test/__support__/e2e/helpers/e2e-email-helpers.js +++ b/frontend/test/__support__/e2e/helpers/e2e-email-helpers.js @@ -20,6 +20,8 @@ export const setupSMTP = () => { "email-smtp-password": "admin", "email-smtp-security": "none", "email-from-address": "mailer@metabase.test", + "email-from-name": "Metabase", + "email-reply-to": ["reply-to@metabase.test"], }); // We must always clear Webmail's inbox before each test diff --git a/frontend/test/metabase/scenarios/admin/settings/email.cy.spec.js b/frontend/test/metabase/scenarios/admin/settings/email.cy.spec.js index c06d77ce17067f0653c99aa64e3f99d82ca01f1c..fd68fae9745f2f54fa301ca47a4f4850517e0667 100644 --- a/frontend/test/metabase/scenarios/admin/settings/email.cy.spec.js +++ b/frontend/test/metabase/scenarios/admin/settings/email.cy.spec.js @@ -23,6 +23,12 @@ describe("scenarios > admin > settings > email settings", () => { cy.findByLabelText("From Address") .type("mailer@metabase.test") .blur(); + cy.findByLabelText("From Name") + .type("Sender Name") + .blur(); + cy.findByLabelText("Reply-To Address") + .type("reply-to@metabase.test") + .blur(); cy.findByText("Save changes").click(); cy.findByText("Changes saved!", { timeout: 10000 }); @@ -32,12 +38,16 @@ describe("scenarios > admin > settings > email settings", () => { cy.findByDisplayValue("25"); cy.findAllByDisplayValue("admin"); cy.findByDisplayValue("mailer@metabase.test"); + cy.findByDisplayValue("Sender Name"); + cy.findByDisplayValue("reply-to@metabase.test"); }); it("should show an error if test email fails", () => { // Reuse Email setup without relying on the previous test cy.request("PUT", "/api/setting", { "email-from-address": "admin@metabase.test", + "email-from-name": "Metabase Admin", + "email-reply-to": "reply-to@metabase.test", "email-smtp-host": "localhost", "email-smtp-password": null, "email-smtp-port": "1234", @@ -66,7 +76,9 @@ describe("scenarios > admin > settings > email settings", () => { cy.findByText("Clear").click(); cy.findByLabelText("SMTP Host").should("have.value", ""); cy.findByLabelText("SMTP Port").should("have.value", ""); + cy.findByLabelText("From Name").should("have.value", ""); cy.findByLabelText("From Address").should("have.value", ""); + cy.findByLabelText("Reply-To Address").should("have.value", ""); }); it("should not offer to save email changes when there aren't any (metabase#14749)", () => { diff --git a/src/metabase/api/email.clj b/src/metabase/api/email.clj index 3678f1fbf04a9054bdc0279ab82b76fac0fab0cc..55745dc22f2c623d0ab8763dcb4882d80111bf99 100644 --- a/src/metabase/api/email.clj +++ b/src/metabase/api/email.clj @@ -19,7 +19,9 @@ :email-smtp-password :pass :email-smtp-port :port :email-smtp-security :security - :email-from-address :sender}) + :email-from-name :sender-name + :email-from-address :sender + :email-reply-to :reply-to}) (defn- humanize-error-messages "Convert raw error message responses from our email functions into our normal api error response structure." diff --git a/src/metabase/email.clj b/src/metabase/email.clj index 807ccebd7c8fcdff288f992c8b9a2652ea509738..c91b8fd98f7769b15fc231027006b617de9cbaf8 100644 --- a/src/metabase/email.clj +++ b/src/metabase/email.clj @@ -16,9 +16,16 @@ ;;; CONFIG (defsetting email-from-address - (deferred-tru "Email address you want to use as the sender of Metabase.") + (deferred-tru "The email address you want to use for the sender of emails.") :default "notifications@metabase.com") +(defsetting email-from-name + (deferred-tru "The name you want to use for the sender of emails.")) + +(defsetting email-reply-to + (deferred-tru "The email address you want the replies to go to, if different from the from address. You can have multiple reply-to email addresses, just separate them with a comma.") + :type :json) + (defsetting email-smtp-host (deferred-tru "The address of the SMTP server that handles your emails.")) @@ -95,14 +102,19 @@ (throw (ex-info (tru "SMTP host is not set.") {:cause :smtp-host-not-set}))) ;; Now send the email (send-email! (smtp-settings) - {:from (email-from-address) - :to recipients - :subject subject - :body (case message-type - :attachments message - :text message - :html [{:type "text/html; charset=utf-8" - :content message}])})) + (merge + {:from (if-let [from-name (email-from-name)] + (str from-name " <" (email-from-address) ">") + (email-from-address)) + :to recipients + :subject subject + :body (case message-type + :attachments message + :text message + :html [{:type "text/html; charset=utf-8" + :content message}])} + (when-let [reply-to (email-reply-to)] + {:reply-to reply-to})))) (def ^:private SMTPStatus "Schema for the response returned by various functions in [[metabase.email]]. Response will be a map with the key @@ -130,13 +142,15 @@ {::error e}))) (def ^:private SMTPSettings - {:host su/NonBlankString - :port su/IntGreaterThanZero - ;; TODO -- not sure which of these other ones are actually required or not, and which are optional. - (s/optional-key :user) (s/maybe s/Str) - (s/optional-key :security) (s/maybe (s/enum :tls :ssl :none :starttls)) - (s/optional-key :pass) (s/maybe s/Str) - (s/optional-key :sender) (s/maybe s/Str)}) + {:host su/NonBlankString + :port su/IntGreaterThanZero + ;; TODO -- not sure which of these other ones are actually required or not, and which are optional. + (s/optional-key :user) (s/maybe s/Str) + (s/optional-key :security) (s/maybe (s/enum :tls :ssl :none :starttls)) + (s/optional-key :pass) (s/maybe s/Str) + (s/optional-key :sender) (s/maybe s/Str) + (s/optional-key :sender-name) (s/maybe s/Str) + (s/optional-key :reply-to) (s/maybe [s/Str])}) (s/defn ^:private test-smtp-settings :- SMTPStatus "Tests an SMTP configuration by attempting to connect and authenticate if an authenticated method is passed diff --git a/test/metabase/api/email_test.clj b/test/metabase/api/email_test.clj index 369eb4dce47b05dcf56e24c302cfe8b5638886f0..d55c7a02a2a9be67bc7441addc3390cad030aa38 100644 --- a/test/metabase/api/email_test.clj +++ b/test/metabase/api/email_test.clj @@ -22,7 +22,9 @@ :email-smtp-security (setting/get :email-smtp-security) :email-smtp-username (setting/get :email-smtp-username) :email-smtp-password (setting/get :email-smtp-password) - :email-from-address (setting/get :email-from-address)}) + :email-from-address (setting/get :email-from-address) + :email-from-name (setting/get :email-from-name) + :email-reply-to (setting/get :email-reply-to)}) (def ^:private default-email-settings {:email-smtp-host "foobar" @@ -30,11 +32,15 @@ :email-smtp-security :tls :email-smtp-username "munchkin" :email-smtp-password "gobble gobble" - :email-from-address "eating@hungry.com"}) + :email-from-address "eating@hungry.com" + :email-from-name "Eating" + :email-reply-to ["reply-to@hungry.com"]}) (deftest test-email-settings-test (testing "POST /api/email/test -- send a test email" - (mt/with-temporary-setting-values [email-from-address "notifications@metabase.com"] + (mt/with-temporary-setting-values [email-from-address "notifications@metabase.com" + email-from-name "Sender Name" + email-reply-to ["reply-to@metabase.com"]] (mt/with-fake-inbox (testing "Non-admin -- request should fail" (is (= "You don't have permissions to do that." @@ -44,10 +50,11 @@ (is (= {:ok true} (mt/user-http-request :crowberto :post 200 "email/test"))) (is (= {"crowberto@metabase.com" - [{:from "notifications@metabase.com", - :to ["crowberto@metabase.com"], - :subject "Metabase Test Email", - :body "Your Metabase emails are working — hooray!"}]} + [{:from "Sender Name <notifications@metabase.com>", + :to ["crowberto@metabase.com"], + :reply-to ["reply-to@metabase.com"] + :subject "Metabase Test Email", + :body "Your Metabase emails are working — hooray!"}]} @mt/inbox)))))) (deftest update-email-settings-test @@ -66,7 +73,7 @@ (with-redefs [email/retry-delay-ms 0] (thunk)))}] (tu/discard-setting-changes [email-smtp-host email-smtp-port email-smtp-security email-smtp-username - email-smtp-password email-from-address] + email-smtp-password email-from-address email-from-name email-reply-to] (testing (format "SMTP connection is valid? %b\n" success?) (f (fn [] (testing "API request" @@ -88,7 +95,7 @@ (deftest clear-email-settings-test (testing "DELETE /api/email" (tu/discard-setting-changes [email-smtp-host email-smtp-port email-smtp-security email-smtp-username - email-smtp-password email-from-address] + email-smtp-password email-from-address email-from-name email-reply-to] (with-redefs [email/test-smtp-settings (constantly {::email/error nil})] (is (= (-> default-email-settings (assoc :with-corrections {}) @@ -97,11 +104,13 @@ (let [new-email-settings (email-settings)] (is (nil? (mt/user-http-request :crowberto :delete 204 "email"))) (is (= default-email-settings - new-email-settings)) + new-email-settings)) (is (= {:email-smtp-host nil :email-smtp-port nil :email-smtp-security :none :email-smtp-username nil :email-smtp-password nil - :email-from-address "notifications@metabase.com"} + :email-from-address "notifications@metabase.com" + :email-from-name nil + :email-reply-to nil} (email-settings)))))))) diff --git a/test/metabase/email_test.clj b/test/metabase/email_test.clj index 96f31ce183ef6853f51db45f1a4db175ef27cdf4..c9493e182e9a04df7340652c6035d27fd38f8935 100644 --- a/test/metabase/email_test.clj +++ b/test/metabase/email_test.clj @@ -209,25 +209,44 @@ (deftest send-message!-test (tu/with-temporary-setting-values [email-from-address "lucky@metabase.com" + email-from-name "Lucky" email-smtp-host "smtp.metabase.com" email-smtp-username "lucky" email-smtp-password "d1nner3scapee!" email-smtp-port 1025 + email-reply-to ["reply-to-me@metabase.com" "reply-to-me-too@metabase.com"] email-smtp-security :none] (testing "basic sending" (is (= - [{:from (email/email-from-address) - :to ["test@test.com"] - :subject "101 Reasons to use Metabase" - :body [{:type "text/html; charset=utf-8" - :content "101. Metabase will make you a better person"}]}] + [{:from (str (email/email-from-name) " <" (email/email-from-address) ">") + :to ["test@test.com"] + :subject "101 Reasons to use Metabase" + :reply-to (email/email-reply-to) + :body [{:type "text/html; charset=utf-8" + :content "101. Metabase will make you a better person"}]}] (with-fake-inbox (email/send-message! - :subject "101 Reasons to use Metabase" - :recipients ["test@test.com"] - :message-type :html - :message "101. Metabase will make you a better person") + :subject "101 Reasons to use Metabase" + :recipients ["test@test.com"] + :message-type :html + :message "101. Metabase will make you a better person") (@inbox "test@test.com"))))) + (testing "basic sending without email-from-name" + (tu/with-temporary-setting-values [email-from-name nil] + (is (= + [{:from (email/email-from-address) + :to ["test@test.com"] + :subject "101 Reasons to use Metabase" + :reply-to (email/email-reply-to) + :body [{:type "text/html; charset=utf-8" + :content "101. Metabase will make you a better person"}]}] + (with-fake-inbox + (email/send-message! + :subject "101 Reasons to use Metabase" + :recipients ["test@test.com"] + :message-type :html + :message "101. Metabase will make you a better person") + (@inbox "test@test.com")))))) (testing "with an attachment" (let [recipient "csv_user@example.com" csv-contents "hugs_with_metabase,hugs_without_metabase\n1,0" @@ -235,25 +254,26 @@ params {:subject "101 Reasons to use Metabase" :recipients [recipient] :message-type :attachments - :message [{:type "text/html; charset=utf-8" + :message [{:type "text/html; charset=utf-8" :content "100. Metabase will hug you when you're sad"} - {:type :attachment + {:type :attachment :content-type "text/csv" - :file-name "metabase-reasons.csv" - :content csv-file - :description "very scientific data"}]}] + :file-name "metabase-reasons.csv" + :content csv-file + :description "very scientific data"}]}] (testing "it sends successfully" (is (= - [{:from (email/email-from-address) - :to [recipient] - :subject "101 Reasons to use Metabase" - :body [{:type "text/html; charset=utf-8" - :content "100. Metabase will hug you when you're sad"} - {:type :attachment - :content-type "text/csv" - :file-name "metabase-reasons.csv" - :content csv-file - :description "very scientific data"}]}] + [{:from (str (email/email-from-name) " <" (email/email-from-address) ">") + :to [recipient] + :subject "101 Reasons to use Metabase" + :reply-to (email/email-reply-to) + :body [{:type "text/html; charset=utf-8" + :content "100. Metabase will hug you when you're sad"} + {:type :attachment + :content-type "text/csv" + :file-name "metabase-reasons.csv" + :content csv-file + :description "very scientific data"}]}] (with-fake-inbox (m/mapply email/send-message! params) (@inbox recipient)))))