mirror of
https://github.com/penpot/penpot.git
synced 2026-05-26 02:13:46 +00:00
* 🎉 Add telemetry anonymous event collection Rewrite the audit logging subsystem to support three operating modes and add anonymous telemetry event collection: Modes: - A (audit-log only): events persisted with full context - B (audit-log + telemetry): same as A, plus events are collected for telemetry shipping - C (telemetry-only): events stored anonymously with PII stripped, telemetry flag active, audit-log flag inactive Audit system refactoring (app.loggers.audit): - Replace qualified map keys (::audit/name etc.) with plain keywords - Rename submit! -> submit, insert! -> insert, prepare-event -> prepare-rpc-event - Add submit* as a lower-level public API - Add process-event dispatch function that handles all three modes and webhooks in a single tx-run! - Add :id to event schema (auto-generated if omitted) - Add filter-telemetry-props: anonymises event props per event type. Keeps UUID/boolean/number values; for login/identify events preserves lang, auth-backend, email-domain; for navigate events preserves route, file-id, team-id, page-id; instance-start trigger passes through. - Add filter-telemetry-context: retains only safe context keys. Backend: version, initiator, client-version, client-user-agent. Frontend: browser, os, locale, screen metrics, event-origin. - Timestamps truncated to day precision via ct/truncate for telemetry storage - PII stripped: props emptied, ip-addr zeroed, session-linking and access-token fields removed from context Config (app.config): - Derive :enable-telemetry flag from telemetry-enabled config option Email utilities (app.email): - Add email/clean and email/get-domain helper functions for domain extraction from email addresses Setup (app.setup): - Emit instance-start trigger event at system startup - Simplify handle-instance-id (remove read-only check) RPC layer (app.rpc): - wrap-audit now activates when :telemetry flag is set - Add :request-id to RPC params context for event correlation RPC commands (management, teams_invitations, verify_token, OIDC auth, webhooks): migrate all audit call sites to use the new plain-key API SREPL (app.srepl.main): - Migrate all audit/insert! calls to audit/insert with plain keys Telemetry task (app.tasks.telemetry): - Restructure legacy report into make-legacy-request; distinguish payload type as :telemetry-legacy-report - Add collect-and-send-audit-events: loop fetching up to 10,000 rows per iteration, encodes and sends each page, deletes on success, stops immediately on failure for retry - Add send-event-batch: POSTs fressian+zstd batch (base64 via blob/encode-str) to the telemetry endpoint with instance-id per event - Add gc-telemetry-events: enforces 100,000-row safety cap by dropping oldest rows first - Add delete-sent-events: deletes successfully shipped rows by id Blob utilities (app.util.blob): - Add encode-str/decode-str: combine fressian+zstd encoding with URL- safe base64 for JSON-safe string transport Database: - Add migration 0145: index on audit_log (source, created_at ASC) for efficient telemetry batch collection queries Frontend: - Always initialize event system regardless of :audit-log flag - Defer auth events (signin identify) to after profile is set - Refactor event subsystem for telemetry support Tests (21 test vars, 94 assertions in tasks-telemetry-test): - Cover all code paths: disabled/enabled telemetry, no-events no-op, happy-path batch send and delete, failure retention, payload anonymity, context stripping, timestamp day precision, batch encoding round-trip, multi-page iteration, GC cap enforcement, partial failure handling - blob encode-str/decode-str round-trip tests (14 test vars) - RPC audit integration tests (5 test vars) Signed-off-by: Andrey Antukh <niwi@niwi.nz> * 📎 Add pr feedback changes --------- Signed-off-by: Andrey Antukh <niwi@niwi.nz>
598 lines
18 KiB
Clojure
598 lines
18 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 :as d]
|
|
[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.internet.InternetAddress
|
|
jakarta.mail.internet.MimeBodyPart
|
|
jakarta.mail.internet.MimeMessage
|
|
jakarta.mail.internet.MimeMultipart
|
|
jakarta.mail.Message$RecipientType
|
|
jakarta.mail.Session
|
|
jakarta.mail.Transport
|
|
java.util.Properties))
|
|
|
|
(defn clean
|
|
"Clean and normalizes email address string"
|
|
[email]
|
|
(let [email (str/lower email)
|
|
email (if (str/starts-with? email "mailto:")
|
|
(subs email 7)
|
|
email)
|
|
email (if (or (str/starts-with? email "<")
|
|
(str/ends-with? email ">"))
|
|
(str/trim email "<>")
|
|
email)]
|
|
email))
|
|
|
|
(defn get-domain
|
|
[email]
|
|
(let [email (clean email)
|
|
[_ domain] (str/split email "@" 2)]
|
|
domain))
|
|
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
;; 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 attachments] :or {charset "utf-8"}}]
|
|
(let [mixed-mpart (MimeMultipart. "mixed")]
|
|
(cond
|
|
(string? body)
|
|
(let [text-part (MimeBodyPart.)]
|
|
(.setText text-part ^String body ^String charset)
|
|
(.addBodyPart mixed-mpart text-part))
|
|
|
|
(map? body)
|
|
(let [content-part (MimeBodyPart.)
|
|
alternative-mpart (MimeMultipart. "alternative")]
|
|
|
|
(when-let [content (get body "text/plain")]
|
|
(let [text-part (MimeBodyPart.)]
|
|
(.setText text-part ^String content ^String charset)
|
|
(.addBodyPart alternative-mpart text-part)))
|
|
|
|
(when-let [content (get body "text/html")]
|
|
(let [html-part (MimeBodyPart.)]
|
|
(.setContent html-part ^String content
|
|
(str "text/html; charset=" charset))
|
|
(.addBodyPart alternative-mpart html-part)))
|
|
|
|
(.setContent content-part alternative-mpart)
|
|
(.addBodyPart mixed-mpart content-part))
|
|
|
|
:else
|
|
(throw (IllegalArgumentException. "invalid email body provided")))
|
|
|
|
(doseq [[name content] attachments]
|
|
(let [attachment-part (MimeBodyPart.)]
|
|
(.setFileName attachment-part ^String name)
|
|
(.setContent attachment-part ^String content (str "text/plain; charset=" charset))
|
|
(.addBodyPart mixed-mpart attachment-part)))
|
|
|
|
(.setContent mmsg mixed-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 (d/without-nils
|
|
{"text/plain" text
|
|
"text/html" html})}))
|
|
|
|
(def ^:private schema:params
|
|
[:map {:title "Email Params"}
|
|
[:to [:or ::sm/email [::sm/vec ::sm/email]]]
|
|
[:reply-to {:optional true} ::sm/email]
|
|
[:from {:optional true} ::sm/email]
|
|
[:lang {:optional true} ::sm/text]
|
|
[:subject {:optional true} ::sm/text]
|
|
[:priority {:optional true} [:enum :high :low]]
|
|
[:extra-data {:optional true} ::sm/text]
|
|
[:body {:optional true}
|
|
[:or :string [:map-of :string :string]]]
|
|
[:attachments {:optional true}
|
|
[:map-of :string :string]]])
|
|
|
|
(def ^:private check-params
|
|
(sm/check-fn schema:params))
|
|
|
|
(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 [params]
|
|
(let [params (-> params check-params check-fn)
|
|
email (build-email-template id params)]
|
|
(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 params)
|
|
(assoc :extra-data (:extra-data params))
|
|
|
|
(seq (:attachments params))
|
|
(assoc :attachments (:attachments params))
|
|
|
|
(:from params)
|
|
(assoc :from (:from params))
|
|
|
|
(:reply-to params)
|
|
(assoc :reply-to (:reply-to params))
|
|
|
|
(:to params)
|
|
(assoc :to (:to params)))))))
|
|
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
;; PUBLIC HIGH-LEVEL API
|
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
|
|
(defn render
|
|
[email-factory params]
|
|
(email-factory params))
|
|
|
|
(defn send!
|
|
"Schedule an already defined email to be sent using asynchronously
|
|
using worker task."
|
|
[{:keys [::conn ::factory] :as params}]
|
|
(assert (db/connectable? conn) "expected a valid database connection or pool")
|
|
|
|
(let [email (if factory
|
|
(factory params)
|
|
(-> params
|
|
(dissoc params)
|
|
(check-params)))]
|
|
(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
|
|
[:feedback-subject ::sm/text]
|
|
[:feedback-type ::sm/text]
|
|
[:feedback-content ::sm/text]
|
|
[:profile :map]])
|
|
|
|
(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:invite-to-org
|
|
[:map
|
|
[:invited-by ::sm/text]
|
|
[:organization-name ::sm/text]
|
|
[:organization-initials [:maybe :string]]
|
|
[:organization-logo ::sm/uri]
|
|
[:user-name [:maybe ::sm/text]]
|
|
[:token ::sm/text]])
|
|
|
|
(def invite-to-org
|
|
"Org member invitation email."
|
|
(template-factory
|
|
:id ::invite-to-org
|
|
:schema schema:invite-to-org))
|
|
|
|
(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))))
|