mirror of
https://github.com/penpot/penpot.git
synced 2025-05-19 15:36:11 +02: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."
|
"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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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]
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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))
|
||||||
|
|
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)
|
{: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
|
||||||
|
|
|
@ -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")}
|
||||||
|
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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}
|
(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]
|
||||||
(emails/send! conn emails/password-recovery
|
(let [ptoken (tokens :generate-predefined
|
||||||
{:to (:email profile)
|
{:iss :profile-identity
|
||||||
:token (:token profile)
|
:profile-id (:id profile)})]
|
||||||
:name (:fullname profile)})
|
(emails/send! conn emails/password-recovery
|
||||||
nil)]
|
{:to (:email profile)
|
||||||
|
:token (:token profile)
|
||||||
|
:name (:fullname profile)
|
||||||
|
: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))))))
|
||||||
|
|
|
@ -297,26 +297,48 @@
|
||||||
(sv/defmethod ::invite-team-member
|
(sv/defmethod ::invite-team-member
|
||||||
[{:keys [pool tokens] :as cfg} {:keys [profile-id team-id email role] :as params}]
|
[{:keys [pool tokens] :as cfg} {:keys [profile-id team-id email role] :as params}]
|
||||||
(db/with-atomic [conn pool]
|
(db/with-atomic [conn pool]
|
||||||
(let [perms (teams/check-edition-permissions! conn profile-id team-id)
|
(let [perms (teams/check-edition-permissions! conn profile-id team-id)
|
||||||
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)))
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)))))
|
||||||
|
|
|
@ -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 {})))
|
||||||
|
|
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
|
(: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]))
|
||||||
|
|
|
@ -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)))
|
||||||
|
|
||||||
|
)))
|
||||||
|
|
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;
|
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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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" : {
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue