🎉 Add automatic complaint and bouncing handling.

This commit is contained in:
Andrey Antukh 2021-02-11 17:57:41 +01:00
parent 17229228a3
commit 7708752ad9
26 changed files with 1073 additions and 73 deletions

View file

@ -11,10 +11,10 @@
"A configuration management." "A configuration management."
(:refer-clojure :exclude [get]) (:refer-clojure :exclude [get])
(:require (:require
[clojure.core :as c]
[app.common.spec :as us] [app.common.spec :as us]
[app.common.version :as v] [app.common.version :as v]
[app.util.time :as dt] [app.util.time :as dt]
[clojure.core :as c]
[clojure.spec.alpha :as s] [clojure.spec.alpha :as s]
[cuerdas.core :as str] [cuerdas.core :as str]
[environ.core :refer [env]])) [environ.core :refer [env]]))
@ -54,6 +54,12 @@
:smtp-default-reply-to "Penpot <no-reply@example.com>" :smtp-default-reply-to "Penpot <no-reply@example.com>"
:smtp-default-from "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 :allow-demo-users true
:registration-enabled true :registration-enabled true
:registration-domain-whitelist "" :registration-domain-whitelist ""
@ -100,6 +106,11 @@
(s/def ::feedback-enabled ::us/boolean) (s/def ::feedback-enabled ::us/boolean)
(s/def ::feedback-destination ::us/string) (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 ::error-report-webhook ::us/string)
(s/def ::smtp-enabled ::us/boolean) (s/def ::smtp-enabled ::us/boolean)
@ -187,6 +198,10 @@
::ldap-bind-dn ::ldap-bind-dn
::ldap-bind-password ::ldap-bind-password
::public-uri ::public-uri
::profile-complaint-threshold
::profile-bounce-threshold
::profile-complaint-max-age
::profile-bounce-max-age
::redis-uri ::redis-uri
::registration-domain-whitelist ::registration-domain-whitelist
::registration-enabled ::registration-enabled

View file

@ -5,13 +5,15 @@
;; This Source Code Form is "Incompatible With Secondary Licenses", as ;; This Source Code Form is "Incompatible With Secondary Licenses", as
;; defined by the Mozilla Public License, v. 2.0. ;; 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 (ns app.emails
"Main api for send emails." "Main api for send emails."
(:require (:require
[app.common.spec :as us] [app.common.spec :as us]
[app.config :as cfg] [app.config :as cfg]
[app.db :as db]
[app.db.sql :as sql]
[app.tasks :as tasks] [app.tasks :as tasks]
[app.util.emails :as emails] [app.util.emails :as emails]
[clojure.spec.alpha :as s])) [clojure.spec.alpha :as s]))
@ -41,6 +43,54 @@
:priority 200 :priority 200
:props email}))) :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 ;; --- Emails
(s/def ::subject ::us/string) (s/def ::subject ::us/string)

View file

