Convenience functions for sending templated email messages. Each function here should represent a single email. NOTE: we want to keep this about email formatting, so don't put heavy logic here RE: building data for emails.

NOTE: This namespace is deprecated, all of these emails will soon be converted to System Email Notifications.

(ns metabase.channel.email.messages
  (:require
   [buddy.core.codecs :as codecs]
   [java-time.api :as t]
   [medley.core :as m]
   [metabase.channel.email :as email]
   [metabase.channel.render.core :as channel.render]
   [metabase.channel.template.core :as channel.template]
   [metabase.db.query :as mdb.query]
   [metabase.driver :as driver]
   [metabase.lib.util :as lib.util]
   [metabase.models.collection :as collection]
   [metabase.models.data-permissions :as data-perms]
   [metabase.models.permissions :as perms]
   [metabase.premium-features.core :as premium-features]
   [metabase.public-settings :as public-settings]
   [metabase.query-processor.timezone :as qp.timezone]
   [metabase.util :as u]
   [metabase.util.date-2 :as u.date]
   [metabase.util.encryption :as encryption]
   [metabase.util.i18n :as i18n :refer [trs tru]]
   [metabase.util.json :as json]
   [metabase.util.log :as log]
   [metabase.util.malli :as mu]
   [metabase.util.urls :as urls]
   [toucan2.core :as t2])
  (:import
   (java.time LocalTime)
   (java.time.format DateTimeFormatter)))
(set! *warn-on-reflection* true)

Return the user configured application name, or Metabase translated via trs if a name isn't configured.

(defn app-name-trs
  []
  (or (public-settings/application-name)
      (trs "Metabase")))

Return the URL for the application logo. If the logo is the default, return a URL to the Metabase logo.

(defn logo-url
  []
  (let [url (public-settings/application-logo-url)]
    (cond
      (= url "app/assets/img/logo.svg") "http://static.metabase.com/email_logo.png"
      :else nil)))

NOTE: disabling whitelabeled URLs for now since some email clients don't render them correctly We need to extract them and embed as attachments like we do in metabase.channel.render.image-bundle (data-uri-svg? url) (themed-image-url url color) :else url

Return a CSS style string for a button with the given color.

(defn button-style
  [color]
  (str "display: inline-block; "
       "box-sizing: border-box; "
       "padding: 0.5rem 1.375rem; "
       "font-size: 1.063rem; "
       "font-weight: bold; "
       "text-decoration: none; "
       "cursor: pointer; "
       "color: #fff; "
       "border: 1px solid " color "; "
       "background-color: " color "; "
       "border-radius: 4px;"))

Various Context Helper Fns. Used to build Stencil template context

Context that is used across multiple email templates, and that is the same for all emails

(defn common-context
  []
  {:applicationName           (public-settings/application-name)
   :applicationColor          (channel.render/primary-color)
   :applicationLogoUrl        (logo-url)
   :buttonStyle               (button-style (channel.render/primary-color))
   :colorTextLight            channel.render/color-text-light
   :colorTextMedium           channel.render/color-text-medium
   :colorTextDark             channel.render/color-text-dark
   :siteUrl                   (public-settings/site-url)})

Public Interface

Return a sequence of email addresses for all Admin users.

The first recipient will be the site admin (or oldest admin if unset), which is the address that should be used in mailto links (e.g., for the new user to email with any questions).

