penpot/backend/src/app/email.clj
2025-01-09 11:55:53 +01:00

548 lines
16 KiB
Clojure

;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.email
"Main api for send emails."
(:require
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.logging :as l]
[app.common.pprint :as pp]
[app.common.schema :as sm]
[app.config :as cf]
[app.db :as db]
[app.db.sql :as sql]
[app.util.template :as tmpl]
[app.worker :as wrk]
[clojure.java.io :as io]
[cuerdas.core :as str]
[integrant.core :as ig])
(:import
jakarta.mail.Message$RecipientType
jakarta.mail.Session
jakarta.mail.Transport
jakarta.mail.internet.InternetAddress
jakarta.mail.internet.MimeBodyPart
jakarta.mail.internet.MimeMessage
jakarta.mail.internet.MimeMultipart
java.util.Properties))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; EMAIL IMPL
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- parse-address
^"[Ljakarta.mail.internet.InternetAddress;"
[v]
(InternetAddress/parse ^String v))
(defn- resolve-recipient-type
^Message$RecipientType
[type]
(case type
:to Message$RecipientType/TO
:cc Message$RecipientType/CC
:bcc Message$RecipientType/BCC))
(defn- assign-recipient
[^MimeMessage mmsg type address]
(if (sequential? address)
(reduce #(assign-recipient %1 type %2) mmsg address)
(let [address (parse-address address)
type (resolve-recipient-type type)]
(.addRecipients mmsg type address)
mmsg)))
(defn- assign-recipients
[mmsg {:keys [to cc bcc] :as params}]
(cond-> mmsg
(some? to) (assign-recipient :to to)
(some? cc) (assign-recipient :cc cc)
(some? bcc) (assign-recipient :bcc bcc)))
(defn- assign-from
[mmsg {:keys [::default-from] :as cfg} {:keys [from] :as params}]
(let [from (or from default-from)]
(when from
(let [from (parse-address from)]
(.addFrom ^MimeMessage mmsg from)))))
(defn- assign-reply-to
[mmsg {:keys [::default-reply-to] :as cfg} {:keys [reply-to] :as params}]
(let [reply-to (or reply-to default-reply-to)]
(when reply-to
(let [reply-to (parse-address reply-to)]
(.setReplyTo ^MimeMessage mmsg reply-to)))))
(defn- assign-subject
[mmsg {:keys [subject charset] :or {charset "utf-8"} :as params}]
(assert (string? subject) "subject is mandatory")
(.setSubject ^MimeMessage mmsg
^String subject
^String charset))
(defn- assign-extra-headers
[^MimeMessage mmsg {:keys [headers extra-data] :as params}]
(let [headers (assoc headers "X-Penpot-Data" extra-data)]
(reduce-kv (fn [^MimeMessage mmsg k v]
(doto mmsg
(.addHeader (name k) (str v))))
mmsg
headers)))
(defn- assign-body
[^MimeMessage mmsg {:keys [body charset] :or {charset "utf-8"}}]
(let [mpart (MimeMultipart. "mixed")]
(cond
(string? body)
(let [bpart (MimeBodyPart.)]
(.setContent bpart ^String body (str "text/plain; charset=" charset))
(.addBodyPart mpart bpart))
(vector? body)
(let [mmp (MimeMultipart. "alternative")
mbp (MimeBodyPart.)]
(.addBodyPart mpart mbp)
(.setContent mbp mmp)
(doseq [item body]
(let [mbp (MimeBodyPart.)]
(.setContent mbp
^String (:content item)
^String (str (:type item "text/plain") "; charset=" charset))
(.addBodyPart mmp mbp))))
(map? body)
(let [bpart (MimeBodyPart.)]
(.setContent bpart
^String (:content body)
^String (str (:type body "text/plain") "; charset=" charset))
(.addBodyPart mpart bpart))
:else
(throw (ex-info "Unsupported type" {:body body})))
(.setContent mmsg mpart)
mmsg))
(defn- opts->props
[{:keys [::username ::tls ::host ::port ::timeout ::default-from]
:or {timeout 30000}}]
(reduce-kv
(fn [^Properties props k v]
(if (nil? v)
props
(doto props (.put ^String k ^String (str v)))))
(Properties.)
{"mail.user" username
"mail.host" host
"mail.debug" (contains? cf/flags :smtp-debug)
"mail.from" default-from
"mail.smtp.auth" (boolean username)
"mail.smtp.starttls.enable" tls
"mail.smtp.starttls.required" tls
"mail.smtp.host" host
"mail.smtp.port" port
"mail.smtp.user" username
"mail.smtp.timeout" timeout
"mail.smtp.connectiontimeout" timeout}))
(def ^:private schema:smtp-config
[:map
[::username {:optional true} :string]
[::password {:optional true} :string]
[::tls {:optional true} ::sm/boolean]
[::ssl {:optional true} ::sm/boolean]
[::host {:optional true} :string]
[::port {:optional true} ::sm/int]
[::default-from {:optional true} :string]
[::default-reply-to {:optional true} :string]])
(def valid-smtp-config?
(sm/check-fn schema:smtp-config))
(defn- create-smtp-session
^Session
[cfg]
(dm/assert!
"expected valid smtp config"
(valid-smtp-config? cfg))
(let [props (opts->props cfg)]
(Session/getInstance props)))
(defn- create-smtp-message
^MimeMessage
[cfg session params]
(let [mmsg (MimeMessage. ^Session session)]
(assign-recipients mmsg params)
(assign-from mmsg cfg params)
(assign-reply-to mmsg cfg params)
(assign-subject mmsg params)
(assign-extra-headers mmsg params)
(assign-body mmsg params)
(.saveChanges ^MimeMessage mmsg)
mmsg))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; TEMPLATE EMAIL IMPL
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private email-path "app/email/%(id)s/%(lang)s.%(type)s")
(defn- render-email-template-part
[type id context]
(let [lang (:lang context :en)
path (str/format email-path {:id (name id)
:lang (name lang)
:type (name type)})]
(some-> (io/resource path)
(tmpl/render context))))
(defn- build-email-template
[id context]
(let [subj (render-email-template-part :subj id context)
text (render-email-template-part :txt id context)
html (render-email-template-part :html id context)]
(when (or (not subj)
(and (not text)
(not html)))
(ex/raise :type :internal
:code :missing-email-templates))
{:subject subj
:body (into
[{:type "text/plain"
:content text}]
(when html
[{:type "text/html"
:content html}]))}))
(def ^:private schema:context
[:map
[:to [:or ::sm/email [::sm/vec ::sm/email]]]
[:reply-to {:optional true} ::sm/email]
[:from {:optional true} ::sm/email]
[:lang {:optional true} ::sm/text]
[:priority {:optional true} [:enum :high :low]]
[:extra-data {:optional true} ::sm/text]])
(def ^:private check-context
(sm/check-fn schema:context))
(defn template-factory
[& {:keys [id schema]}]
(assert (keyword? id) "id should be provided and it should be a keyword")
(let [check-fn (if schema
(sm/check-fn schema)
(constantly nil))]
(fn [context]
(let [context (-> context check-context check-fn)
email (build-email-template id context)]
(when-not email
(ex/raise :type :internal
:code :email-template-does-not-exists
:hint "seems like the template is wrong or does not exists."
:template-id id))
(cond-> (assoc email :id (name id))
(:extra-data context)
(assoc :extra-data (:extra-data context))
(:from context)
(assoc :from (:from context))
(:reply-to context)
(assoc :reply-to (:reply-to context))
(:to context)
(assoc :to (:to context)))))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; PUBLIC HIGH-LEVEL API
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn render
[email-factory context]
(email-factory context))
(defn send!
"Schedule an already defined email to be sent using asynchronously
using worker task."
[{:keys [::conn ::factory] :as context}]
(assert (db/connectable? conn) "expected a valid database connection or pool")
(let [email (if factory
(factory context)
(dissoc context ::conn))]
(wrk/submit! {::wrk/task :sendmail
::wrk/delay 0
::wrk/max-retries 4
::wrk/priority 200
::db/conn conn
::wrk/params email})))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; SENDMAIL FN / TASK HANDLER
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(declare send-to-logger!)
(defmethod ig/init-key ::sendmail
[_ cfg]
(fn [params]
(when (contains? cf/flags :smtp)
(let [session (create-smtp-session cfg)]
(with-open [transport (.getTransport session (if (::ssl cfg) "smtps" "smtp"))]
(.connect ^Transport transport
^String (::host cfg)
^String (::port cfg)
^String (::username cfg)
^String (::password cfg))
(let [^MimeMessage message (create-smtp-message cfg session params)]
(l/dbg :hint "sendmail"
:id (:id params)
:to (:to params)
:subject (str/trim (:subject params)))
(.sendMessage ^Transport transport
^MimeMessage message
(.getAllRecipients message))))))
(when (contains? cf/flags :log-emails)
(send-to-logger! cfg params))))
(defmethod ig/assert-key ::handler
[_ params]
(assert (fn? (::sendmail params)) "expected valid sendmail handler"))
(defmethod ig/init-key ::handler
[_ {:keys [::sendmail]}]
(fn [{:keys [props] :as task}]
(sendmail props)))
(defn- send-to-logger!
[_ email]
(let [body (:body email)
out (with-out-str
(println "email console dump:")
(println "******** start email" (:id email) "**********")
(pp/pprint (dissoc email :body))
(if (string? body)
(println body)
(println (->> body
(filter #(= "text/plain" (:type %)))
(map :content)
first)))
(println "******** end email" (:id email) "**********"))]
(l/raw! :info out)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; EMAIL FACTORIES
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private schema:feedback
[:map
[:subject ::sm/text]
[:content ::sm/text]])
(def user-feedback
"A profile feedback email."
(template-factory
:id ::feedback
:schema schema:feedback))
(def ^:private schema:register
[:map [:name ::sm/text]])
(def register
"A new profile registration welcome email."
(template-factory
:id ::register
:schema schema:register))
(def ^:private schema:password-recovery
[:map
[:name ::sm/text]
[:token ::sm/text]])
(def password-recovery
"A password recovery notification email."
(template-factory
:id ::password-recovery
:schema schema:password-recovery))
(def ^:private schema:change-email
[:map
[:name ::sm/text]
[:pending-email ::sm/email]
[:token ::sm/text]])
(def change-email
"Password change confirmation email"
(template-factory
:id ::change-email
:schema schema:change-email))
(def ^:private schema:invite-to-team
[:map
[:invited-by ::sm/text]
[:team ::sm/text]
[:token ::sm/text]])
(def invite-to-team
"Teams member invitation email."
(template-factory
:id ::invite-to-team
:schema schema:invite-to-team))
(def ^:private schema:join-team
[:map
[:invited-by ::sm/text]
[:team ::sm/text]
[:team-id ::sm/uuid]])
(def join-team
"Teams member joined after request email."
(template-factory
:id ::join-team
:schema schema:join-team))
(def ^:private schema:request-file-access
[:map
[:requested-by ::sm/text]
[:requested-by-email ::sm/text]
[:team-name ::sm/text]
[:team-id ::sm/uuid]
[:file-name ::sm/text]
[:file-id ::sm/uuid]
[:page-id ::sm/uuid]])
(def request-file-access
"File access request email."
(template-factory
:id ::request-file-access
:schema schema:request-file-access))
(def request-file-access-yourpenpot
"File access on Your Penpot request email."
(template-factory
:id ::request-file-access-yourpenpot
:schema schema:request-file-access))
(def request-file-access-yourpenpot-view
"File access on Your Penpot view mode request email."
(template-factory
:id ::request-file-access-yourpenpot-view
:schema schema:request-file-access))
(def ^:private schema:request-team-access
[:map
[:requested-by ::sm/text]
[:requested-by-email ::sm/text]
[:team-name ::sm/text]
[:team-id ::sm/uuid]])
(def request-team-access
"Team access request email."
(template-factory
:id ::request-team-access
:schema schema:request-team-access))
(def ^:private schema:comment-mention
[:map
[:name ::sm/text]
[:source-user ::sm/text]
[:comment-reference ::sm/text]
[:comment-content ::sm/text]
[:comment-url ::sm/text]])
(def comment-mention
(template-factory
:id ::comment-mention
:schema schema:comment-mention))
(def ^:private schema:comment-thread
[:map
[:name ::sm/text]
[:source-user ::sm/text]
[:comment-reference ::sm/text]
[:comment-content ::sm/text]
[:comment-url ::sm/text]])
(def comment-thread
(template-factory
:id ::comment-thread
:schema schema:comment-thread))
(def ^:private schema:comment-notification
[:map
[:name ::sm/text]
[:source-user ::sm/text]
[:comment-reference ::sm/text]
[:comment-content ::sm/text]
[:comment-url ::sm/text]])
(def comment-notification
(template-factory
:id ::comment-notification
:schema schema:comment-notification))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; BOUNCE/COMPLAINS HELPERS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def sql:profile-complaint-report
"select (select count(*)
from profile_complaint_report
where type = 'complaint'
and profile_id = ?
and created_at > now() - ?::interval) as complaints,
(select count(*)
from profile_complaint_report
where type = 'bounce'
and profile_id = ?
and created_at > now() - ?::interval) as bounces;")
(defn allow-send-emails?
[conn profile]
(when-not (:is-muted profile false)
(let [complaint-threshold (cf/get :profile-complaint-threshold)
complaint-max-age (cf/get :profile-complaint-max-age)
bounce-threshold (cf/get :profile-bounce-threshold)
bounce-max-age (cf/get :profile-bounce-max-age)
{:keys [complaints bounces] :as result}
(db/exec-one! conn [sql:profile-complaint-report
(:id profile)
(db/interval complaint-max-age)
(:id profile)
(db/interval bounce-max-age)])]
(and (< (or complaints 0) complaint-threshold)
(< (or bounces 0) bounce-threshold)))))
(defn has-complaint-reports?
([conn email] (has-complaint-reports? conn email nil))
([conn email {:keys [threshold] :or {threshold 1}}]
(let [reports (db/exec! conn (sql/select :global-complaint-report
{:email email :type "complaint"}
{:limit 10}))]
(>= (count reports) threshold))))
(defn has-bounce-reports?
([conn email] (has-bounce-reports? conn email nil))
([conn email {:keys [threshold] :or {threshold 1}}]
(let [reports (db/exec! conn (sql/select :global-complaint-report
{:email email :type "bounce"}
{:limit 10}))]
(>= (count reports) threshold))))
(defn has-reports?
([conn email] (has-reports? conn email nil))
([conn email {:keys [threshold] :or {threshold 1}}]
(let [reports (db/exec! conn (sql/select :global-complaint-report
{:email email}
{:limit 10}))]
(>= (count reports) threshold))))