@ -126,6 +126,9 @@
["/dbg" ["/dbg"
["/error-by-id/:id" {:get (:error-report-handler cfg)}]] ["/error-by-id/:id" {:get (:error-report-handler cfg)}]]
["/webhooks"
["/sns" {:post (:sns-webhook cfg)}]]
["/api" {:middleware [[middleware/format-response-body] ["/api" {:middleware [[middleware/format-response-body]
[middleware/params] [middleware/params]
[middleware/multipart-params] [middleware/multipart-params]

View file

@ -110,6 +110,7 @@
method-fn (get-in rpc [:methods :mutation :login-or-register]) method-fn (get-in rpc [:methods :mutation :login-or-register])
profile (method-fn {:email (:email info) profile (method-fn {:email (:email info)
:backend "github"
:fullname (:fullname info)}) :fullname (:fullname info)})
token (tokens :generate token (tokens :generate
{:iss :auth {:iss :auth

View file

@ -112,6 +112,7 @@
method-fn (get-in rpc [:methods :mutation :login-or-register]) method-fn (get-in rpc [:methods :mutation :login-or-register])
profile (method-fn {:email (:email info) profile (method-fn {:email (:email info)
:backend "gitlab"
:fullname (:fullname info)}) :fullname (:fullname info)})
token (tokens :generate {:iss :auth token (tokens :generate {:iss :auth
:exp (dt/in-future "15m") :exp (dt/in-future "15m")

View file

@ -98,6 +98,7 @@
:code :unable-to-auth)) :code :unable-to-auth))
method-fn (get-in rpc [:methods :mutation :login-or-register]) method-fn (get-in rpc [:methods :mutation :login-or-register])
profile (method-fn {:email (:email info) profile (method-fn {:email (:email info)
:backend "google"
:fullname (:fullname info)}) :fullname (:fullname info)})
token (tokens :generate {:iss :auth token (tokens :generate {:iss :auth
:exp (dt/in-future "15m") :exp (dt/in-future "15m")

View file

@ -64,6 +64,7 @@
:password (:password data)))] :password (:password data)))]
(let [method-fn (get-in rpc [:methods :mutation :login-or-register]) (let [method-fn (get-in rpc [:methods :mutation :login-or-register])
profile (method-fn {:email (:email info) profile (method-fn {:email (:email info)
:backend "ldap"
:fullname (:fullname info)}) :fullname (:fullname info)})
sxf ((:create session) (:id profile)) sxf ((:create session) (:id profile))

View 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)))))

View file

@ -71,6 +71,10 @@
{:pool (ig/ref :app.db/pool) {:pool (ig/ref :app.db/pool)
:cookie-name "auth-token"} :cookie-name "auth-token"}
:app.http.awsns/handler
{:tokens (ig/ref :app.tokens/tokens)
:pool (ig/ref :app.db/pool)}
:app.http/server :app.http/server
{:port (:http-server-port config) {:port (:http-server-port config)
:handler (ig/ref :app.http/router) :handler (ig/ref :app.http/router)
@ -90,6 +94,7 @@
:assets (ig/ref :app.http.assets/handlers) :assets (ig/ref :app.http.assets/handlers)
:svgparse (ig/ref :app.svgparse/handler) :svgparse (ig/ref :app.svgparse/handler)
:storage (ig/ref :app.storage/storage) :storage (ig/ref :app.storage/storage)
:sns-webhook (ig/ref :app.http.awsns/handler)
:error-report-handler (ig/ref :app.error-reporter/handler)} :error-report-handler (ig/ref :app.error-reporter/handler)}
:app.http.assets/handlers :app.http.assets/handlers

View file

@ -148,6 +148,10 @@
{:name "0045-add-index-to-file-change-table" {:name "0045-add-index-to-file-change-table"
:fn (mg/resource "app/migrations/sql/0045-add-index-to-file-change-table.sql")} :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")}
]) ])

View file

@ -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;

View file

@ -55,12 +55,11 @@
(sv/defmethod ::register-profile {:auth false :rlimit :password} (sv/defmethod ::register-profile {:auth false :rlimit :password}
[{:keys [pool tokens session] :as cfg} {:keys [token] :as params}] [{: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 (ex/raise :type :restriction
:code :registration-disabled)) :code :registration-disabled))
(when-not (email-domain-in-whitelist? (:registration-domain-whitelist cfg/config) (when-not (email-domain-in-whitelist? (cfg/get :registration-domain-whitelist) (:email params))
(:email params))
(ex/raise :type :validation (ex/raise :type :validation
:code :email-domain-is-not-allowed)) :code :email-domain-is-not-allowed))
@ -97,20 +96,30 @@
{:transform-response ((:create session) (:id profile))})) {:transform-response ((:create session) (:id profile))}))
;; If no token is provided, send a verification email ;; If no token is provided, send a verification email
(let [token (tokens :generate (let [vtoken (tokens :generate
{:iss :verify-email {:iss :verify-email
:exp (dt/in-future "48h") :exp (dt/in-future "48h")
:profile-id (:id profile) :profile-id (:id profile)
:email (:email 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 (emails/send! conn emails/register
{:to (:email profile) {:to (:email profile)
:name (:fullname profile) :name (:fullname profile)
:token token}) :token vtoken
:extra-data ptoken})
profile))))) profile)))))
(defn email-domain-in-whitelist? (defn email-domain-in-whitelist?
"Returns true if email's domain is in the given whitelist or if given "Returns true if email's domain is in the given whitelist or if given
whitelist is an empty string." whitelist is an empty string."
@ -155,8 +164,8 @@
(defn- create-profile (defn- create-profile
"Create the profile entry on the database with limited input "Create the profile entry on the database with limited input
filling all the other fields with defaults." filling all the other fields with defaults."
[conn {:keys [id fullname email password demo? props is-active] [conn {:keys [id fullname email password demo? props is-active is-muted]
:or {is-active false} :or {is-active false is-muted false}
:as params}] :as params}]
(let [id (or id (uuid/next)) (let [id (or id (uuid/next))
demo? (if (boolean? demo?) demo? false) demo? (if (boolean? demo?) demo? false)
@ -168,9 +177,11 @@
{:id id {:id id
:fullname fullname :fullname fullname
:email (str/lower email) :email (str/lower email)
:auth-backend "penpot"
:password password :password password
:props props :props props
:is-active active? :is-active active?
:is-muted is-muted
:is-demo demo?}) :is-demo demo?})
(update :props db/decode-transit-pgobject)) (update :props db/decode-transit-pgobject))
(catch org.postgresql.util.PSQLException e (catch org.postgresql.util.PSQLException e
@ -252,11 +263,12 @@
;; --- Mutation: Register if not exists ;; --- Mutation: Register if not exists
(s/def ::backend ::us/string)
(s/def ::login-or-register (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} (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] (letfn [(populate-additional-data [conn profile]
(let [data (profile/retrieve-additional-data conn (:id profile))] (let [data (profile/retrieve-additional-data conn (:id profile))]
(merge profile data))) (merge profile data)))
@ -266,6 +278,7 @@
{:id (uuid/next) {:id (uuid/next)
:fullname fullname :fullname fullname
:email (str/lower email) :email (str/lower email)
:auth-backend backend
:is-active true :is-active true
:password "!" :password "!"
:is-demo false})) :is-demo false}))
@ -372,16 +385,30 @@
{:iss :change-email {:iss :change-email
:exp (dt/in-future "15m") :exp (dt/in-future "15m")
:profile-id profile-id :profile-id profile-id
:email email})] :email email})
ptoken (tokens :generate-predefined
{:iss :profile-identity
:profile-id (:id profile)})]
(when (not= email (:email profile)) (when (not= email (:email profile))
(check-profile-existence! conn params)) (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 (emails/send! conn emails/change-email
{:to (:email profile) {:to (:email profile)
:name (:fullname profile) :name (:fullname profile)
:pending-email email :pending-email email
:token token}) :token token
:extra-data ptoken})
nil))) nil)))
(defn select-profile-for-update (defn select-profile-for-update
@ -403,11 +430,15 @@
(assoc profile :token token))) (assoc profile :token token)))
(send-email-notification [conn profile] (send-email-notification [conn profile]
(let [ptoken (tokens :generate-predefined
{:iss :profile-identity
:profile-id (:id profile)})]
(emails/send! conn emails/password-recovery (emails/send! conn emails/password-recovery
{:to (:email profile) {:to (:email profile)
:token (:token profile) :token (:token profile)
:name (:fullname profile)}) :name (:fullname profile)
nil)] :extra-data ptoken})
nil))]
(db/with-atomic [conn pool] (db/with-atomic [conn pool]
(when-let [profile (profile/retrieve-profile-data-by-email conn email)] (when-let [profile (profile/retrieve-profile-data-by-email conn email)]
@ -415,6 +446,17 @@
(ex/raise :type :validation (ex/raise :type :validation
:code :profile-not-verified :code :profile-not-verified
:hint "the user need to validate profile before recover password")) :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 (->> profile
(create-recovery-token) (create-recovery-token)
(send-email-notification conn)))))) (send-email-notification conn))))))

