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