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]
   [clojure.string :as str]
   [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.permissions.core :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.cron :as u.cron]
   [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.login-history.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
                  #(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.

Deprecated: only used for dashboard subscriptions for now, should be migrated to generate-notification-unsubscribe-hash once we migrate all the dashboard subscriptions to the new notification system.

(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}))))

Generates hash to allow for non-users to unsubscribe from notifications.

(defn generate-notification-unsubscribe-hash
  [notification-id email]
  (codecs/bytes->hex
   (encryption/validate-and-hash-secret-key
    (json/encode {:salt            (public-settings/site-uuid-for-unsubscribing-url)
                  :email           email
                  :notification-id notification-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"))

Given cron notification subscription return a human-readable description of the schedule.

(defn notification-card-schedule-text
  [{:keys [cron_schedule type] :as _subscription}]
  (when (= :notification-subscription/cron type)
    ;; TODO consider using https://github.com/grahamar/cron-parser
    (let [schedule (u.cron/cron-string->schedule-map cron_schedule)]
      (case (keyword (:schedule_type schedule))
        :hourly
        "Run hourly"
        :daily
        (format "Run daily at %s %s"
                (schedule-hour-text schedule)
                (schedule-timezone))
        :weekly
        (format "Run weekly on %s at %s %s"
                (schedule-day-text schedule)
                (schedule-hour-text schedule)
                (schedule-timezone))))))

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

(defn- send-email!
  ([recipients subject template-path template-context]
   (send-email! recipients subject template-path template-context false))
  ([recipients subject template-path template-context bcc?]
   (when (seq recipients)
     (future
       (try
         (email/send-email-retrying!
          {:recipients   recipients
           :message-type :html
           :subject      subject
           :message      (channel.template/render template-path template-context)
           :bcc?         bcc?})
         (catch Exception e
           (log/errorf e "Failed to send message to '%s' with subject '%s'" (str/join ", " recipients) 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 "notification_card_unsubscribed"))
(def ^:private removed-template            (template-path "notification_card_you_were_removed"))
(def ^:private added-template              (template-path "notification_card_you_were_added"))
(def ^:private changed-stopped-template    (template-path "card_notification_changed_stopped"))
(def ^:private archived-template           (template-path "card_notification_archived"))
(defn- username
  [user]
  (->> [(:first_name user) (:last_name user)]
       (remove nil?)
       (str/join " ")))

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

(defn send-you-unsubscribed-notification-card-email!
  [notification unsubscribed-emails]
  (send-email! unsubscribed-emails "You unsubscribed from an alert" you-unsubscribed-template notification true))

Send an email to removed-users letting them know admin has removed them from notification

(defn send-you-were-removed-notification-card-email!
  [notification removed-emails actor]
  (send-email! removed-emails "You’ve been unsubscribed from an alert" removed-template (assoc notification :actor_name (username actor)) true))

Send an email to added-users letting them know admin-adder has added them to notification

(defn send-you-were-added-card-notification-email!
  [notification added-user-emails adder]
  (let [subject (format "%s added you to an alert" (username adder))]
    (send-email! added-user-emails subject added-template notification true)))
(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

(defn send-alert-stopped-because-archived-email!
  [card recipient-emails archiver]
  (send-email! recipient-emails not-working-subject archived-template
               {:card card
                :actor archiver}
               true))

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

(defn send-alert-stopped-because-changed-email!
  [card recipient-emails archiver]
  (send-email! recipient-emails not-working-subject changed-stopped-template
               {:card card
                :actor archiver}
               true))

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)})))))