View file

@ -301,22 +301,44 @@
profile (db/get-by-id conn :profile profile-id) profile (db/get-by-id conn :profile profile-id)
member (profile/retrieve-profile-data-by-email conn email) member (profile/retrieve-profile-data-by-email conn email)
team (db/get-by-id conn :team team-id) team (db/get-by-id conn :team team-id)
token (tokens :generate itoken (tokens :generate
{:iss :team-invitation {:iss :team-invitation
:exp (dt/in-future "24h") :exp (dt/in-future "24h")
:profile-id (:id profile) :profile-id (:id profile)
:role role :role role
:team-id team-id :team-id team-id
:member-email (:email member email) :member-email (:email member email)
:member-id (:id member)})] :member-id (:id member)})
ptoken (tokens :generate-predefined
{:iss :profile-identity
:profile-id (:id profile)})]
(when-not (some :is-admin perms) (when-not (some :is-admin perms)
(ex/raise :type :validation (ex/raise :type :validation
:code :insufficient-permissions)) :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 (emails/send! conn emails/invite-to-team
{:to email {:to email
:invited-by (:fullname profile) :invited-by (:fullname profile)
:team (:name team) :team (:name team)
:token token}) :token itoken
:extra-data ptoken})
nil))) nil)))

View file

@ -90,11 +90,19 @@
(let [params (merge {:team-id team-id (let [params (merge {:team-id team-id
:profile-id member-id} :profile-id member-id}
(teams/role->params role)) (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 (db/insert! conn :team-profile-rel params
{:on-conflict-do-nothing true}) {: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) (if (and (uuid? profile-id)
(= member-id profile-id)) (= member-id profile-id))
;; If the current session is already matches the invited ;; If the current session is already matches the invited

View file

@ -60,11 +60,25 @@
(defmethod ig/pre-init-spec ::tokens [_] (defmethod ig/pre-init-spec ::tokens [_]
(s/keys :req-un [::sprops])) (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 (defmethod ig/init-key ::tokens
[_ {:keys [sprops] :as cfg}] [_ {:keys [sprops] :as cfg}]
(let [secret (derive-tokens-secret (:secret-key sprops)) (let [secret (derive-tokens-secret (:secret-key sprops))
cfg (assoc cfg ::secret secret)] cfg (assoc cfg ::secret secret)]
(fn [action params] (fn [action params]
(case action (case action
:generate-predefined (generate-predefined cfg params)
:verify (verify cfg params) :verify (verify cfg params)
:generate (generate cfg params))))) :generate (generate cfg params)))))

View file

@ -31,15 +31,24 @@
[environ.core :refer [env]] [environ.core :refer [env]]
[expound.alpha :as expound] [expound.alpha :as expound]
[integrant.core :as ig] [integrant.core :as ig]
[mockery.core :as mk]
[promesa.core :as p]) [promesa.core :as p])
(:import org.postgresql.ds.PGSimpleDataSource)) (:import org.postgresql.ds.PGSimpleDataSource))
(def ^:dynamic *system* nil) (def ^:dynamic *system* nil)
(def ^:dynamic *pool* 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 (defn state-init
[next] [next]
(let [config (-> (main/build-system-config cfg/test-config) (let [config (-> (main/build-system-config config)
(dissoc :app.srepl/server (dissoc :app.srepl/server
:app.http/server :app.http/server
:app.http/router :app.http/router
@ -300,3 +309,31 @@
(defn sleep (defn sleep
[ms] [ms]
(Thread/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 {})))

View 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))))))

View file

@ -11,7 +11,6 @@
(:require (:require
[clojure.test :as t] [clojure.test :as t]
[promesa.core :as p] [promesa.core :as p]
[mockery.core :refer [with-mock]]
[app.db :as db] [app.db :as db]
[app.emails :as emails] [app.emails :as emails]
[app.tests.helpers :as th])) [app.tests.helpers :as th]))

View file

@ -18,6 +18,9 @@
[app.rpc.mutations.profile :as profile] [app.rpc.mutations.profile :as profile]
[app.tests.helpers :as th])) [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 :once th/state-init)
(t/use-fixtures :each th/database-reset) (t/use-fixtures :each th/database-reset)
@ -187,11 +190,6 @@
(t/testing "not allowed email domain" (t/testing "not allowed email domain"
(t/is (false? (profile/email-domain-in-whitelist? whitelist "username@somedomain.com")))))) (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 (t/deftest test-register-when-registration-disabled
(with-mocks [mock {:target 'app.config/get (with-mocks [mock {:target 'app.config/get
:return (th/mock-config-get-with :return (th/mock-config-get-with
@ -267,8 +265,7 @@
(t/is (= (:code edata) :email-has-permanent-bounces)))))) (t/is (= (:code edata) :email-has-permanent-bounces))))))
(t/deftest test-register-profile-with-complained-email (t/deftest test-register-profile-with-complained-email
(with-mocks [mock {:target 'app.emails/send! (with-mocks [mock {:target 'app.emails/send! :return nil}]
:return nil}]
(let [pool (:app.db/pool th/*system*) (let [pool (:app.db/pool th/*system*)
data {::th/type :register-profile data {::th/type :register-profile
:email "user@example.com" :email "user@example.com"
@ -282,3 +279,86 @@
(let [result (:result out)] (let [result (:result out)]
(t/is (= (:email data) (:email result))))))) (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)))
)))

View 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)))))
)))

View file

@ -99,6 +99,10 @@ http {
proxy_pass http://127.0.0.1:6060/api; proxy_pass http://127.0.0.1:6060/api;
} }
location /webhooks {
proxy_pass http://127.0.0.1:6060/webhooks;
}
location /dbg { location /dbg {
proxy_pass http://127.0.0.1:6060/dbg; proxy_pass http://127.0.0.1:6060/dbg;
} }

View file

@ -836,6 +836,28 @@
"es" : "Autenticación con google esta dehabilitada en el servidor" "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" : { "errors.auth.unauthorized" : {
"used-in" : [ "src/app/main/ui/auth/login.cljs:89" ], "used-in" : [ "src/app/main/ui/auth/login.cljs:89" ],
"translations" : { "translations" : {

View file

@ -18,9 +18,9 @@
[app.util.dom :as dom] [app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr t]] [app.util.i18n :as i18n :refer [tr t]]
[app.util.router :as rt] [app.util.router :as rt]
[beicon.core :as rx]
[cljs.spec.alpha :as s] [cljs.spec.alpha :as s]
[cuerdas.core :as str] [cuerdas.core :as str]
[beicon.core :as rx]
[rumext.alpha :as mf])) [rumext.alpha :as mf]))
(s/def ::email ::us/email) (s/def ::email ::us/email)
@ -28,37 +28,41 @@
(mf/defc recovery-form (mf/defc recovery-form
[] []
(let [form (fm/use-form :spec ::recovery-request-form (let [form (fm/use-form :spec ::recovery-request-form :initial {})
:initial {})
submitted (mf/use-state false) 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 on-success
(mf/use-callback (mf/use-callback
(fn [] (fn [data]
(reset! submitted false) (reset! submitted false)
(st/emit! (dm/info (tr "auth.notifications.recovery-token-sent")) (st/emit! (dm/info (tr "auth.notifications.recovery-token-sent"))
(rt/nav :auth-login)))) (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 on-submit
(mf/use-callback (mf/use-callback
(fn [] (fn []
(reset! submitted true) (reset! submitted true)
(->> (with-meta (:clean-data @form) (let [cdata (:clean-data @form)
{:on-success on-success params (with-meta cdata
:on-error on-error}) {:on-success #(on-success cdata %)
(uda/request-profile-recovery) :on-error #(on-error cdata %)})]
(st/emit!))))] (st/emit! (uda/request-profile-recovery params)))))]
[:& fm/form {:on-submit on-submit [:& fm/form {:on-submit on-submit
:form form} :form form}

View file

@ -64,13 +64,17 @@
(reset! submitted? false) (reset! submitted? false)
(case (:code error) (case (:code error)
:registration-disabled :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 :email-already-exists
(swap! form assoc-in [:errors :email] (swap! form assoc-in [:errors :email]
{:message "errors.email-already-exists"}) {:message "errors.email-already-exists"})
(st/emit! (dm/error (tr "errors.unexpected-error")))))) (rx/throw error))))
on-success on-success
(mf/use-callback (mf/use-callback

View file

@ -97,13 +97,34 @@
(st/emitf (dm/success "Invitation sent successfully") (st/emitf (dm/success "Invitation sent successfully")
(modal/hide))) (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 on-submit
(mf/use-callback (mf/use-callback
(mf/deps team) (mf/deps team)
(fn [form] (fn [form]
(let [params (:clean-data @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))))))] (st/emit! (dd/invite-team-member (with-meta params mdata))))))]
[:div.modal.dashboard-invite-modal.form-container [:div.modal.dashboard-invite-modal.form-container

View file

@ -40,14 +40,20 @@
(s/keys :req-un [::email-1 ::email-2])) (s/keys :req-un [::email-1 ::email-2]))
(defn- on-error (defn- on-error
[form error] [form {:keys [code] :as error}]
(cond (case code
(= (:code error) :email-already-exists) :email-already-exists
(swap! form (fn [data] (swap! form (fn [data]
(let [error {:message (tr "errors.email-already-exists")}] (let [error {:message (tr "errors.email-already-exists")}]
(assoc-in data [:errors :email-1] error)))) (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))) (rx/throw error)))
(defn- on-success (defn- on-success