(ns metabase.channel.impl.email (:require [clojure.string :as str] [hiccup.core :refer [html]] [medley.core :as m] [metabase.channel.core :as channel] [metabase.channel.email :as email] [metabase.channel.email.messages :as messages] [metabase.channel.email.result-attachment :as email.result-attachment] [metabase.channel.models.channel :as models.channel] [metabase.channel.params :as channel.params] [metabase.channel.render.core :as channel.render] [metabase.channel.shared :as channel.shared] [metabase.channel.template.handlebars :as handlebars] [metabase.models.notification :as models.notification] [metabase.models.params.shared :as shared.params] [metabase.public-settings :as public-settings] [metabase.util :as u] [metabase.util.i18n :refer [trs]] [metabase.util.log :as log] [metabase.util.malli :as mu] [metabase.util.malli.schema :as ms] [metabase.util.markdown :as markdown] [metabase.util.ui-logic :as ui-logic] [metabase.util.urls :as urls] [ring.util.codec :as codec])) | |
(def ^:private EmailMessage
[:map
[:subject :string]
[:recipients [:sequential ms/Email]]
[:message-type [:enum :attachments :html :text]]
[:message :any]
[:recipient-type {:optional true} [:maybe (ms/enum-keywords-and-strings :cc :bcc)]]]) | |
(mu/defmethod channel/send! :channel/email
[_channel {:keys [subject recipients message-type message recipient-type]} :- EmailMessage]
(email/send-message-or-throw! {:subject subject
:recipients recipients
:message-type message-type
:message message
:bcc? (if recipient-type
(= :bcc recipient-type)
(email/bcc-enabled?))})) | |
------------------------------------------------------------------------------------------------;; Render Utils ;; ------------------------------------------------------------------------------------------------;; | |
(defn- notification-unsubscribe-url-for-non-user
[notification-handler-id non-user-email]
(str (urls/unsubscribe-url)
"?"
(codec/form-encode {:hash (messages/generate-notification-unsubscribe-hash notification-handler-id non-user-email)
:email non-user-email
:notification-handler-id notification-handler-id}))) | |
Given a | (defn- pulse-unsubscribe-url-for-non-user
[dashboard-subscription-id non-user-email]
(when dashboard-subscription-id
(str (urls/unsubscribe-url)
"?"
(codec/form-encode {:hash (messages/generate-pulse-unsubscribe-hash dashboard-subscription-id non-user-email)
:email non-user-email
:pulse-id dashboard-subscription-id}))))
(defn- render-part
[timezone part options]
(case (:type part)
:card
(channel.render/render-pulse-section timezone (channel.shared/realize-data-rows part) options)
:text
{:content (markdown/process-markdown (:text part) :html)}
:tab-title
{:content (markdown/process-markdown (format "# %s\n---" (:text part)) :html)})) |
(defn- render-body
[{:keys [details] :as _template} payload]
(case (keyword (:type details))
:email/handlebars-resource
(handlebars/render (:path details) payload)
:email/handlebars-text
(handlebars/render-string (:body details) payload)
(do
(log/warnf "Unknown email template type: %s" (:type details))
nil))) | |
(defn- render-message-body
[template message-context attachments]
(vec (concat [{:type "text/html; charset=utf-8" :content (render-body template message-context)}] attachments))) | |
(defn- make-message-attachment [[content-id url]]
{:type :inline
:content-id content-id
:content-type "image/png"
:content url}) | |
(defn- assoc-attachment-booleans [part-configs parts]
(for [{{result-card-id :id} :card :as result} parts
;; TODO: check if does this match by dashboard_card_id or card_id?
:let [noti-dashcard (m/find-first #(= (:card_id %) result-card-id) part-configs)]]
(if result-card-id
(update result :card merge (select-keys noti-dashcard [:include_csv :include_xls :format_rows :pivot_results]))
result))) | |
Bundle an icon. The available icons are defined in [[render.js.svg/icon-paths]]. | (defn- icon-bundle
[icon-name]
(let [color (channel.render/primary-color)
png-bytes (channel.render/icon icon-name color)]
(-> (channel.render/make-image-bundle :attachment png-bytes)
(channel.render/image-bundle->attachment)))) |
(defn- construct-email
([subject recipients message]
(construct-email subject recipients message nil))
([subject recipients message recipient-type]
{:subject subject
:recipients recipients
:message-type :attachments
:message message
:recipient-type recipient-type})) | |
(defn- recipients->emails
[recipients]
(update-vals
{:user-emails (mapv (comp :email :user) (filter #(= :notification-recipient/user (:type %)) recipients))
:non-user-emails (mapv (comp :value :details) (filter #(= :notification-recipient/raw-value (:type %)) recipients))}
#(filter u/email? %))) | |
(defn- construct-emails
[template message-context-fn attachments recipients]
(let [{:keys [user-emails
non-user-emails]} (recipients->emails recipients)
email-to-users (when (seq user-emails)
(let [message-ctx (message-context-fn nil)]
(construct-email
(channel.params/substitute-params (-> template :details :subject) message-ctx)
user-emails
(render-message-body template (message-context-fn nil) attachments))))
email-to-nonusers (for [non-user-email non-user-emails]
(let [message-ctx (message-context-fn non-user-email)]
(construct-email
(channel.params/substitute-params (-> template :details :subject) message-ctx)
[non-user-email]
(render-message-body template (message-context-fn non-user-email) attachments))))]
(filter some? (conj email-to-nonusers email-to-users)))) | |
(def ^:private payload-type->default-template
{:notification/dashboard {:channel_type :channel/email
:details {:type :email/handlebars-resource
:subject "{{payload.dashboard.name}}"
:path "metabase/channel/email/dashboard_subscription.hbs"}}
:notification/card {:channel_type :channel/email
:details {:type :email/handlebars-resource
:subject "{{computed.subject}}"
:path "metabase/channel/email/notification_card.hbs"}}}) | |
------------------------------------------------------------------------------------------------;; Notification Card ;; ------------------------------------------------------------------------------------------------;; | |
(mu/defmethod channel/render-notification [:channel/email :notification/card] :- [:sequential EmailMessage]
[_channel-type {:keys [payload payload_type] :as notification-payload} template recipients]
(let [{:keys [card_part
notification_card
subscriptions
card]} payload
template (or template (payload-type->default-template payload_type))
timezone (channel.render/defaulted-timezone card)
rendered-card (render-part timezone card_part {:channel.render/include-title? true})
icon-attachment (apply make-message-attachment (icon-bundle :bell))
card-attachments (map make-message-attachment (:attachments rendered-card))
result-attachments (email.result-attachment/result-attachment
(first (assoc-attachment-booleans
[(assoc notification_card :include_csv true :format_rows true)]
[card_part])))
attachments (concat [icon-attachment] card-attachments result-attachments)
html-content (html (:content rendered-card))
goal (ui-logic/find-goal-value payload)
message-context-fn (fn [non-user-email]
(assoc notification-payload
:computed {:subject (case (keyword (:send_condition notification_card))
:goal_above (trs "Alert: {0} has reached its goal" (:name card))
:goal_below (trs "Alert: {0} has gone below its goal" (:name card))
:has_result (trs "Alert: {0} has results" (:name card)))
:icon_cid (:content-id icon-attachment)
:content html-content
;; UI only allow one subscription per card notification
:alert_schedule (messages/notification-card-schedule-text (first subscriptions))
:goal_value goal
:management_text (if (nil? non-user-email)
"Manage your subscriptions"
"Unsubscribe")
:management_url (if (nil? non-user-email)
(urls/notification-management-url)
(let [email-handler-id (:notification_handler_id
(m/find-first #(= non-user-email (-> % :details :value)) recipients))]
(notification-unsubscribe-url-for-non-user email-handler-id non-user-email)))}))]
(construct-emails template message-context-fn attachments recipients))) | |
------------------------------------------------------------------------------------------------;; Dashboard Subscriptions ;; ------------------------------------------------------------------------------------------------;; | |
(defn- render-filters
[parameters]
(let [cells (map
(fn [filter]
[:td {:class "filter-cell"
:style (channel.render/style {:width "50%"
:padding "0px"
:vertical-align "baseline"})}
[:table {:cellpadding "0"
:cellspacing "0"
:width "100%"
:height "100%"}
[:tr
[:td
{:style (channel.render/style {:color channel.render/color-text-medium
:min-width "100px"
:width "50%"
:padding "4px 4px 4px 0"
:vertical-align "baseline"})}
(:name filter)]
[:td
{:style (channel.render/style {:color channel.render/color-text-dark
:min-width "100px"
:width "50%"
:padding "4px 16px 4px 8px"
:vertical-align "baseline"})}
(shared.params/value-string filter (public-settings/site-locale))]]]])
parameters)
rows (partition-all 2 cells)]
(html
[:table {:style (channel.render/style {:table-layout :fixed
:border-collapse :collapse
:cellpadding "0"
:cellspacing "0"
:width "100%"
:font-size "12px"
:font-weight 700
:margin-top "8px"})}
(for [row rows]
[:tr {} row])]))) | |
(mu/defmethod channel/render-notification [:channel/email :notification/dashboard] :- [:sequential EmailMessage]
[_channel-type {:keys [payload payload_type] :as notification-payload} template recipients]
(let [{:keys [dashboard_parts
dashboard_subscription
parameters
dashboard]} payload
template (or template (payload-type->default-template payload_type))
timezone (some->> dashboard_parts (some :card) channel.render/defaulted-timezone)
;; We want to walk dashboard_parts once and not retain Hiccup structures in memory to reduce memory water mark
;; and avoid OOMs. Hence, we:
;; 1. Accumulate the attachments in an imperative way.
;; 2. Convert Hiccup structure into HTML immediately.
;; 3. Later, we combine all HTMLs using ordinary string mashing.
merged-attachments (volatile! {})
result-attachments (volatile! [])
html-contents (->> dashboard_parts
(assoc-attachment-booleans (:dashboard_subscription_dashcards dashboard_subscription))
(mapv #(let [{:keys [attachments content]}
(render-part timezone % {:channel.render/include-title? true})
result-attachment (email.result-attachment/result-attachment %)]
(vswap! merged-attachments merge attachments)
(vswap! result-attachments into result-attachment)
(html content))))
icon-attachment (make-message-attachment (first (icon-bundle :dashboard)))
card-attachments (map make-message-attachment @merged-attachments)
attachments (concat [icon-attachment] card-attachments @result-attachments)
dashboard-content (str "<div>" (str/join html-contents) "</div>")
message-context-fn (fn [non-user-email]
(-> notification-payload
(assoc :computed {:dashboard_content dashboard-content
:icon_cid (:content-id icon-attachment)
:dashboard_has_tabs (some-> dashboard :tabs seq)
:management_text (if (nil? non-user-email)
"Manage your subscriptions"
"Unsubscribe")
:management_url (if (nil? non-user-email)
(urls/notification-management-url)
(pulse-unsubscribe-url-for-non-user (:id dashboard_subscription) non-user-email))
:filters (when parameters
(render-filters parameters))})
(m/update-existing-in [:payload :dashboard :description] #(markdown/process-markdown % :html))))]
(construct-emails template message-context-fn attachments recipients))) | |
------------------------------------------------------------------------------------------------;; System Events ;; ------------------------------------------------------------------------------------------------;; | |
(defn- notification-recipients->emails
[recipients notification-payload]
(into [] cat (for [recipient recipients
:let [details (:details recipient)
emails (case (:type recipient)
:notification-recipient/user
[(-> recipient :user :email)]
:notification-recipient/group
(->> recipient :permissions_group :members (map :email))
:notification-recipient/raw-value
[(:value details)]
:notification-recipient/template
[(not-empty (channel.params/substitute-params (:pattern details) notification-payload :ignore-missing? (:is_optional details)))]
nil)]
:let [emails (filter some? emails)]
:when (seq emails)]
emails))) | |
(mu/defmethod channel/render-notification
[:channel/email :notification/system-event]
[_channel-type
notification-payload #_:- #_notification/NotificationPayload
template :- ::models.channel/ChannelTemplate
recipients :- [:sequential ::models.notification/NotificationRecipient]]
(assert (some? template) "Template is required for system event notifications")
[(construct-email (channel.params/substitute-params (-> template :details :subject) notification-payload)
(notification-recipients->emails recipients notification-payload)
[{:type "text/html; charset=utf-8"
:content (render-body template notification-payload)}]
(-> template :details :recipient-type keyword))]) | |