mirror of
https://github.com/penpot/penpot.git
synced 2025-04-05 03:21:26 -05:00
🎉 Add automatic complaint and bouncing handling.
This commit is contained in:
parent
17229228a3
commit
7708752ad9
26 changed files with 1073 additions and 73 deletions
|
@ -11,10 +11,10 @@
|
|||
"A configuration management."
|
||||
(:refer-clojure :exclude [get])
|
||||
(:require
|
||||
[clojure.core :as c]
|
||||
[app.common.spec :as us]
|
||||
[app.common.version :as v]
|
||||
[app.util.time :as dt]
|
||||
[clojure.core :as c]
|
||||
[clojure.spec.alpha :as s]
|
||||
[cuerdas.core :as str]
|
||||
[environ.core :refer [env]]))
|
||||
|
@ -54,6 +54,12 @@
|
|||
:smtp-default-reply-to "Penpot <no-reply@example.com>"
|
||||
:smtp-default-from "Penpot <no-reply@example.com>"
|
||||
|
||||
:profile-complaint-max-age (dt/duration {:days 7})
|
||||
:profile-complaint-threshold 2
|
||||
|
||||
:profile-bounce-max-age (dt/duration {:days 7})
|
||||
:profile-bounce-threshold 10
|
||||
|
||||
:allow-demo-users true
|
||||
:registration-enabled true
|
||||
:registration-domain-whitelist ""
|
||||
|
@ -100,6 +106,11 @@
|
|||
(s/def ::feedback-enabled ::us/boolean)
|
||||
(s/def ::feedback-destination ::us/string)
|
||||
|
||||
(s/def ::profile-complaint-max-age ::dt/duration)
|
||||
(s/def ::profile-complaint-threshold ::us/integer)
|
||||
(s/def ::profile-bounce-max-age ::dt/duration)
|
||||
(s/def ::profile-bounce-threshold ::us/integer)
|
||||
|
||||
(s/def ::error-report-webhook ::us/string)
|
||||
|
||||
(s/def ::smtp-enabled ::us/boolean)
|
||||
|
@ -187,6 +198,10 @@
|
|||
::ldap-bind-dn
|
||||
::ldap-bind-password
|
||||
::public-uri
|
||||
::profile-complaint-threshold
|
||||
::profile-bounce-threshold
|
||||
::profile-complaint-max-age
|
||||
::profile-bounce-max-age
|
||||
::redis-uri
|
||||
::registration-domain-whitelist
|
||||
::registration-enabled
|
||||
|
|
|
@ -5,13 +5,15 @@
|
|||
;; This Source Code Form is "Incompatible With Secondary Licenses", as
|
||||
;; defined by the Mozilla Public License, v. 2.0.
|
||||
;;
|
||||
;; Copyright (c) 2020 UXBOX Labs SL
|
||||
;; Copyright (c) 2020-2021 UXBOX Labs SL
|
||||
|
||||
(ns app.emails
|
||||
"Main api for send emails."
|
||||
(:require
|
||||
[app.common.spec :as us]
|
||||
[app.config :as cfg]
|
||||
[app.db :as db]
|
||||
[app.db.sql :as sql]
|
||||
[app.tasks :as tasks]
|
||||
[app.util.emails :as emails]
|
||||
[clojure.spec.alpha :as s]))
|
||||
|
@ -41,6 +43,54 @@
|
|||
:priority 200
|
||||
:props email})))
|
||||
|
||||
|
||||
(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 (cfg/get :profile-complaint-threshold)
|
||||
complaint-max-age (cfg/get :profile-complaint-max-age)
|
||||
bounce-threshold (cfg/get :profile-bounce-threshold)
|
||||
bounce-max-age (cfg/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 (< complaints complaint-threshold)
|
||||
(< bounces 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))))
|
||||
|
||||
|
||||
;; --- Emails
|
||||
|
||||
(s/def ::subject ::us/string)
|
||||
|
|
|
@ -126,6 +126,9 @@
|
|||
["/dbg"
|
||||
["/error-by-id/:id" {:get (:error-report-handler cfg)}]]
|
||||
|
||||
["/webhooks"
|
||||
["/sns" {:post (:sns-webhook cfg)}]]
|
||||
|
||||
["/api" {:middleware [[middleware/format-response-body]
|
||||
[middleware/params]
|
||||
[middleware/multipart-params]
|
||||
|
|
|
@ -110,6 +110,7 @@
|
|||
|
||||
method-fn (get-in rpc [:methods :mutation :login-or-register])
|
||||
profile (method-fn {:email (:email info)
|
||||
:backend "github"
|
||||
:fullname (:fullname info)})
|
||||
token (tokens :generate
|
||||
{:iss :auth
|
||||
|
|
|
@ -112,6 +112,7 @@
|
|||
|
||||
method-fn (get-in rpc [:methods :mutation :login-or-register])
|
||||
profile (method-fn {:email (:email info)
|
||||
:backend "gitlab"
|
||||
:fullname (:fullname info)})
|
||||
token (tokens :generate {:iss :auth
|
||||
:exp (dt/in-future "15m")
|
||||
|
|
|
@ -98,6 +98,7 @@
|
|||
:code :unable-to-auth))
|
||||
method-fn (get-in rpc [:methods :mutation :login-or-register])
|
||||
profile (method-fn {:email (:email info)
|
||||
:backend "google"
|
||||
:fullname (:fullname info)})
|
||||
token (tokens :generate {:iss :auth
|
||||
:exp (dt/in-future "15m")
|
||||
|
|
|
@ -64,6 +64,7 @@
|
|||
:password (:password data)))]
|
||||
(let [method-fn (get-in rpc [:methods :mutation :login-or-register])
|
||||
profile (method-fn {:email (:email info)
|
||||
:backend "ldap"
|
||||
:fullname (:fullname info)})
|
||||
|
||||
sxf ((:create session) (:id profile))
|
||||
|
|
207
backend/src/app/http/awsns.clj
Normal file
207
backend/src/app/http/awsns.clj
Normal file
|
@ -0,0 +1,207 @@
|
|||
;; 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/.
|
||||
;;
|
||||
;; This Source Code Form is "Incompatible With Secondary Licenses", as
|
||||
;; defined by the Mozilla Public License, v. 2.0.
|
||||
;;
|
||||
;; Copyright (c) 2020 Andrey Antukh <niwi@niwi.nz>
|
||||
|
||||
(ns app.http.awsns
|
||||
"AWS SNS webhook handler for bounces."
|
||||
(:require
|
||||
[app.common.exceptions :as ex]
|
||||
[app.db :as db]
|
||||
[app.db.sql :as sql]
|
||||
[app.util.http :as http]
|
||||
[clojure.pprint :refer [pprint]]
|
||||
[clojure.spec.alpha :as s]
|
||||
[clojure.tools.logging :as log]
|
||||
[cuerdas.core :as str]
|
||||
[integrant.core :as ig]
|
||||
[jsonista.core :as j]))
|
||||
|
||||
(declare parse-json)
|
||||
(declare parse-notification)
|
||||
(declare process-report)
|
||||
|
||||
(defn- pprint-report
|
||||
[message]
|
||||
(binding [clojure.pprint/*print-right-margin* 120]
|
||||
(with-out-str (pprint message))))
|
||||
|
||||
(defmethod ig/pre-init-spec ::handler [_]
|
||||
(s/keys :req-un [::db/pool]))
|
||||
|
||||
(defmethod ig/init-key ::handler
|
||||
[_ cfg]
|
||||
(fn [request]
|
||||
(let [body (parse-json (slurp (:body request)))
|
||||
mtype (get body "Type")]
|
||||
(cond
|
||||
(= mtype "SubscriptionConfirmation")
|
||||
(let [surl (get body "SubscribeURL")
|
||||
stopic (get body "TopicArn")]
|
||||
(log/infof "Subscription received (topic=%s, url=%s)" stopic surl)
|
||||
(http/send! {:uri surl :method :post :timeout 10000}))
|
||||
|
||||
(= mtype "Notification")
|
||||
(when-let [message (parse-json (get body "Message"))]
|
||||
;; (log/infof "Received: %s" (pr-str message))
|
||||
(let [notification (parse-notification cfg message)]
|
||||
(process-report cfg notification)))
|
||||
|
||||
:else
|
||||
(log/warn (str "Unexpected data received.\n"
|
||||
(pprint-report body))))
|
||||
|
||||
{:status 200 :body ""})))
|
||||
|
||||
(defn- parse-bounce
|
||||
[data]
|
||||
{:type "bounce"
|
||||
:kind (str/lower (get data "bounceType"))
|
||||
:category (str/lower (get data "bounceSubType"))
|
||||
:feedback-id (get data "feedbackId")
|
||||
:timestamp (get data "timestamp")
|
||||
:recipients (->> (get data "bouncedRecipients")
|
||||
(mapv (fn [item]
|
||||
{:email (str/lower (get item "emailAddress"))
|
||||
:status (get item "status")
|
||||
:action (get item "action")
|
||||
:dcode (get item "diagnosticCode")})))})
|
||||
|
||||
(defn- parse-complaint
|
||||
[data]
|
||||
{:type "complaint"
|
||||
:user-agent (get data "userAgent")
|
||||
:kind (get data "complaintFeedbackType")
|
||||
:category (get data "complaintSubType")
|
||||
:timestamp (get data "arrivalDate")
|
||||
:feedback-id (get data "feedbackId")
|
||||
:recipients (->> (get data "complainedRecipients")
|
||||
(mapv #(get % "emailAddress"))
|
||||
(mapv str/lower))})
|
||||
|
||||
(defn- extract-headers
|
||||
[mail]
|
||||
(reduce (fn [acc item]
|
||||
(let [key (get item "name")
|
||||
val (get item "value")]
|
||||
(assoc acc (str/lower key) val)))
|
||||
{}
|
||||
(get mail "headers")))
|
||||
|
||||
(defn- extract-identity
|
||||
[{:keys [tokens] :as cfg} headers]
|
||||
(let [tdata (get headers "x-penpot-data")]
|
||||
(when-not (str/empty? tdata)
|
||||
(let [result (tokens :verify {:token tdata :iss :profile-identity})]
|
||||
(:profile-id result)))))
|
||||
|
||||
(defn- parse-notification
|
||||
[cfg message]
|
||||
(let [type (get message "notificationType")
|
||||
data (case type
|
||||
"Bounce" (parse-bounce (get message "bounce"))
|
||||
"Complaint" (parse-complaint (get message "complaint"))
|
||||
{:type (keyword (str/lower type))
|
||||
:message message})]
|
||||
(when data
|
||||
(let [mail (get message "mail")]
|
||||
(when-not mail
|
||||
(ex/raise :type :internal
|
||||
:code :incomplete-notification
|
||||
:hint "no email data received, please enable full headers report"))
|
||||
(let [headers (extract-headers mail)
|
||||
mail {:destination (get mail "destination")
|
||||
:source (get mail "source")
|
||||
:timestamp (get mail "timestamp")
|
||||
:subject (get-in mail ["commonHeaders" "subject"])
|
||||
:headers headers}]
|
||||
(assoc data
|
||||
:mail mail
|
||||
:profile-id (extract-identity cfg headers)))))))
|
||||
|
||||
(defn- parse-json
|
||||
[v]
|
||||
(ex/ignoring
|
||||
(j/read-value v)))
|
||||
|
||||
(defn- register-bounce-for-profile
|
||||
[{:keys [pool]} {:keys [type kind profile-id] :as report}]
|
||||
(when (= kind "permanent")
|
||||
(db/with-atomic [conn pool]
|
||||
(db/insert! conn :profile-complaint-report
|
||||
{:profile-id profile-id
|
||||
:type (name type)
|
||||
:content (db/tjson report)})
|
||||
|
||||
;; TODO: maybe also try to find profiles by mail and if exists
|
||||
;; register profile reports for them?
|
||||
(doseq [recipient (:recipients report)]
|
||||
(db/insert! conn :global-complaint-report
|
||||
{:email (:email recipient)
|
||||
:type (name type)
|
||||
:content (db/tjson report)}))
|
||||
|
||||
(let [profile (db/exec-one! conn (sql/select :profile {:id profile-id}))]
|
||||
(when (some #(= (:email profile) (:email %)) (:recipients report))
|
||||
;; If the report matches the profile email, this means that
|
||||
;; the report is for itself, can be caused when a user
|
||||
;; registers with an invalid email or the user email is
|
||||
;; permanently rejecting receiving the email. In this case we
|
||||
;; have no option to mark the user as muted (and in this case
|
||||
;; the profile will be also inactive.
|
||||
(db/update! conn :profile
|
||||
{:is-muted true}
|
||||
{:id profile-id}))))))
|
||||
|
||||
(defn- register-complaint-for-profile
|
||||
[{:keys [pool]} {:keys [type profile-id] :as report}]
|
||||
(db/with-atomic [conn pool]
|
||||
(db/insert! conn :profile-complaint-report
|
||||
{:profile-id profile-id
|
||||
:type (name type)
|
||||
:content (db/tjson report)})
|
||||
|
||||
;; TODO: maybe also try to find profiles by email and if exists
|
||||
;; register profile reports for them?
|
||||
(doseq [email (:recipients report)]
|
||||
(db/insert! conn :global-complaint-report
|
||||
{:email email
|
||||
:type (name type)
|
||||
:content (db/tjson report)}))
|
||||
|
||||
(let [profile (db/exec-one! conn (sql/select :profile {:id profile-id}))]
|
||||
(when (some #(= % (:email profile)) (:recipients report))
|
||||
;; If the report matches the profile email, this means that
|
||||
;; the report is for itself, rare case but can happen; In this
|
||||
;; case just mark profile as muted (very rare case).
|
||||
(db/update! conn :profile
|
||||
{:is-muted true}
|
||||
{:id profile-id})))))
|
||||
|
||||
(defn- process-report
|
||||
[cfg {:keys [type profile-id] :as report}]
|
||||
(log/debug (str "Procesing report:\n" (pprint-report report)))
|
||||
(cond
|
||||
;; In this case we receive a bounce/complaint notification without
|
||||
;; confirmed identity, we just emit a warning but do nothing about
|
||||
;; it because this is not a normal case. All notifications should
|
||||
;; come with profile identity.
|
||||
(nil? profile-id)
|
||||
(log/warn (str "A notification without identity recevied from AWS\n"
|
||||
(pprint-report report)))
|
||||
|
||||
(= "bounce" type)
|
||||
(register-bounce-for-profile cfg report)
|
||||
|
||||
(= "complaint" type)
|
||||
(register-complaint-for-profile cfg report)
|
||||
|
||||
:else
|
||||
(log/warn (str "Unrecognized report received from AWS\n"
|
||||
(pprint-report report)))))
|
||||
|
||||
|
|
@ -71,6 +71,10 @@
|
|||
{:pool (ig/ref :app.db/pool)
|
||||
:cookie-name "auth-token"}
|
||||
|
||||
:app.http.awsns/handler
|
||||
{:tokens (ig/ref :app.tokens/tokens)
|
||||
:pool (ig/ref :app.db/pool)}
|
||||
|
||||
:app.http/server
|
||||
{:port (:http-server-port config)
|
||||
:handler (ig/ref :app.http/router)
|
||||
|
@ -90,6 +94,7 @@
|
|||
:assets (ig/ref :app.http.assets/handlers)
|
||||
:svgparse (ig/ref :app.svgparse/handler)
|
||||
:storage (ig/ref :app.storage/storage)
|
||||
:sns-webhook (ig/ref :app.http.awsns/handler)
|
||||
:error-report-handler (ig/ref :app.error-reporter/handler)}
|
||||
|
||||
:app.http.assets/handlers
|
||||
|
|
|
@ -148,6 +148,10 @@
|
|||
|
||||
{:name "0045-add-index-to-file-change-table"
|
||||
:fn (mg/resource "app/migrations/sql/0045-add-index-to-file-change-table.sql")}
|
||||
|
||||
{:name "0046-add-profile-complaint-table"
|
||||
:fn (mg/resource "app/migrations/sql/0046-add-profile-complaint-table.sql")}
|
||||
|
||||
])
|
||||
|
||||
|
||||
|
|
|
@ -0,0 +1,45 @@
|
|||
CREATE TABLE profile_complaint_report (
|
||||
profile_id uuid NOT NULL REFERENCES profile(id) ON DELETE CASCADE,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
type text NOT NULL,
|
||||
content jsonb,
|
||||
|
||||
PRIMARY KEY (profile_id, created_at)
|
||||
);
|
||||
|
||||
ALTER TABLE profile_complaint_report
|
||||
ALTER COLUMN type SET STORAGE external,
|
||||
ALTER COLUMN content SET STORAGE external;
|
||||
|
||||
ALTER TABLE profile
|
||||
ADD COLUMN is_muted boolean DEFAULT false,
|
||||
ADD COLUMN auth_backend text NULL;
|
||||
|
||||
ALTER TABLE profile
|
||||
ALTER COLUMN auth_backend SET STORAGE external;
|
||||
|
||||
UPDATE profile
|
||||
SET auth_backend = 'google'
|
||||
WHERE password = '!';
|
||||
|
||||
UPDATE profile
|
||||
SET auth_backend = 'penpot'
|
||||
WHERE password != '!';
|
||||
|
||||
-- Table storing a permanent complaint table for register all
|
||||
-- permanent bounces and spam reports (complaints) and avoid sending
|
||||
-- more emails there.
|
||||
CREATE TABLE global_complaint_report (
|
||||
email text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
type text NOT NULL,
|
||||
content jsonb,
|
||||
|
||||
PRIMARY KEY (email, created_at)
|
||||
);
|
||||
|
||||
ALTER TABLE global_complaint_report
|
||||
ALTER COLUMN type SET STORAGE external,
|
||||
ALTER COLUMN content SET STORAGE external;
|
|
@ -55,12 +55,11 @@
|
|||
|
||||
(sv/defmethod ::register-profile {:auth false :rlimit :password}
|
||||
[{:keys [pool tokens session] :as cfg} {:keys [token] :as params}]
|
||||
(when-not (:registration-enabled cfg/config)
|
||||
(when-not (cfg/get :registration-enabled)
|
||||
(ex/raise :type :restriction
|
||||
:code :registration-disabled))
|
||||
|
||||
(when-not (email-domain-in-whitelist? (:registration-domain-whitelist cfg/config)
|
||||
(:email params))
|
||||
(when-not (email-domain-in-whitelist? (cfg/get :registration-domain-whitelist) (:email params))
|
||||
(ex/raise :type :validation
|
||||
:code :email-domain-is-not-allowed))
|
||||
|
||||
|
@ -97,20 +96,30 @@
|
|||
{:transform-response ((:create session) (:id profile))}))
|
||||
|
||||
;; If no token is provided, send a verification email
|
||||
(let [token (tokens :generate
|
||||
{:iss :verify-email
|
||||
:exp (dt/in-future "48h")
|
||||
:profile-id (:id profile)
|
||||
:email (:email profile)})]
|
||||
(let [vtoken (tokens :generate
|
||||
{:iss :verify-email
|
||||
:exp (dt/in-future "48h")
|
||||
:profile-id (:id profile)
|
||||
:email (:email profile)})
|
||||
ptoken (tokens :generate-predefined
|
||||
{:iss :profile-identity
|
||||
:profile-id (:id profile)})]
|
||||
|
||||
;; Don't allow proceed in register page if the email is
|
||||
;; already reported as permanent bounced
|
||||
(when (emails/has-bounce-reports? conn (:email profile))
|
||||
(ex/raise :type :validation
|
||||
:code :email-has-permanent-bounces
|
||||
:hint "looks like the email has one or many bounces reported"))
|
||||
|
||||
(emails/send! conn emails/register
|
||||
{:to (:email profile)
|
||||
:name (:fullname profile)
|
||||
:token token})
|
||||
:token vtoken
|
||||
:extra-data ptoken})
|
||||
|
||||
profile)))))
|
||||
|
||||
|
||||
(defn email-domain-in-whitelist?
|
||||
"Returns true if email's domain is in the given whitelist or if given
|
||||
whitelist is an empty string."
|
||||
|
@ -155,8 +164,8 @@
|
|||
(defn- create-profile
|
||||
"Create the profile entry on the database with limited input
|
||||
filling all the other fields with defaults."
|
||||
[conn {:keys [id fullname email password demo? props is-active]
|
||||
:or {is-active false}
|
||||
[conn {:keys [id fullname email password demo? props is-active is-muted]
|
||||
:or {is-active false is-muted false}
|
||||
:as params}]
|
||||
(let [id (or id (uuid/next))
|
||||
demo? (if (boolean? demo?) demo? false)
|
||||
|
@ -168,9 +177,11 @@
|
|||
{:id id
|
||||
:fullname fullname
|
||||
:email (str/lower email)
|
||||
:auth-backend "penpot"
|
||||
:password password
|
||||
:props props
|
||||
:is-active active?
|
||||
:is-muted is-muted
|
||||
:is-demo demo?})
|
||||
(update :props db/decode-transit-pgobject))
|
||||
(catch org.postgresql.util.PSQLException e
|
||||
|
@ -252,11 +263,12 @@
|
|||
|
||||
;; --- Mutation: Register if not exists
|
||||
|
||||
(s/def ::backend ::us/string)
|
||||
(s/def ::login-or-register
|
||||
(s/keys :req-un [::email ::fullname]))
|
||||
(s/keys :req-un [::email ::fullname ::backend]))
|
||||
|
||||
(sv/defmethod ::login-or-register {:auth false}
|
||||
[{:keys [pool] :as cfg} {:keys [email fullname] :as params}]
|
||||
[{:keys [pool] :as cfg} {:keys [email backend fullname] :as params}]
|
||||
(letfn [(populate-additional-data [conn profile]
|
||||
(let [data (profile/retrieve-additional-data conn (:id profile))]
|
||||
(merge profile data)))
|
||||
|
@ -266,6 +278,7 @@
|
|||
{:id (uuid/next)
|
||||
:fullname fullname
|
||||
:email (str/lower email)
|
||||
:auth-backend backend
|
||||
:is-active true
|
||||
:password "!"
|
||||
:is-demo false}))
|
||||
|
@ -372,16 +385,30 @@
|
|||
{:iss :change-email
|
||||
:exp (dt/in-future "15m")
|
||||
:profile-id profile-id
|
||||
:email email})]
|
||||
:email email})
|
||||
ptoken (tokens :generate-predefined
|
||||
{:iss :profile-identity
|
||||
:profile-id (:id profile)})]
|
||||
|
||||
(when (not= email (:email profile))
|
||||
(check-profile-existence! conn params))
|
||||
|
||||
(when-not (emails/allow-send-emails? conn profile)
|
||||
(ex/raise :type :validation
|
||||
:code :profile-is-muted
|
||||
:hint "looks like the profile has reported repeatedly as spam or has permanent bounces."))
|
||||
|
||||
(when (emails/has-bounce-reports? conn email)
|
||||
(ex/raise :type :validation
|
||||
:code :email-has-permanent-bounces
|
||||
:hint "looks like the email you invite has been repeatedly reported as spam or permanent bounce"))
|
||||
|
||||
(emails/send! conn emails/change-email
|
||||
{:to (:email profile)
|
||||
:name (:fullname profile)
|
||||
:pending-email email
|
||||
:token token})
|
||||
:token token
|
||||
:extra-data ptoken})
|
||||
nil)))
|
||||
|
||||
(defn select-profile-for-update
|
||||
|
@ -403,11 +430,15 @@
|
|||
(assoc profile :token token)))
|
||||
|
||||
(send-email-notification [conn profile]
|
||||
(emails/send! conn emails/password-recovery
|
||||
{:to (:email profile)
|
||||
:token (:token profile)
|
||||
:name (:fullname profile)})
|
||||
nil)]
|
||||
(let [ptoken (tokens :generate-predefined
|
||||
{:iss :profile-identity
|
||||
:profile-id (:id profile)})]
|
||||
(emails/send! conn emails/password-recovery
|
||||
{:to (:email profile)
|
||||
:token (:token profile)
|
||||
:name (:fullname profile)
|
||||
:extra-data ptoken})
|
||||
nil))]
|
||||
|
||||
(db/with-atomic [conn pool]
|
||||
(when-let [profile (profile/retrieve-profile-data-by-email conn email)]
|
||||
|
@ -415,6 +446,17 @@
|
|||
(ex/raise :type :validation
|
||||
:code :profile-not-verified
|
||||
:hint "the user need to validate profile before recover password"))
|
||||
|
||||
(when-not (emails/allow-send-emails? conn profile)
|
||||
(ex/raise :type :validation
|
||||
:code :profile-is-muted
|
||||
:hint "looks like the profile has reported repeatedly as spam or has permanent bounces."))
|
||||
|
||||
(when (emails/has-bounce-reports? conn (:email profile))
|
||||
(ex/raise :type :validation
|
||||
:code :email-has-permanent-bounces
|
||||
:hint "looks like the email you invite has been repeatedly reported as spam or permanent bounce"))
|
||||
|
||||
(->> profile
|
||||
(create-recovery-token)
|
||||
(send-email-notification conn))))))
|
||||
|
|
|
@ -297,26 +297,48 @@
|
|||
(sv/defmethod ::invite-team-member
|
||||
[{:keys [pool tokens] :as cfg} {:keys [profile-id team-id email role] :as params}]
|
||||
(db/with-atomic [conn pool]
|
||||
(let [perms (teams/check-edition-permissions! conn profile-id team-id)
|
||||
profile (db/get-by-id conn :profile profile-id)
|
||||
member (profile/retrieve-profile-data-by-email conn email)
|
||||
team (db/get-by-id conn :team team-id)
|
||||
token (tokens :generate
|
||||
{:iss :team-invitation
|
||||
:exp (dt/in-future "24h")
|
||||
:profile-id (:id profile)
|
||||
:role role
|
||||
:team-id team-id
|
||||
:member-email (:email member email)
|
||||
:member-id (:id member)})]
|
||||
(let [perms (teams/check-edition-permissions! conn profile-id team-id)
|
||||
profile (db/get-by-id conn :profile profile-id)
|
||||
member (profile/retrieve-profile-data-by-email conn email)
|
||||
team (db/get-by-id conn :team team-id)
|
||||
itoken (tokens :generate
|
||||
{:iss :team-invitation
|
||||
:exp (dt/in-future "24h")
|
||||
:profile-id (:id profile)
|
||||
:role role
|
||||
:team-id team-id
|
||||
:member-email (:email member email)
|
||||
:member-id (:id member)})
|
||||
ptoken (tokens :generate-predefined
|
||||
{:iss :profile-identity
|
||||
:profile-id (:id profile)})]
|
||||
|
||||
(when-not (some :is-admin perms)
|
||||
(ex/raise :type :validation
|
||||
:code :insufficient-permissions))
|
||||
|
||||
;; First check if the current profile is allowed to send emails.
|
||||
(when-not (emails/allow-send-emails? conn profile)
|
||||
(ex/raise :type :validation
|
||||
:code :profile-is-muted
|
||||
:hint "looks like the profile has reported repeatedly as spam or has permanent bounces"))
|
||||
|
||||
(when (and member (not (emails/allow-send-emails? conn member)))
|
||||
(ex/raise :type :validation
|
||||
:code :member-is-muted
|
||||
:hint "looks like the profile has reported repeatedly as spam or has permanent bounces"))
|
||||
|
||||
;; Secondly check if the invited member email is part of the
|
||||
;; global spam/bounce report.
|
||||
(when (emails/has-bounce-reports? conn email)
|
||||
(ex/raise :type :validation
|
||||
:code :email-has-permanent-bounces
|
||||
:hint "looks like the email you invite has been repeatedly reported as spam or permanent bounce"))
|
||||
|
||||
(emails/send! conn emails/invite-to-team
|
||||
{:to email
|
||||
:invited-by (:fullname profile)
|
||||
:team (:name team)
|
||||
:token token})
|
||||
:token itoken
|
||||
:extra-data ptoken})
|
||||
nil)))
|
||||
|
|
|
@ -90,11 +90,19 @@
|
|||
(let [params (merge {:team-id team-id
|
||||
:profile-id member-id}
|
||||
(teams/role->params role))
|
||||
claims (assoc claims :state :created)]
|
||||
claims (assoc claims :state :created)
|
||||
member (profile/retrieve-profile conn member-id)]
|
||||
|
||||
(db/insert! conn :team-profile-rel params
|
||||
{:on-conflict-do-nothing true})
|
||||
|
||||
;; If profile is not yet verified, mark it as verified because
|
||||
;; accepting an invitation link serves as verification.
|
||||
(when-not (:is-active member)
|
||||
(db/update! conn :profile
|
||||
{:is-active true}
|
||||
{:id member-id}))
|
||||
|
||||
(if (and (uuid? profile-id)
|
||||
(= member-id profile-id))
|
||||
;; If the current session is already matches the invited
|
||||
|
|
|
@ -60,11 +60,25 @@
|
|||
(defmethod ig/pre-init-spec ::tokens [_]
|
||||
(s/keys :req-un [::sprops]))
|
||||
|
||||
(defn- generate-predefined
|
||||
[cfg {:keys [iss profile-id] :as params}]
|
||||
(case iss
|
||||
:profile-identity
|
||||
(do
|
||||
(us/verify uuid? profile-id)
|
||||
(generate cfg (assoc params
|
||||
:exp (dt/in-future {:days 30}))))
|
||||
|
||||
(ex/raise :type :internal
|
||||
:code :not-implemented
|
||||
:hint "no predefined token")))
|
||||
|
||||
(defmethod ig/init-key ::tokens
|
||||
[_ {:keys [sprops] :as cfg}]
|
||||
(let [secret (derive-tokens-secret (:secret-key sprops))
|
||||
cfg (assoc cfg ::secret secret)]
|
||||
(fn [action params]
|
||||
(case action
|
||||
:generate-predefined (generate-predefined cfg params)
|
||||
:verify (verify cfg params)
|
||||
:generate (generate cfg params)))))
|
||||
|
|
|
@ -31,15 +31,24 @@
|
|||
[environ.core :refer [env]]
|
||||
[expound.alpha :as expound]
|
||||
[integrant.core :as ig]
|
||||
[mockery.core :as mk]
|
||||
[promesa.core :as p])
|
||||
(:import org.postgresql.ds.PGSimpleDataSource))
|
||||
|
||||
(def ^:dynamic *system* nil)
|
||||
(def ^:dynamic *pool* nil)
|
||||
|
||||
(def config
|
||||
(merge {:redis-uri "redis://redis/1"
|
||||
:database-uri "postgresql://postgres/penpot_test"
|
||||
:storage-fs-directory "/tmp/app/storage"
|
||||
:migrations-verbose false}
|
||||
cfg/config))
|
||||
|
||||
|
||||
(defn state-init
|
||||
[next]
|
||||
(let [config (-> (main/build-system-config cfg/test-config)
|
||||
(let [config (-> (main/build-system-config config)
|
||||
(dissoc :app.srepl/server
|
||||
:app.http/server
|
||||
:app.http/router
|
||||
|
@ -300,3 +309,31 @@
|
|||
(defn sleep
|
||||
[ms]
|
||||
(Thread/sleep ms))
|
||||
|
||||
(defn mock-config-get-with
|
||||
"Helper for mock app.config/get"
|
||||
[data]
|
||||
(fn
|
||||
([key] (get (merge config data) key))
|
||||
([key default] (get (merge config data) key default))))
|
||||
|
||||
(defn create-complaint-for
|
||||
[conn {:keys [id created-at type]}]
|
||||
(db/insert! conn :profile-complaint-report
|
||||
{:profile-id id
|
||||
:created-at (or created-at (dt/now))
|
||||
:type (name type)
|
||||
:content (db/tjson {})}))
|
||||
|
||||
(defn create-global-complaint-for
|
||||
[conn {:keys [email type created-at]}]
|
||||
(db/insert! conn :global-complaint-report
|
||||
{:email email
|
||||
:type (name type)
|
||||
:created-at (or created-at (dt/now))
|
||||
:content (db/tjson {})}))
|
||||
|
||||
|
||||
(defn reset-mock!
|
||||
[m]
|
||||
(reset! m @(mk/make-mock {})))
|
||||
|
|
316
backend/tests/app/tests/test_bounces_handling.clj
Normal file
316
backend/tests/app/tests/test_bounces_handling.clj
Normal file
|
@ -0,0 +1,316 @@
|
|||
;; 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/.
|
||||
;;
|
||||
;; This Source Code Form is "Incompatible With Secondary Licenses", as
|
||||
;; defined by the Mozilla Public License, v. 2.0.
|
||||
;;
|
||||
;; Copyright (c) 2021 UXBOX Labs SL
|
||||
|
||||
(ns app.tests.test-bounces-handling
|
||||
(:require
|
||||
[clojure.pprint :refer [pprint]]
|
||||
[app.http.awsns :as awsns]
|
||||
[app.emails :as emails]
|
||||
[app.tests.helpers :as th]
|
||||
[app.db :as db]
|
||||
[app.util.time :as dt]
|
||||
[mockery.core :refer [with-mocks]]
|
||||
[clojure.test :as t]))
|
||||
|
||||
(t/use-fixtures :once th/state-init)
|
||||
(t/use-fixtures :each th/database-reset)
|
||||
|
||||
;; (with-mocks [mock {:target 'app.tasks/submit! :return nil}]
|
||||
;; Right now we have many different scenarios what can cause a
|
||||
;; bounce/complain report.
|
||||
|
||||
(defn- decode-row
|
||||
[{:keys [content] :as row}]
|
||||
(cond-> row
|
||||
(db/pgobject? content)
|
||||
(assoc :content (db/decode-transit-pgobject content))))
|
||||
|
||||
(defn bounce-report
|
||||
[{:keys [token email] :or {email "user@example.com"}}]
|
||||
{"notificationType" "Bounce",
|
||||
"bounce" {"feedbackId""010701776d7dd251-c08d280d-9f47-41aa-b959-0094fec779d9-000000",
|
||||
"bounceType" "Permanent",
|
||||
"bounceSubType" "General",
|
||||
"bouncedRecipients" [{"emailAddress" email,
|
||||
"action" "failed",
|
||||
"status" "5.1.1",
|
||||
"diagnosticCode" "smtp; 550 5.1.1 user unknown"}]
|
||||
"timestamp" "2021-02-04T14:41:38.000Z",
|
||||
"remoteMtaIp" "22.22.22.22",
|
||||
"reportingMTA" "dsn; b224-13.smtp-out.eu-central-1.amazonses.com"}
|
||||
"mail" {"timestamp" "2021-02-04T14:41:37.020Z",
|
||||
"source" "no-reply@penpot.app",
|
||||
"sourceArn" "arn:aws:ses:eu-central-1:1111111111:identity/penpot.app",
|
||||
"sourceIp" "22.22.22.22",
|
||||
"sendingAccountId" "1111111111",
|
||||
"messageId" "010701776d7dccfc-3c0094e7-01d7-458d-8100-893320186028-000000",
|
||||
"destination" [email],
|
||||
"headersTruncated" false,
|
||||
"headers" [{"name" "Received","value" "from app-pre"},
|
||||
{"name" "Date","value" "Thu, 4 Feb 2021 14:41:36 +0000 (UTC)"},
|
||||
{"name" "From","value" "Penpot <no-reply@penpot.app>"},
|
||||
{"name" "Reply-To","value" "Penpot <no-reply@penpot.app>"},
|
||||
{"name" "To","value" email},
|
||||
{"name" "Message-ID","value" "<2054501.5.1612449696846@penpot.app>"},
|
||||
{"name" "Subject","value" "test"},
|
||||
{"name" "MIME-Version","value" "1.0"},
|
||||
{"name" "Content-Type","value" "multipart/mixed; boundary=\"----=_Part_3_1150363050.1612449696845\""},
|
||||
{"name" "X-Penpot-Data","value" token}],
|
||||
"commonHeaders" {"from" ["Penpot <no-reply@penpot.app>"],
|
||||
"replyTo" ["Penpot <no-reply@penpot.app>"],
|
||||
"date" "Thu, 4 Feb 2021 14:41:36 +0000 (UTC)",
|
||||
"to" [email],
|
||||
"messageId" "<2054501.5.1612449696846@penpot.app>",
|
||||
"subject" "test"}}})
|
||||
|
||||
|
||||
(defn complaint-report
|
||||
[{:keys [token email] :or {email "user@example.com"}}]
|
||||
{"notificationType" "Complaint",
|
||||
"complaint" {"feedbackId" "0107017771528618-dcf4d61f-c889-4c8b-a6ff-6f0b6553b837-000000",
|
||||
"complaintSubType" nil,
|
||||
"complainedRecipients" [{"emailAddress" email}],
|
||||
"timestamp" "2021-02-05T08:32:49.000Z",
|
||||
"userAgent" "Yahoo!-Mail-Feedback/2.0",
|
||||
"complaintFeedbackType" "abuse",
|
||||
"arrivalDate" "2021-02-05T08:31:15.000Z"},
|
||||
"mail" {"timestamp" "2021-02-05T08:31:13.715Z",
|
||||
"source" "no-reply@penpot.app",
|
||||
"sourceArn" "arn:aws:ses:eu-central-1:111111111:identity/penpot.app",
|
||||
"sourceIp" "22.22.22.22",
|
||||
"sendingAccountId" "11111111111",
|
||||
"messageId" "0107017771510f33-a0696d28-859c-4f08-9211-8392d1b5c226-000000",
|
||||
"destination" ["user@yahoo.com"],
|
||||
"headersTruncated" false,
|
||||
"headers" [{"name" "Received","value" "from smtp"},
|
||||
{"name" "Date","value" "Fri, 5 Feb 2021 08:31:13 +0000 (UTC)"},
|
||||
{"name" "From","value" "Penpot <no-reply@penpot.app>"},
|
||||
{"name" "Reply-To","value" "Penpot <no-reply@penpot.app>"},
|
||||
{"name" "To","value" email},
|
||||
{"name" "Message-ID","value" "<1833063698.279.1612513873536@penpot.app>"},
|
||||
{"name" "Subject","value" "Verify email."},
|
||||
{"name" "MIME-Version","value" "1.0"},
|
||||
{"name" "Content-Type","value" "multipart/mixed; boundary=\"----=_Part_276_1174403980.1612513873535\""},
|
||||
{"name" "X-Penpot-Data","value" token}],
|
||||
"commonHeaders" {"from" ["Penpot <no-reply@penpot.app>"],
|
||||
"replyTo" ["Penpot <no-reply@penpot.app>"],
|
||||
"date" "Fri, 5 Feb 2021 08:31:13 +0000 (UTC)",
|
||||
"to" [email],
|
||||
"messageId" "<1833063698.279.1612513873536@penpot.app>",
|
||||
"subject" "Verify email."}}})
|
||||
|
||||
(t/deftest test-parse-bounce-report
|
||||
(let [profile (th/create-profile* 1)
|
||||
tokens (:app.tokens/tokens th/*system*)
|
||||
cfg {:tokens tokens}
|
||||
report (bounce-report {:token (tokens :generate-predefined
|
||||
{:iss :profile-identity
|
||||
:profile-id (:id profile)})})
|
||||
result (#'awsns/parse-notification cfg report)]
|
||||
;; (pprint result)
|
||||
|
||||
(t/is (= "bounce" (:type result)))
|
||||
(t/is (= "permanent" (:kind result)))
|
||||
(t/is (= "general" (:category result)))
|
||||
(t/is (= ["user@example.com"] (mapv :email (:recipients result))))
|
||||
(t/is (= (:id profile) (:profile-id result)))
|
||||
))
|
||||
|
||||
(t/deftest test-parse-complaint-report
|
||||
(let [profile (th/create-profile* 1)
|
||||
tokens (:app.tokens/tokens th/*system*)
|
||||
cfg {:tokens tokens}
|
||||
report (complaint-report {:token (tokens :generate-predefined
|
||||
{:iss :profile-identity
|
||||
:profile-id (:id profile)})})
|
||||
result (#'awsns/parse-notification cfg report)]
|
||||
;; (pprint result)
|
||||
(t/is (= "complaint" (:type result)))
|
||||
(t/is (= "abuse" (:kind result)))
|
||||
(t/is (= nil (:category result)))
|
||||
(t/is (= ["user@example.com"] (into [] (:recipients result))))
|
||||
(t/is (= (:id profile) (:profile-id result)))
|
||||
))
|
||||
|
||||
(t/deftest test-parse-complaint-report-without-token
|
||||
(let [tokens (:app.tokens/tokens th/*system*)
|
||||
cfg {:tokens tokens}
|
||||
report (complaint-report {:token ""})
|
||||
result (#'awsns/parse-notification cfg report)]
|
||||
(t/is (= "complaint" (:type result)))
|
||||
(t/is (= "abuse" (:kind result)))
|
||||
(t/is (= nil (:category result)))
|
||||
(t/is (= ["user@example.com"] (into [] (:recipients result))))
|
||||
(t/is (= nil (:profile-id result)))
|
||||
))
|
||||
|
||||
(t/deftest test-process-bounce-report
|
||||
(let [profile (th/create-profile* 1)
|
||||
tokens (:app.tokens/tokens th/*system*)
|
||||
pool (:app.db/pool th/*system*)
|
||||
cfg {:tokens tokens :pool pool}
|
||||
report (bounce-report {:token (tokens :generate-predefined
|
||||
{:iss :profile-identity
|
||||
:profile-id (:id profile)})})
|
||||
report (#'awsns/parse-notification cfg report)]
|
||||
|
||||
(#'awsns/process-report cfg report)
|
||||
|
||||
(let [rows (->> (db/query pool :profile-complaint-report {:profile-id (:id profile)})
|
||||
(mapv decode-row))]
|
||||
(t/is (= 1 (count rows)))
|
||||
(t/is (= "bounce" (get-in rows [0 :type])))
|
||||
(t/is (= "2021-02-04T14:41:38.000Z" (get-in rows [0 :content :timestamp]))))
|
||||
|
||||
(let [rows (->> (db/query pool :global-complaint-report :all)
|
||||
(mapv decode-row))]
|
||||
(t/is (= 1 (count rows)))
|
||||
(t/is (= "bounce" (get-in rows [0 :type])))
|
||||
(t/is (= "user@example.com" (get-in rows [0 :email]))))
|
||||
|
||||
(let [prof (db/get-by-id pool :profile (:id profile))]
|
||||
(t/is (false? (:is-muted prof))))
|
||||
|
||||
))
|
||||
|
||||
(t/deftest test-process-complaint-report
|
||||
(let [profile (th/create-profile* 1)
|
||||
tokens (:app.tokens/tokens th/*system*)
|
||||
pool (:app.db/pool th/*system*)
|
||||
cfg {:tokens tokens :pool pool}
|
||||
report (complaint-report {:token (tokens :generate-predefined
|
||||
{:iss :profile-identity
|
||||
:profile-id (:id profile)})})
|
||||
report (#'awsns/parse-notification cfg report)]
|
||||
|
||||
(#'awsns/process-report cfg report)
|
||||
|
||||
(let [rows (->> (db/query pool :profile-complaint-report {:profile-id (:id profile)})
|
||||
(mapv decode-row))]
|
||||
(t/is (= 1 (count rows)))
|
||||
(t/is (= "complaint" (get-in rows [0 :type])))
|
||||
(t/is (= "2021-02-05T08:31:15.000Z" (get-in rows [0 :content :timestamp]))))
|
||||
|
||||
|
||||
(let [rows (->> (db/query pool :global-complaint-report :all)
|
||||
(mapv decode-row))]
|
||||
(t/is (= 1 (count rows)))
|
||||
(t/is (= "complaint" (get-in rows [0 :type])))
|
||||
(t/is (= "user@example.com" (get-in rows [0 :email]))))
|
||||
|
||||
|
||||
(let [prof (db/get-by-id pool :profile (:id profile))]
|
||||
(t/is (false? (:is-muted prof))))
|
||||
|
||||
))
|
||||
|
||||
(t/deftest test-process-bounce-report-to-self
|
||||
(let [profile (th/create-profile* 1)
|
||||
tokens (:app.tokens/tokens th/*system*)
|
||||
pool (:app.db/pool th/*system*)
|
||||
cfg {:tokens tokens :pool pool}
|
||||
report (bounce-report {:email (:email profile)
|
||||
:token (tokens :generate-predefined
|
||||
{:iss :profile-identity
|
||||
:profile-id (:id profile)})})
|
||||
report (#'awsns/parse-notification cfg report)]
|
||||
|
||||
(#'awsns/process-report cfg report)
|
||||
|
||||
(let [rows (db/query pool :profile-complaint-report {:profile-id (:id profile)})]
|
||||
(t/is (= 1 (count rows))))
|
||||
|
||||
(let [rows (db/query pool :global-complaint-report :all)]
|
||||
(t/is (= 1 (count rows))))
|
||||
|
||||
(let [prof (db/get-by-id pool :profile (:id profile))]
|
||||
(t/is (true? (:is-muted prof))))))
|
||||
|
||||
(t/deftest test-process-complaint-report-to-self
|
||||
(let [profile (th/create-profile* 1)
|
||||
tokens (:app.tokens/tokens th/*system*)
|
||||
pool (:app.db/pool th/*system*)
|
||||
cfg {:tokens tokens :pool pool}
|
||||
report (complaint-report {:email (:email profile)
|
||||
:token (tokens :generate-predefined
|
||||
{:iss :profile-identity
|
||||
:profile-id (:id profile)})})
|
||||
report (#'awsns/parse-notification cfg report)]
|
||||
|
||||
(#'awsns/process-report cfg report)
|
||||
|
||||
(let [rows (db/query pool :profile-complaint-report {:profile-id (:id profile)})]
|
||||
(t/is (= 1 (count rows))))
|
||||
|
||||
(let [rows (db/query pool :global-complaint-report :all)]
|
||||
(t/is (= 1 (count rows))))
|
||||
|
||||
(let [prof (db/get-by-id pool :profile (:id profile))]
|
||||
(t/is (true? (:is-muted prof))))))
|
||||
|
||||
(t/deftest test-allow-send-messages-predicate-with-bounces
|
||||
(with-mocks [mock {:target 'app.config/get
|
||||
:return (th/mock-config-get-with
|
||||
{:profile-bounce-threshold 3
|
||||
:profile-complaint-threshold 2})}]
|
||||
(let [profile (th/create-profile* 1)
|
||||
pool (:app.db/pool th/*system*)]
|
||||
(th/create-complaint-for pool {:type :bounce :id (:id profile) :created-at (dt/in-past {:days 8})})
|
||||
(th/create-complaint-for pool {:type :bounce :id (:id profile)})
|
||||
(th/create-complaint-for pool {:type :bounce :id (:id profile)})
|
||||
|
||||
(t/is (true? (emails/allow-send-emails? pool profile)))
|
||||
(t/is (= 4 (:call-count (deref mock))))
|
||||
|
||||
(th/create-complaint-for pool {:type :bounce :id (:id profile)})
|
||||
(t/is (false? (emails/allow-send-emails? pool profile))))))
|
||||
|
||||
|
||||
(t/deftest test-allow-send-messages-predicate-with-complaints
|
||||
(with-mocks [mock {:target 'app.config/get
|
||||
:return (th/mock-config-get-with
|
||||
{:profile-bounce-threshold 3
|
||||
:profile-complaint-threshold 2})}]
|
||||
(let [profile (th/create-profile* 1)
|
||||
pool (:app.db/pool th/*system*)]
|
||||
(th/create-complaint-for pool {:type :bounce :id (:id profile) :created-at (dt/in-past {:days 8})})
|
||||
(th/create-complaint-for pool {:type :bounce :id (:id profile) :created-at (dt/in-past {:days 8})})
|
||||
(th/create-complaint-for pool {:type :bounce :id (:id profile)})
|
||||
(th/create-complaint-for pool {:type :bounce :id (:id profile)})
|
||||
(th/create-complaint-for pool {:type :complaint :id (:id profile)})
|
||||
|
||||
(t/is (true? (emails/allow-send-emails? pool profile)))
|
||||
(t/is (= 4 (:call-count (deref mock))))
|
||||
|
||||
(th/create-complaint-for pool {:type :complaint :id (:id profile)})
|
||||
(t/is (false? (emails/allow-send-emails? pool profile))))))
|
||||
|
||||
(t/deftest test-has-complaint-reports-predicate
|
||||
(let [profile (th/create-profile* 1)
|
||||
pool (:app.db/pool th/*system*)]
|
||||
|
||||
(t/is (false? (emails/has-complaint-reports? pool (:email profile))))
|
||||
|
||||
(th/create-global-complaint-for pool {:type :bounce :email (:email profile)})
|
||||
(t/is (false? (emails/has-complaint-reports? pool (:email profile))))
|
||||
|
||||
(th/create-global-complaint-for pool {:type :complaint :email (:email profile)})
|
||||
(t/is (true? (emails/has-complaint-reports? pool (:email profile))))))
|
||||
|
||||
(t/deftest test-has-bounce-reports-predicate
|
||||
(let [profile (th/create-profile* 1)
|
||||
pool (:app.db/pool th/*system*)]
|
||||
|
||||
(t/is (false? (emails/has-bounce-reports? pool (:email profile))))
|
||||
|
||||
(th/create-global-complaint-for pool {:type :complaint :email (:email profile)})
|
||||
(t/is (false? (emails/has-bounce-reports? pool (:email profile))))
|
||||
|
||||
(th/create-global-complaint-for pool {:type :bounce :email (:email profile)})
|
||||
(t/is (true? (emails/has-bounce-reports? pool (:email profile))))))
|
|
@ -11,7 +11,6 @@
|
|||
(:require
|
||||
[clojure.test :as t]
|
||||
[promesa.core :as p]
|
||||
[mockery.core :refer [with-mock]]
|
||||
[app.db :as db]
|
||||
[app.emails :as emails]
|
||||
[app.tests.helpers :as th]))
|
||||
|
|
|
@ -18,6 +18,9 @@
|
|||
[app.rpc.mutations.profile :as profile]
|
||||
[app.tests.helpers :as th]))
|
||||
|
||||
;; TODO: profile deletion with teams
|
||||
;; TODO: profile deletion with owner teams
|
||||
|
||||
(t/use-fixtures :once th/state-init)
|
||||
(t/use-fixtures :each th/database-reset)
|
||||
|
||||
|
@ -187,11 +190,6 @@
|
|||
(t/testing "not allowed email domain"
|
||||
(t/is (false? (profile/email-domain-in-whitelist? whitelist "username@somedomain.com"))))))
|
||||
|
||||
;; TODO: profile deletion with teams
|
||||
;; TODO: profile deletion with owner teams
|
||||
;; TODO: profile registration
|
||||
;; TODO: profile password recovery
|
||||
|
||||
(t/deftest test-register-when-registration-disabled
|
||||
(with-mocks [mock {:target 'app.config/get
|
||||
:return (th/mock-config-get-with
|
||||
|
@ -267,8 +265,7 @@
|
|||
(t/is (= (:code edata) :email-has-permanent-bounces))))))
|
||||
|
||||
(t/deftest test-register-profile-with-complained-email
|
||||
(with-mocks [mock {:target 'app.emails/send!
|
||||
:return nil}]
|
||||
(with-mocks [mock {:target 'app.emails/send! :return nil}]
|
||||
(let [pool (:app.db/pool th/*system*)
|
||||
data {::th/type :register-profile
|
||||
:email "user@example.com"
|
||||
|
@ -282,3 +279,86 @@
|
|||
|
||||
(let [result (:result out)]
|
||||
(t/is (= (:email data) (:email result)))))))
|
||||
|
||||
(t/deftest test-email-change-request
|
||||
(with-mocks [mock {:target 'app.emails/send! :return nil}]
|
||||
(let [profile (th/create-profile* 1)
|
||||
pool (:app.db/pool th/*system*)
|
||||
data {::th/type :request-email-change
|
||||
:profile-id (:id profile)
|
||||
:email "user1@example.com"}]
|
||||
|
||||
;; without complaints
|
||||
(let [out (th/mutation! data)]
|
||||
;; (th/print-result! out)
|
||||
(t/is (nil? (:result out)))
|
||||
(let [mock (deref mock)]
|
||||
(t/is (= 1 (:call-count mock)))
|
||||
(t/is (true? (:called? mock)))))
|
||||
|
||||
;; with complaints
|
||||
(th/create-global-complaint-for pool {:type :complaint :email (:email data)})
|
||||
(let [out (th/mutation! data)]
|
||||
;; (th/print-result! out)
|
||||
(t/is (nil? (:result out)))
|
||||
(t/is (= 2 (:call-count (deref mock)))))
|
||||
|
||||
;; with bounces
|
||||
(th/create-global-complaint-for pool {:type :bounce :email (:email data)})
|
||||
(let [out (th/mutation! data)
|
||||
error (:error out)]
|
||||
;; (th/print-result! out)
|
||||
(t/is (th/ex-info? error))
|
||||
(t/is (th/ex-of-type? error :validation))
|
||||
(t/is (th/ex-of-code? error :email-has-permanent-bounces))
|
||||
(t/is (= 2 (:call-count (deref mock))))))))
|
||||
|
||||
(t/deftest test-request-profile-recovery
|
||||
(with-mocks [mock {:target 'app.emails/send! :return nil}]
|
||||
(let [profile1 (th/create-profile* 1)
|
||||
profile2 (th/create-profile* 2 {:is-active true})
|
||||
pool (:app.db/pool th/*system*)
|
||||
data {::th/type :request-profile-recovery}]
|
||||
|
||||
;; with invalid email
|
||||
(let [data (assoc data :email "foo@bar.com")
|
||||
out (th/mutation! data)]
|
||||
(t/is (nil? (:result out)))
|
||||
(t/is (= 0 (:call-count (deref mock)))))
|
||||
|
||||
;; with valid email inactive user
|
||||
(let [data (assoc data :email (:email profile1))
|
||||
out (th/mutation! data)
|
||||
error (:error out)]
|
||||
(t/is (= 0 (:call-count (deref mock))))
|
||||
(t/is (th/ex-info? error))
|
||||
(t/is (th/ex-of-type? error :validation))
|
||||
(t/is (th/ex-of-code? error :profile-not-verified)))
|
||||
|
||||
;; with valid email and active user
|
||||
(let [data (assoc data :email (:email profile2))
|
||||
out (th/mutation! data)]
|
||||
;; (th/print-result! out)
|
||||
(t/is (nil? (:result out)))
|
||||
(t/is (= 1 (:call-count (deref mock)))))
|
||||
|
||||
;; with valid email and active user with global complaints
|
||||
(th/create-global-complaint-for pool {:type :complaint :email (:email profile2)})
|
||||
(let [data (assoc data :email (:email profile2))
|
||||
out (th/mutation! data)]
|
||||
;; (th/print-result! out)
|
||||
(t/is (nil? (:result out)))
|
||||
(t/is (= 2 (:call-count (deref mock)))))
|
||||
|
||||
;; with valid email and active user with global bounce
|
||||
(th/create-global-complaint-for pool {:type :bounce :email (:email profile2)})
|
||||
(let [data (assoc data :email (:email profile2))
|
||||
out (th/mutation! data)
|
||||
error (:error out)]
|
||||
;; (th/print-result! out)
|
||||
(t/is (= 2 (:call-count (deref mock))))
|
||||
(t/is (th/ex-info? error))
|
||||
(t/is (th/ex-of-type? error :validation))
|
||||
(t/is (th/ex-of-code? error :email-has-permanent-bounces)))
|
||||
|
||||
)))
|
||||
|
|
88
backend/tests/app/tests/test_services_teams.clj
Normal file
88
backend/tests/app/tests/test_services_teams.clj
Normal file
|
@ -0,0 +1,88 @@
|
|||
;; 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/.
|
||||
;;
|
||||
;; This Source Code Form is "Incompatible With Secondary Licenses", as
|
||||
;; defined by the Mozilla Public License, v. 2.0.
|
||||
;;
|
||||
;; Copyright (c) 2020 UXBOX Labs SL
|
||||
|
||||
(ns app.tests.test-services-teams
|
||||
(:require
|
||||
[app.common.uuid :as uuid]
|
||||
[app.db :as db]
|
||||
[app.http :as http]
|
||||
[app.storage :as sto]
|
||||
[app.tests.helpers :as th]
|
||||
[mockery.core :refer [with-mocks]]
|
||||
[clojure.test :as t]
|
||||
[datoteka.core :as fs]))
|
||||
|
||||
(t/use-fixtures :once th/state-init)
|
||||
(t/use-fixtures :each th/database-reset)
|
||||
|
||||
(t/deftest test-invite-team-member
|
||||
(with-mocks [mock {:target 'app.emails/send! :return nil}]
|
||||
(let [profile1 (th/create-profile* 1 {:is-active true})
|
||||
profile2 (th/create-profile* 2 {:is-active true})
|
||||
profile3 (th/create-profile* 3 {:is-active true :is-muted true})
|
||||
|
||||
team (th/create-team* 1 {:profile-id (:id profile1)})
|
||||
|
||||
pool (:app.db/pool th/*system*)
|
||||
data {::th/type :invite-team-member
|
||||
:team-id (:id team)
|
||||
:role :editor
|
||||
:profile-id (:id profile1)}]
|
||||
|
||||
;; (th/print-result! out)
|
||||
|
||||
;; invite external user without complaints
|
||||
(let [data (assoc data :email "foo@bar.com")
|
||||
out (th/mutation! data)]
|
||||
(t/is (nil? (:result out)))
|
||||
(t/is (= 1 (:call-count (deref mock)))))
|
||||
|
||||
;; invite internal user without complaints
|
||||
(th/reset-mock! mock)
|
||||
(let [data (assoc data :email (:email profile2))
|
||||
out (th/mutation! data)]
|
||||
(t/is (nil? (:result out)))
|
||||
(t/is (= 1 (:call-count (deref mock)))))
|
||||
|
||||
;; invite user with complaint
|
||||
(th/create-global-complaint-for pool {:type :complaint :email "foo@bar.com"})
|
||||
(th/reset-mock! mock)
|
||||
(let [data (assoc data :email "foo@bar.com")
|
||||
out (th/mutation! data)]
|
||||
(t/is (nil? (:result out)))
|
||||
(t/is (= 1 (:call-count (deref mock)))))
|
||||
|
||||
;; invite user with bounce
|
||||
(th/reset-mock! mock)
|
||||
(th/create-global-complaint-for pool {:type :bounce :email "foo@bar.com"})
|
||||
(let [data (assoc data :email "foo@bar.com")
|
||||
out (th/mutation! data)
|
||||
error (:error out)]
|
||||
|
||||
(t/is (th/ex-info? error))
|
||||
(t/is (th/ex-of-type? error :validation))
|
||||
(t/is (th/ex-of-code? error :email-has-permanent-bounces))
|
||||
(t/is (= 0 (:call-count (deref mock)))))
|
||||
|
||||
;; invite internal user that is muted
|
||||
(th/reset-mock! mock)
|
||||
(let [data (assoc data :email (:email profile3))
|
||||
out (th/mutation! data)
|
||||
error (:error out)]
|
||||
|
||||
(t/is (th/ex-info? error))
|
||||
(t/is (th/ex-of-type? error :validation))
|
||||
(t/is (th/ex-of-code? error :member-is-muted))
|
||||
(t/is (= 0 (:call-count (deref mock)))))
|
||||
|
||||
)))
|
||||
|
||||
|
||||
|
||||
|
|
@ -99,6 +99,10 @@ http {
|
|||
proxy_pass http://127.0.0.1:6060/api;
|
||||
}
|
||||
|
||||
location /webhooks {
|
||||
proxy_pass http://127.0.0.1:6060/webhooks;
|
||||
}
|
||||
|
||||
location /dbg {
|
||||
proxy_pass http://127.0.0.1:6060/dbg;
|
||||
}
|
||||
|
|
|
@ -836,6 +836,28 @@
|
|||
"es" : "Autenticación con google esta dehabilitada en el servidor"
|
||||
}
|
||||
},
|
||||
|
||||
"errors.profile-is-muted" : {
|
||||
"translations" : {
|
||||
"en" : "Your profile has emails muted (spam reports or high bounces).",
|
||||
"es" : "Tu perfil tiene los emails silenciados (por reportes de spam o alto indice de rebote)."
|
||||
}
|
||||
},
|
||||
|
||||
"errors.member-is-muted" : {
|
||||
"translations" : {
|
||||
"en" : "The profile you inviting has emails muted (spam reports or high bounces).",
|
||||
"es" : "El perfil que esta invitando tiene los emails silenciados (por reportes de spam o alto indice de rebote)."
|
||||
}
|
||||
},
|
||||
|
||||
"errors.email-has-permanent-bounces" : {
|
||||
"translations" : {
|
||||
"en" : "The email «%s» has many permanent bounce reports.",
|
||||
"es" : "El email «%s» tiene varios reportes de rebote permanente."
|
||||
}
|
||||
},
|
||||
|
||||
"errors.auth.unauthorized" : {
|
||||
"used-in" : [ "src/app/main/ui/auth/login.cljs:89" ],
|
||||
"translations" : {
|
||||
|
|
|
@ -18,9 +18,9 @@
|
|||
[app.util.dom :as dom]
|
||||
[app.util.i18n :as i18n :refer [tr t]]
|
||||
[app.util.router :as rt]
|
||||
[beicon.core :as rx]
|
||||
[cljs.spec.alpha :as s]
|
||||
[cuerdas.core :as str]
|
||||
[beicon.core :as rx]
|
||||
[rumext.alpha :as mf]))
|
||||
|
||||
(s/def ::email ::us/email)
|
||||
|
@ -28,37 +28,41 @@
|
|||
|
||||
(mf/defc recovery-form
|
||||
[]
|
||||
(let [form (fm/use-form :spec ::recovery-request-form
|
||||
:initial {})
|
||||
|
||||
(let [form (fm/use-form :spec ::recovery-request-form :initial {})
|
||||
submitted (mf/use-state false)
|
||||
|
||||
on-error
|
||||
(mf/use-callback
|
||||
(fn [{:keys [code] :as error}]
|
||||
(reset! submitted false)
|
||||
(if (= code :profile-not-verified)
|
||||
(rx/of (dm/error (tr "auth.notifications.profile-not-verified")
|
||||
{:timeout nil}))
|
||||
|
||||
(rx/throw error))))
|
||||
|
||||
on-success
|
||||
(mf/use-callback
|
||||
(fn []
|
||||
(fn [data]
|
||||
(reset! submitted false)
|
||||
(st/emit! (dm/info (tr "auth.notifications.recovery-token-sent"))
|
||||
(rt/nav :auth-login))))
|
||||
|
||||
on-error
|
||||
(mf/use-callback
|
||||
(fn [data {:keys [code] :as error}]
|
||||
(reset! submitted false)
|
||||
(case code
|
||||
:profile-not-verified
|
||||
(rx/of (dm/error (tr "auth.notifications.profile-not-verified") {:timeout nil}))
|
||||
|
||||
:profile-is-muted
|
||||
(rx/of (dm/error (tr "errors.profile-is-muted")))
|
||||
|
||||
:email-has-permanent-bounces
|
||||
(rx/of (dm/error (tr "errors.email-has-permanent-bounces" (:email data))))
|
||||
|
||||
(rx/throw error))))
|
||||
|
||||
on-submit
|
||||
(mf/use-callback
|
||||
(fn []
|
||||
(reset! submitted true)
|
||||
(->> (with-meta (:clean-data @form)
|
||||
{:on-success on-success
|
||||
:on-error on-error})
|
||||
(uda/request-profile-recovery)
|
||||
(st/emit!))))]
|
||||
(let [cdata (:clean-data @form)
|
||||
params (with-meta cdata
|
||||
{:on-success #(on-success cdata %)
|
||||
:on-error #(on-error cdata %)})]
|
||||
(st/emit! (uda/request-profile-recovery params)))))]
|
||||
|
||||
[:& fm/form {:on-submit on-submit
|
||||
:form form}
|
||||
|
|
|
@ -64,13 +64,17 @@
|
|||
(reset! submitted? false)
|
||||
(case (:code error)
|
||||
:registration-disabled
|
||||
(st/emit! (dm/error (tr "errors.registration-disabled")))
|
||||
(rx/of (dm/error (tr "errors.registration-disabled")))
|
||||
|
||||
:email-has-permanent-bounces
|
||||
(let [email (get @form [:data :email])]
|
||||
(rx/of (dm/error (tr "errors.email-has-permanent-bounces" email))))
|
||||
|
||||
:email-already-exists
|
||||
(swap! form assoc-in [:errors :email]
|
||||
{:message "errors.email-already-exists"})
|
||||
|
||||
(st/emit! (dm/error (tr "errors.unexpected-error"))))))
|
||||
(rx/throw error))))
|
||||
|
||||
on-success
|
||||
(mf/use-callback
|
||||
|
|
|
@ -97,13 +97,34 @@
|
|||
(st/emitf (dm/success "Invitation sent successfully")
|
||||
(modal/hide)))
|
||||
|
||||
on-error
|
||||
(mf/use-callback
|
||||
(mf/deps team)
|
||||
(fn [form {:keys [type code] :as error}]
|
||||
(let [email (get @form [:data :email])]
|
||||
(cond
|
||||
(and (= :validation type)
|
||||
(= :profile-is-muted code))
|
||||
(dm/error (tr "errors.profile-is-muted"))
|
||||
|
||||
(and (= :validation type)
|
||||
(= :member-is-muted code))
|
||||
(dm/error (tr "errors.member-is-muted"))
|
||||
|
||||
(and (= :validation type)
|
||||
(= :email-has-permanent-bounces))
|
||||
(dm/error (tr "errors.email-has-permanent-bounces" email))
|
||||
|
||||
:else
|
||||
(dm/error (tr "errors.generic"))))))
|
||||
|
||||
on-submit
|
||||
(mf/use-callback
|
||||
(mf/deps team)
|
||||
(fn [form]
|
||||
(let [params (:clean-data @form)
|
||||
mdata {:on-success (partial on-success form)}]
|
||||
mdata {:on-success (partial on-success form)
|
||||
:on-error (partial on-error form)}]
|
||||
(st/emit! (dd/invite-team-member (with-meta params mdata))))))]
|
||||
|
||||
[:div.modal.dashboard-invite-modal.form-container
|
||||
|
|
|
@ -40,14 +40,20 @@
|
|||
(s/keys :req-un [::email-1 ::email-2]))
|
||||
|
||||
(defn- on-error
|
||||
[form error]
|
||||
(cond
|
||||
(= (:code error) :email-already-exists)
|
||||
[form {:keys [code] :as error}]
|
||||
(case code
|
||||
:email-already-exists
|
||||
(swap! form (fn [data]
|
||||
(let [error {:message (tr "errors.email-already-exists")}]
|
||||
(assoc-in data [:errors :email-1] error))))
|
||||
|
||||
:else
|
||||
:profile-is-muted
|
||||
(rx/of (dm/error (tr "errors.profile-is-muted")))
|
||||
|
||||
:email-has-permanent-bounces
|
||||
(let [email (get @form [:data email])]
|
||||
(rx/of (dm/error (tr "errors.email-has-permanent-bounces" email))))
|
||||
|
||||
(rx/throw error)))
|
||||
|
||||
(defn- on-success
|
||||
|
|
Loading…
Add table
Reference in a new issue