(defn- all-admin-recipients
  []
  (concat (when-let [admin-email (public-settings/admin-email)]
            [admin-email])
          (t2/select-fn-set :email 'User, :is_superuser true, :is_active true, {:order-by [[:id :asc]]})))

Send an email to the invitor (the Admin who invited new-user) letting them know new-user has joined.

(defn send-user-joined-admin-notification-email!
  [new-user & {:keys [google-auth?]}]
  {:pre [(map? new-user)]}
  (let [recipients (all-admin-recipients)]
    (email/send-message!
     {:subject      (str (if google-auth?
                           (trs "{0} created a {1} account" (:common_name new-user) (app-name-trs))
                           (trs "{0} accepted their {1} invite" (:common_name new-user) (app-name-trs))))
      :recipients   recipients
      :message-type :html
      :message      (channel.template/render "metabase/channel/email/user_joined_notification.hbs"
                                             (merge (common-context)
                                                    {:logoHeader        true
                                                     :joinedUserName    (or (:first_name new-user) (:email new-user))
                                                     :joinedViaSSO      google-auth?
                                                     :joinedUserEmail   (:email new-user)
                                                     :joinedDate        (t/format "EEEE, MMMM d" (t/zoned-date-time)) ; e.g. "Wednesday, July 13".
                                                     :adminEmail        (first recipients)
                                                     :joinedUserEditUrl (str (public-settings/site-url) "/admin/people")}))})))

Format and send an email informing the user how to reset their password.

(defn send-password-reset-email!
  [email sso-source password-reset-url is-active?]
  {:pre [(u/email? email)
         ((some-fn string? nil?) password-reset-url)]}
  (let [google-sso? (= "google" sso-source)
        message-body (channel.template/render
                      "metabase/channel/email/password_reset.hbs"
                      (merge (common-context)
                             {:emailType        "password_reset"
                              :google           google-sso?
                              :nonGoogleSSO     (and (not google-sso?) (some? sso-source))
                              :passwordResetUrl password-reset-url
                              :logoHeader       true
                              :isActive         is-active?
                              :adminEmail       (public-settings/admin-email)
                              :adminEmailSet    (boolean (public-settings/admin-email))}))]
    (email/send-message!
     {:subject      (trs "[{0}] Password Reset Request" (app-name-trs))
      :recipients   [email]
      :message-type :html
      :message      message-body})))

Format and send an email informing the user that this is the first time we've seen a login from this device. Expects login history information as returned by metabase.models.login-history/human-friendly-infos.

(mu/defn send-login-from-new-device-email!
  [{user-id :user_id, :keys [timestamp], :as login-history} :- [:map [:user_id pos-int?]]]
  (let [user-info    (or (t2/select-one ['User [:first_name :first-name] :email :locale] :id user-id)
                         (throw (ex-info (tru "User {0} does not exist" user-id)
                                         {:user-id user-id, :status-code 404})))
        user-locale  (or (:locale user-info) (i18n/site-locale))
        timestamp    (u.date/format-human-readable timestamp user-locale)
        context      (merge (common-context)
                            {:first-name (:first-name user-info)
                             :device     (:device_description login-history)
                             :location   (:location login-history)
                             :timestamp  timestamp})
        message-body (channel.template/render "metabase/channel/email/login_from_new_device.hbs"
                                              context)]
    (email/send-message!
     {:subject      (trs "We''ve Noticed a New {0} Login, {1}" (app-name-trs) (:first-name user-info))
      :recipients   [(:email user-info)]
      :message-type :html
      :message      message-body})))

Find emails for users that have an interest in monitoring the database. If oss that means admin users. If ee that also means users with monitoring and details permissions.

(defn- admin-or-ee-monitoring-details-emails
  [database-id]
  (let [monitoring (perms/application-perms-path :monitoring)
        user-ids-with-monitoring (when (premium-features/enable-advanced-permissions?)
                                   (->> {:select   [:pgm.user_id]
                                         :from     [[:permissions_group_membership :pgm]]
                                         :join     [[:permissions_group :pg] [:= :pgm.group_id :pg.id]]
                                         :where    [:and
                                                    [:exists {:select [1]
                                                              :from [[:permissions :p]]
                                                              :where [:and
                                                                      [:= :p.group_id :pg.id]
                                                                      [:= :p.object monitoring]]}]]
                                         :group-by [:pgm.user_id]}
                                        mdb.query/query
                                        (mapv :user_id)))
        user-ids (filter
                  #(data-perms/user-has-permission-for-database? % :perms/manage-database :yes database-id)
                  user-ids-with-monitoring)]
    (into
     []
     (distinct)
     (concat
      (all-admin-recipients)
      (when (seq user-ids)
        (t2/select-fn-set :email :model/User {:where [:and
                                                      [:= :is_active true]
                                                      [:in :id user-ids]]}))))))

Format and send an email informing the user about errors in the persistent model refresh task.

(defn send-persistent-model-error-email!
  [database-id persisted-infos trigger]
  {:pre [(seq persisted-infos)]}
  (let [database (:database (first persisted-infos))
        emails (admin-or-ee-monitoring-details-emails database-id)
        timezone (some-> database qp.timezone/results-timezone-id t/zone-id)
        context {:database-name (:name database)
                 :errors
                 (for [[idx persisted-info] (m/indexed persisted-infos)
                       :let [card (:card persisted-info)
                             collection (or (:collection card)
                                            (collection/root-collection-with-ui-details nil))]]
                   {:is-not-first (not= 0 idx)
                    :error (:error persisted-info)
                    :card-id (:id card)
                    :card-name (:name card)
                    :collection-name (:name collection)
                    ;; February 1, 2022, 3:10 PM
                    :last-run-at (t/format "MMMM d, yyyy, h:mm a z" (t/zoned-date-time (:refresh_begin persisted-info) timezone))
                    :last-run-trigger trigger
                    :card-url (urls/card-url (:id card))
                    :collection-url (urls/collection-url (:id collection))
                    :caching-log-details-url (urls/tools-caching-details-url (:id persisted-info))})}
        message-body (channel.template/render "metabase/channel/email/persisted-model-error.hbs"
                                              (merge (common-context) context))]
    (when (seq emails)
      (email/send-message!
       {:subject      (trs "[{0}] Model cache refresh failed for {1}" (app-name-trs) (:name database))
        :recipients   (vec emails)
        :message-type :html
        :message      message-body}))))

Format and send an email to the system admin following up on the installation.

(defn send-follow-up-email!
  [email]
  {:pre [(u/email? email)]}
  (let [context (merge (common-context)
                       {:emailType    "notification"
                        :logoHeader   true
                        :heading      (trs "We hope you''ve been enjoying Metabase.")
                        :callToAction (trs "Would you mind taking a quick 5 minute survey to tell us how it’s going?")
                        :link         "https://metabase.com/feedback/active"})
        email {:subject      (trs "[{0}] Tell us how things are going." (app-name-trs))
               :recipients   [email]
               :message-type :html
               :message      (channel.template/render "metabase/channel/email/follow_up_email.hbs" context)}]
    (email/send-message! email)))

Format and send an email to a creator with a link to a survey. If a [[blob]] is included, it will be turned into json and then base64 encoded.

(defn send-creator-sentiment-email!
  [{:keys [email first_name]} blob]
  {:pre [(u/email? email)]}
  (let [encoded-info    (when blob
                          (-> blob
                              json/encode
                              .getBytes
                              codecs/bytes->b64-str))
        context (merge (common-context)
                       {:emailType  "notification"
                        :logoHeader true
                        :first-name first_name
                        :link       (cond-> "https://metabase.com/feedback/creator"
                                      encoded-info (str "?context=" encoded-info))}
                       (when-not (premium-features/is-hosted?)
                         {:self-hosted (public-settings/site-url)}))
        message {:subject      "Metabase would love your take on something"
                 :recipients   [email]
                 :message-type :html
                 :message      (channel.template/render "metabase/channel/email/creator_sentiment_email.hbs" context)}]
    (email/send-message! message)))

Generates hash to allow for non-users to unsubscribe from pulses/subscriptions.

(defn generate-pulse-unsubscribe-hash
  [pulse-id email]
  (codecs/bytes->hex
   (encryption/validate-and-hash-secret-key
    (json/encode {:salt     (public-settings/site-uuid-for-unsubscribing-url)
                  :email    email
                  :pulse-id pulse-id}))))

Given an alert return a keyword representing what kind of goal needs to be met.

(defn pulse->alert-condition-kwd
  [{:keys [alert_above_goal alert_condition] :as _alert}]
  (if (= "goal" alert_condition)
    (if (true? alert_above_goal)
      :meets
      :below)
    :rows))

Alerts only have a single card, so the alerts API accepts a :card key, while pulses have :cards. Depending on whether the data comes from the alert API or pulse tasks, the card could be under :card or :cards

(defn- first-card
  [alert]
  (or (:card alert)
      (first (:cards alert))))

Template context that is applicable to all alert templates, including alert management templates (e.g. the subscribed/unsubscribed emails)

(defn common-alert-context
  ([alert]
   (common-alert-context alert nil))
  ([alert alert-condition-map]
   (let [{card-id :id, card-name :name} (first-card alert)]
     (merge (common-context)
            {:emailType                 "alert"
             :questionName              card-name
             :questionURL               (urls/card-url card-id)
             :sectionStyle              (channel.render/section-style)}
            (when alert-condition-map
              {:alertCondition (get alert-condition-map (pulse->alert-condition-kwd alert))})))))
(defn- schedule-hour-text
  [{hour :schedule_hour}]
  (.format (LocalTime/of hour 0)
           (DateTimeFormatter/ofPattern "h a")))
(defn- schedule-day-text
  [{day :schedule_day}]
  (get {"sun" "Sunday"
        "mon" "Monday"
        "tue" "Tuesday"
        "wed" "Wednesday"
        "thu" "Thursday"
        "fri" "Friday"
        "sat" "Saturday"}
       day))
(defn- schedule-timezone
  []
  (or (driver/report-timezone) "UTC"))

Returns a string that describes the run schedule of an alert (i.e. how often results are checked), for inclusion in the email template. Not translated, since emails in general are not currently translated.

(defn alert-schedule-text
  [channel]
  (case (keyword (:schedule_type channel))
    :hourly
    "Run hourly"
    :daily
    (format "Run daily at %s %s"
            (schedule-hour-text channel)
            (schedule-timezone))
    :weekly
    (format "Run weekly on %s at %s %s"
            (schedule-day-text channel)
            (schedule-hour-text channel)
            (schedule-timezone))))

A map of alert conditions to their corresponding text.

(def alert-condition-text
  {:meets "when this question meets its goal"
   :below "when this question goes below its goal"
   :rows  "whenever this question has any results"})

Sends an email on a background thread, returning a future.

(defn- send-email!
  [user subject template-path template-context]
  (future
    (try
      (email/send-email-retrying!
       {:recipients   [(:email user)]
        :message-type :html
        :subject      subject
        :message      (channel.template/render template-path template-context)})
      (catch Exception e
        (log/errorf e "Failed to send message to '%s' with subject '%s'" (:email user) subject)))))
(defn- template-path [template-name]
  (str "metabase/channel/email/" template-name ".hbs"))

Paths to the templates for all of the alerts emails

(def ^:private you-unsubscribed-template   (template-path "alert_unsubscribed"))
(def ^:private admin-unsubscribed-template (template-path "alert_admin_unsubscribed_you"))
(def ^:private added-template              (template-path "alert_you_were_added"))
(def ^:private stopped-template            (template-path "alert_stopped_working"))
(def ^:private archived-template           (template-path "alert_archived"))

Send an email to who-unsubscribed letting them know they've unsubscribed themselves from alert

(defn send-you-unsubscribed-alert-email!
  [alert who-unsubscribed]
  (send-email! who-unsubscribed "You unsubscribed from an alert" you-unsubscribed-template
               (common-alert-context alert)))

Send an email to user-added letting them know admin has unsubscribed them from alert

(defn send-admin-unsubscribed-alert-email!
  [alert user-added {:keys [first_name last_name] :as _admin}]
  (let [admin-name (format "%s %s" first_name last_name)]
    (send-email! user-added "You’ve been unsubscribed from an alert" admin-unsubscribed-template
                 (assoc (common-alert-context alert) :adminName admin-name))))

Send an email to user-added letting them know admin-adder has added them to alert

(defn send-you-were-added-alert-email!
  [alert user-added {:keys [first_name last_name] :as _admin-adder}]
  (let [subject (format "%s %s added you to an alert" first_name last_name)]
    (send-email! user-added subject added-template (common-alert-context alert alert-condition-text))))
(def ^:private not-working-subject "One of your alerts has stopped working")

Email to notify users when a card associated to their alert has been archived

Email to notify users when a card associated to their alert changed in a way that invalidates their alert

(defn send-alert-stopped-because-archived-email!
  [alert user {:keys [first_name last_name] :as _archiver}]
  (let [{card-id :id card-name :name} (first-card alert)]
    (send-email! user not-working-subject archived-template {:archiveURL   (urls/archive-url)
                                                             :questionName (format "%s (#%d)" card-name card-id)
                                                             :archiverName (format "%s %s" first_name last_name)})))
(defn send-alert-stopped-because-changed-email!
  [alert user {:keys [first_name last_name] :as _archiver}]
  (let [edited-text (format "the question was edited by %s %s" first_name last_name)]
    (send-email! user not-working-subject stopped-template (assoc (common-alert-context alert) :deletionCause edited-text))))

Email dashboard and subscription creators information about a broken subscription due to bad parameters

(defn send-broken-subscription-notification!
  [{:keys [dashboard-id dashboard-name pulse-creator dashboard-creator affected-users bad-parameters]}]
  (let [{:keys [siteUrl] :as context} (common-context)]
    (email/send-message!
     :subject (trs "Subscription to {0} removed" dashboard-name)
     :recipients (distinct (map :email [pulse-creator dashboard-creator]))
     :message-type :html
     :message (channel.template/render
               "metabase/channel/email/broken_subscription_notification.hbs"
               (merge context
                      {:dashboardName            dashboard-name
                       :badParameters            (map
                                                  (fn [{:keys [value] :as param}]
                                                    (cond-> param
                                                      (coll? value)
                                                      (update :value #(lib.util/join-strings-with-conjunction
                                                                       (i18n/tru "or")
                                                                       %))))
                                                  bad-parameters)
                       :affectedUsers            (map
                                                  (fn [{:keys [notification-type] :as m}]
                                                    (cond-> m
                                                      notification-type
                                                      (update :notification-type name)))
                                                  (into
                                                   [{:notification-type :email
                                                     :recipient         (:common_name dashboard-creator)
                                                     :role              "Dashboard Creator"}
                                                    {:notification-type :email
                                                     :recipient         (:common_name pulse-creator)
                                                     :role              "Subscription Creator"}]
                                                   (map #(assoc % :role "Subscriber") affected-users)))
                       :dashboardUrl             (format "%s/dashboard/%s" siteUrl dashboard-id)})))))