From 7708752ad9e7651c5b3df8901dd1ad831a4d634d Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 11 Feb 2021 17:57:41 +0100 Subject: [PATCH] :tada: Add automatic complaint and bouncing handling. --- backend/src/app/config.clj | 17 +- backend/src/app/emails.clj | 52 ++- backend/src/app/http.clj | 3 + backend/src/app/http/auth/github.clj | 1 + backend/src/app/http/auth/gitlab.clj | 1 + backend/src/app/http/auth/google.clj | 1 + backend/src/app/http/auth/ldap.clj | 1 + backend/src/app/http/awsns.clj | 207 ++++++++++++ backend/src/app/main.clj | 5 + backend/src/app/migrations.clj | 4 + .../sql/0046-add-profile-complaint-table.sql | 45 +++ backend/src/app/rpc/mutations/profile.clj | 84 +++-- backend/src/app/rpc/mutations/teams.clj | 48 ++- .../src/app/rpc/mutations/verify_token.clj | 10 +- backend/src/app/tokens.clj | 14 + backend/tests/app/tests/helpers.clj | 39 ++- .../tests/app/tests/test_bounces_handling.clj | 316 ++++++++++++++++++ backend/tests/app/tests/test_emails.clj | 1 - .../tests/app/tests/test_services_profile.clj | 94 +++++- .../tests/app/tests/test_services_teams.clj | 88 +++++ docker/devenv/files/nginx.conf | 4 + frontend/resources/locales.json | 22 ++ .../app/main/ui/auth/recovery_request.cljs | 44 +-- frontend/src/app/main/ui/auth/register.cljs | 8 +- frontend/src/app/main/ui/dashboard/team.cljs | 23 +- .../app/main/ui/settings/change_email.cljs | 14 +- 26 files changed, 1073 insertions(+), 73 deletions(-) create mode 100644 backend/src/app/http/awsns.clj create mode 100644 backend/src/app/migrations/sql/0046-add-profile-complaint-table.sql create mode 100644 backend/tests/app/tests/test_bounces_handling.clj create mode 100644 backend/tests/app/tests/test_services_teams.clj diff --git a/backend/src/app/config.clj b/backend/src/app/config.clj index d5f1ce587..51741e329 100644 --- a/backend/src/app/config.clj +++ b/backend/src/app/config.clj @@ -11,10 +11,10 @@ "A configuration management." (:refer-clojure :exclude [get]) (:require - [clojure.core :as c] [app.common.spec :as us] [app.common.version :as v] [app.util.time :as dt] + [clojure.core :as c] [clojure.spec.alpha :as s] [cuerdas.core :as str] [environ.core :refer [env]])) @@ -54,6 +54,12 @@ :smtp-default-reply-to "Penpot " :smtp-default-from "Penpot " + :profile-complaint-max-age (dt/duration {:days 7}) + :profile-complaint-threshold 2 + + :profile-bounce-max-age (dt/duration {:days 7}) + :profile-bounce-threshold 10 + :allow-demo-users true :registration-enabled true :registration-domain-whitelist "" @@ -100,6 +106,11 @@ (s/def ::feedback-enabled ::us/boolean) (s/def ::feedback-destination ::us/string) +(s/def ::profile-complaint-max-age ::dt/duration) +(s/def ::profile-complaint-threshold ::us/integer) +(s/def ::profile-bounce-max-age ::dt/duration) +(s/def ::profile-bounce-threshold ::us/integer) + (s/def ::error-report-webhook ::us/string) (s/def ::smtp-enabled ::us/boolean) @@ -187,6 +198,10 @@ ::ldap-bind-dn ::ldap-bind-password ::public-uri + ::profile-complaint-threshold + ::profile-bounce-threshold + ::profile-complaint-max-age + ::profile-bounce-max-age ::redis-uri ::registration-domain-whitelist ::registration-enabled diff --git a/backend/src/app/emails.clj b/backend/src/app/emails.clj index 68441d821..74fbaf84b 100644 --- a/backend/src/app/emails.clj +++ b/backend/src/app/emails.clj @@ -5,13 +5,15 @@ ;; This Source Code Form is "Incompatible With Secondary Licenses", as ;; defined by the Mozilla Public License, v. 2.0. ;; -;; Copyright (c) 2020 UXBOX Labs SL +;; Copyright (c) 2020-2021 UXBOX Labs SL (ns app.emails "Main api for send emails." (:require [app.common.spec :as us] [app.config :as cfg] + [app.db :as db] + [app.db.sql :as sql] [app.tasks :as tasks] [app.util.emails :as emails] [clojure.spec.alpha :as s])) @@ -41,6 +43,54 @@ :priority 200 :props email}))) + +(def sql:profile-complaint-report + "select (select count(*) + from profile_complaint_report + where type = 'complaint' + and profile_id = ? + and created_at > now() - ?::interval) as complaints, + (select count(*) + from profile_complaint_report + where type = 'bounce' + and profile_id = ? + and created_at > now() - ?::interval) as bounces;") + +(defn allow-send-emails? + [conn profile] + (when-not (:is-muted profile false) + (let [complaint-threshold (cfg/get :profile-complaint-threshold) + complaint-max-age (cfg/get :profile-complaint-max-age) + bounce-threshold (cfg/get :profile-bounce-threshold) + bounce-max-age (cfg/get :profile-bounce-max-age) + + {:keys [complaints bounces] :as result} + (db/exec-one! conn [sql:profile-complaint-report + (:id profile) + (db/interval complaint-max-age) + (:id profile) + (db/interval bounce-max-age)])] + + (and (< complaints complaint-threshold) + (< bounces bounce-threshold))))) + +(defn has-complaint-reports? + ([conn email] (has-complaint-reports? conn email nil)) + ([conn email {:keys [threshold] :or {threshold 1}}] + (let [reports (db/exec! conn (sql/select :global-complaint-report + {:email email :type "complaint"} + {:limit 10}))] + (>= (count reports) threshold)))) + +(defn has-bounce-reports? + ([conn email] (has-bounce-reports? conn email nil)) + ([conn email {:keys [threshold] :or {threshold 1}}] + (let [reports (db/exec! conn (sql/select :global-complaint-report + {:email email :type "bounce"} + {:limit 10}))] + (>= (count reports) threshold)))) + + ;; --- Emails (s/def ::subject ::us/string) diff --git a/backend/src/app/http.clj b/backend/src/app/http.clj index ffe534971..1158f34b9 100644 --- a/backend/src/app/http.clj +++ b/backend/src/app/http.clj @@ -126,6 +126,9 @@ ["/dbg" ["/error-by-id/:id" {:get (:error-report-handler cfg)}]] + ["/webhooks" + ["/sns" {:post (:sns-webhook cfg)}]] + ["/api" {:middleware [[middleware/format-response-body] [middleware/params] [middleware/multipart-params] diff --git a/backend/src/app/http/auth/github.clj b/backend/src/app/http/auth/github.clj index c01200126..26753bc05 100644 --- a/backend/src/app/http/auth/github.clj +++ b/backend/src/app/http/auth/github.clj @@ -110,6 +110,7 @@ method-fn (get-in rpc [:methods :mutation :login-or-register]) profile (method-fn {:email (:email info) + :backend "github" :fullname (:fullname info)}) token (tokens :generate {:iss :auth diff --git a/backend/src/app/http/auth/gitlab.clj b/backend/src/app/http/auth/gitlab.clj index 5e78f1071..f253016b0 100644 --- a/backend/src/app/http/auth/gitlab.clj +++ b/backend/src/app/http/auth/gitlab.clj @@ -112,6 +112,7 @@ method-fn (get-in rpc [:methods :mutation :login-or-register]) profile (method-fn {:email (:email info) + :backend "gitlab" :fullname (:fullname info)}) token (tokens :generate {:iss :auth :exp (dt/in-future "15m") diff --git a/backend/src/app/http/auth/google.clj b/backend/src/app/http/auth/google.clj index 12a2d7034..653352d42 100644 --- a/backend/src/app/http/auth/google.clj +++ b/backend/src/app/http/auth/google.clj @@ -98,6 +98,7 @@ :code :unable-to-auth)) method-fn (get-in rpc [:methods :mutation :login-or-register]) profile (method-fn {:email (:email info) + :backend "google" :fullname (:fullname info)}) token (tokens :generate {:iss :auth :exp (dt/in-future "15m") diff --git a/backend/src/app/http/auth/ldap.clj b/backend/src/app/http/auth/ldap.clj index ec6dc16dc..41e260e9f 100644 --- a/backend/src/app/http/auth/ldap.clj +++ b/backend/src/app/http/auth/ldap.clj @@ -64,6 +64,7 @@ :password (:password data)))] (let [method-fn (get-in rpc [:methods :mutation :login-or-register]) profile (method-fn {:email (:email info) + :backend "ldap" :fullname (:fullname info)}) sxf ((:create session) (:id profile)) diff --git a/backend/src/app/http/awsns.clj b/backend/src/app/http/awsns.clj new file mode 100644 index 000000000..9443b90eb --- /dev/null +++ b/backend/src/app/http/awsns.clj @@ -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 + +(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))))) + + diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj index e26d84a92..6f2203445 100644 --- a/backend/src/app/main.clj +++ b/backend/src/app/main.clj @@ -71,6 +71,10 @@ {:pool (ig/ref :app.db/pool) :cookie-name "auth-token"} + :app.http.awsns/handler + {:tokens (ig/ref :app.tokens/tokens) + :pool (ig/ref :app.db/pool)} + :app.http/server {:port (:http-server-port config) :handler (ig/ref :app.http/router) @@ -90,6 +94,7 @@ :assets (ig/ref :app.http.assets/handlers) :svgparse (ig/ref :app.svgparse/handler) :storage (ig/ref :app.storage/storage) + :sns-webhook (ig/ref :app.http.awsns/handler) :error-report-handler (ig/ref :app.error-reporter/handler)} :app.http.assets/handlers diff --git a/backend/src/app/migrations.clj b/backend/src/app/migrations.clj index 2d247e11c..7b0a19e6c 100644 --- a/backend/src/app/migrations.clj +++ b/backend/src/app/migrations.clj @@ -148,6 +148,10 @@ {:name "0045-add-index-to-file-change-table" :fn (mg/resource "app/migrations/sql/0045-add-index-to-file-change-table.sql")} + + {:name "0046-add-profile-complaint-table" + :fn (mg/resource "app/migrations/sql/0046-add-profile-complaint-table.sql")} + ]) diff --git a/backend/src/app/migrations/sql/0046-add-profile-complaint-table.sql b/backend/src/app/migrations/sql/0046-add-profile-complaint-table.sql new file mode 100644 index 000000000..431f73744 --- /dev/null +++ b/backend/src/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; diff --git a/backend/src/app/rpc/mutations/profile.clj b/backend/src/app/rpc/mutations/profile.clj index 3b18c3a36..81ab78746 100644 --- a/backend/src/app/rpc/mutations/profile.clj +++ b/backend/src/app/rpc/mutations/profile.clj @@ -55,12 +55,11 @@ (sv/defmethod ::register-profile {:auth false :rlimit :password} [{:keys [pool tokens session] :as cfg} {:keys [token] :as params}] - (when-not (:registration-enabled cfg/config) + (when-not (cfg/get :registration-enabled) (ex/raise :type :restriction :code :registration-disabled)) - (when-not (email-domain-in-whitelist? (:registration-domain-whitelist cfg/config) - (:email params)) + (when-not (email-domain-in-whitelist? (cfg/get :registration-domain-whitelist) (:email params)) (ex/raise :type :validation :code :email-domain-is-not-allowed)) @@ -97,20 +96,30 @@ {:transform-response ((:create session) (:id profile))})) ;; If no token is provided, send a verification email - (let [token (tokens :generate - {:iss :verify-email - :exp (dt/in-future "48h") - :profile-id (:id profile) - :email (:email profile)})] + (let [vtoken (tokens :generate + {:iss :verify-email + :exp (dt/in-future "48h") + :profile-id (:id profile) + :email (:email profile)}) + ptoken (tokens :generate-predefined + {:iss :profile-identity + :profile-id (:id profile)})] + + ;; Don't allow proceed in register page if the email is + ;; already reported as permanent bounced + (when (emails/has-bounce-reports? conn (:email profile)) + (ex/raise :type :validation + :code :email-has-permanent-bounces + :hint "looks like the email has one or many bounces reported")) (emails/send! conn emails/register {:to (:email profile) :name (:fullname profile) - :token token}) + :token vtoken + :extra-data ptoken}) profile))))) - (defn email-domain-in-whitelist? "Returns true if email's domain is in the given whitelist or if given whitelist is an empty string." @@ -155,8 +164,8 @@ (defn- create-profile "Create the profile entry on the database with limited input filling all the other fields with defaults." - [conn {:keys [id fullname email password demo? props is-active] - :or {is-active false} + [conn {:keys [id fullname email password demo? props is-active is-muted] + :or {is-active false is-muted false} :as params}] (let [id (or id (uuid/next)) demo? (if (boolean? demo?) demo? false) @@ -168,9 +177,11 @@ {:id id :fullname fullname :email (str/lower email) + :auth-backend "penpot" :password password :props props :is-active active? + :is-muted is-muted :is-demo demo?}) (update :props db/decode-transit-pgobject)) (catch org.postgresql.util.PSQLException e @@ -252,11 +263,12 @@ ;; --- Mutation: Register if not exists +(s/def ::backend ::us/string) (s/def ::login-or-register - (s/keys :req-un [::email ::fullname])) + (s/keys :req-un [::email ::fullname ::backend])) (sv/defmethod ::login-or-register {:auth false} - [{:keys [pool] :as cfg} {:keys [email fullname] :as params}] + [{:keys [pool] :as cfg} {:keys [email backend fullname] :as params}] (letfn [(populate-additional-data [conn profile] (let [data (profile/retrieve-additional-data conn (:id profile))] (merge profile data))) @@ -266,6 +278,7 @@ {:id (uuid/next) :fullname fullname :email (str/lower email) + :auth-backend backend :is-active true :password "!" :is-demo false})) @@ -372,16 +385,30 @@ {:iss :change-email :exp (dt/in-future "15m") :profile-id profile-id - :email email})] + :email email}) + ptoken (tokens :generate-predefined + {:iss :profile-identity + :profile-id (:id profile)})] (when (not= email (:email profile)) (check-profile-existence! conn params)) + (when-not (emails/allow-send-emails? conn profile) + (ex/raise :type :validation + :code :profile-is-muted + :hint "looks like the profile has reported repeatedly as spam or has permanent bounces.")) + + (when (emails/has-bounce-reports? conn email) + (ex/raise :type :validation + :code :email-has-permanent-bounces + :hint "looks like the email you invite has been repeatedly reported as spam or permanent bounce")) + (emails/send! conn emails/change-email {:to (:email profile) :name (:fullname profile) :pending-email email - :token token}) + :token token + :extra-data ptoken}) nil))) (defn select-profile-for-update @@ -403,11 +430,15 @@ (assoc profile :token token))) (send-email-notification [conn profile] - (emails/send! conn emails/password-recovery - {:to (:email profile) - :token (:token profile) - :name (:fullname profile)}) - nil)] + (let [ptoken (tokens :generate-predefined + {:iss :profile-identity + :profile-id (:id profile)})] + (emails/send! conn emails/password-recovery + {:to (:email profile) + :token (:token profile) + :name (:fullname profile) + :extra-data ptoken}) + nil))] (db/with-atomic [conn pool] (when-let [profile (profile/retrieve-profile-data-by-email conn email)] @@ -415,6 +446,17 @@ (ex/raise :type :validation :code :profile-not-verified :hint "the user need to validate profile before recover password")) + + (when-not (emails/allow-send-emails? conn profile) + (ex/raise :type :validation + :code :profile-is-muted + :hint "looks like the profile has reported repeatedly as spam or has permanent bounces.")) + + (when (emails/has-bounce-reports? conn (:email profile)) + (ex/raise :type :validation + :code :email-has-permanent-bounces + :hint "looks like the email you invite has been repeatedly reported as spam or permanent bounce")) + (->> profile (create-recovery-token) (send-email-notification conn)))))) diff --git a/backend/src/app/rpc/mutations/teams.clj b/backend/src/app/rpc/mutations/teams.clj index 5abbb4152..c5e37d001 100644 --- a/backend/src/app/rpc/mutations/teams.clj +++ b/backend/src/app/rpc/mutations/teams.clj @@ -297,26 +297,48 @@ (sv/defmethod ::invite-team-member [{:keys [pool tokens] :as cfg} {:keys [profile-id team-id email role] :as params}] (db/with-atomic [conn pool] - (let [perms (teams/check-edition-permissions! conn profile-id team-id) - profile (db/get-by-id conn :profile profile-id) - member (profile/retrieve-profile-data-by-email conn email) - team (db/get-by-id conn :team team-id) - token (tokens :generate - {:iss :team-invitation - :exp (dt/in-future "24h") - :profile-id (:id profile) - :role role - :team-id team-id - :member-email (:email member email) - :member-id (:id member)})] + (let [perms (teams/check-edition-permissions! conn profile-id team-id) + profile (db/get-by-id conn :profile profile-id) + member (profile/retrieve-profile-data-by-email conn email) + team (db/get-by-id conn :team team-id) + itoken (tokens :generate + {:iss :team-invitation + :exp (dt/in-future "24h") + :profile-id (:id profile) + :role role + :team-id team-id + :member-email (:email member email) + :member-id (:id member)}) + ptoken (tokens :generate-predefined + {:iss :profile-identity + :profile-id (:id profile)})] (when-not (some :is-admin perms) (ex/raise :type :validation :code :insufficient-permissions)) + ;; First check if the current profile is allowed to send emails. + (when-not (emails/allow-send-emails? conn profile) + (ex/raise :type :validation + :code :profile-is-muted + :hint "looks like the profile has reported repeatedly as spam or has permanent bounces")) + + (when (and member (not (emails/allow-send-emails? conn member))) + (ex/raise :type :validation + :code :member-is-muted + :hint "looks like the profile has reported repeatedly as spam or has permanent bounces")) + + ;; Secondly check if the invited member email is part of the + ;; global spam/bounce report. + (when (emails/has-bounce-reports? conn email) + (ex/raise :type :validation + :code :email-has-permanent-bounces + :hint "looks like the email you invite has been repeatedly reported as spam or permanent bounce")) + (emails/send! conn emails/invite-to-team {:to email :invited-by (:fullname profile) :team (:name team) - :token token}) + :token itoken + :extra-data ptoken}) nil))) diff --git a/backend/src/app/rpc/mutations/verify_token.clj b/backend/src/app/rpc/mutations/verify_token.clj index 29eefd5ca..357e20e90 100644 --- a/backend/src/app/rpc/mutations/verify_token.clj +++ b/backend/src/app/rpc/mutations/verify_token.clj @@ -90,11 +90,19 @@ (let [params (merge {:team-id team-id :profile-id member-id} (teams/role->params role)) - claims (assoc claims :state :created)] + claims (assoc claims :state :created) + member (profile/retrieve-profile conn member-id)] (db/insert! conn :team-profile-rel params {:on-conflict-do-nothing true}) + ;; If profile is not yet verified, mark it as verified because + ;; accepting an invitation link serves as verification. + (when-not (:is-active member) + (db/update! conn :profile + {:is-active true} + {:id member-id})) + (if (and (uuid? profile-id) (= member-id profile-id)) ;; If the current session is already matches the invited diff --git a/backend/src/app/tokens.clj b/backend/src/app/tokens.clj index a71c49dae..4abbca855 100644 --- a/backend/src/app/tokens.clj +++ b/backend/src/app/tokens.clj @@ -60,11 +60,25 @@ (defmethod ig/pre-init-spec ::tokens [_] (s/keys :req-un [::sprops])) +(defn- generate-predefined + [cfg {:keys [iss profile-id] :as params}] + (case iss + :profile-identity + (do + (us/verify uuid? profile-id) + (generate cfg (assoc params + :exp (dt/in-future {:days 30})))) + + (ex/raise :type :internal + :code :not-implemented + :hint "no predefined token"))) + (defmethod ig/init-key ::tokens [_ {:keys [sprops] :as cfg}] (let [secret (derive-tokens-secret (:secret-key sprops)) cfg (assoc cfg ::secret secret)] (fn [action params] (case action + :generate-predefined (generate-predefined cfg params) :verify (verify cfg params) :generate (generate cfg params))))) diff --git a/backend/tests/app/tests/helpers.clj b/backend/tests/app/tests/helpers.clj index 3709b5465..81c442e68 100644 --- a/backend/tests/app/tests/helpers.clj +++ b/backend/tests/app/tests/helpers.clj @@ -31,15 +31,24 @@ [environ.core :refer [env]] [expound.alpha :as expound] [integrant.core :as ig] + [mockery.core :as mk] [promesa.core :as p]) (:import org.postgresql.ds.PGSimpleDataSource)) (def ^:dynamic *system* nil) (def ^:dynamic *pool* nil) +(def config + (merge {:redis-uri "redis://redis/1" + :database-uri "postgresql://postgres/penpot_test" + :storage-fs-directory "/tmp/app/storage" + :migrations-verbose false} + cfg/config)) + + (defn state-init [next] - (let [config (-> (main/build-system-config cfg/test-config) + (let [config (-> (main/build-system-config config) (dissoc :app.srepl/server :app.http/server :app.http/router @@ -300,3 +309,31 @@ (defn sleep [ms] (Thread/sleep ms)) + +(defn mock-config-get-with + "Helper for mock app.config/get" + [data] + (fn + ([key] (get (merge config data) key)) + ([key default] (get (merge config data) key default)))) + +(defn create-complaint-for + [conn {:keys [id created-at type]}] + (db/insert! conn :profile-complaint-report + {:profile-id id + :created-at (or created-at (dt/now)) + :type (name type) + :content (db/tjson {})})) + +(defn create-global-complaint-for + [conn {:keys [email type created-at]}] + (db/insert! conn :global-complaint-report + {:email email + :type (name type) + :created-at (or created-at (dt/now)) + :content (db/tjson {})})) + + +(defn reset-mock! + [m] + (reset! m @(mk/make-mock {}))) diff --git a/backend/tests/app/tests/test_bounces_handling.clj b/backend/tests/app/tests/test_bounces_handling.clj new file mode 100644 index 000000000..065ada03f --- /dev/null +++ b/backend/tests/app/tests/test_bounces_handling.clj @@ -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 "}, + {"name" "Reply-To","value" "Penpot "}, + {"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 "], + "replyTo" ["Penpot "], + "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 "}, + {"name" "Reply-To","value" "Penpot "}, + {"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 "], + "replyTo" ["Penpot "], + "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)))))) diff --git a/backend/tests/app/tests/test_emails.clj b/backend/tests/app/tests/test_emails.clj index 1ab3eca1e..7381f510a 100644 --- a/backend/tests/app/tests/test_emails.clj +++ b/backend/tests/app/tests/test_emails.clj @@ -11,7 +11,6 @@ (:require [clojure.test :as t] [promesa.core :as p] - [mockery.core :refer [with-mock]] [app.db :as db] [app.emails :as emails] [app.tests.helpers :as th])) diff --git a/backend/tests/app/tests/test_services_profile.clj b/backend/tests/app/tests/test_services_profile.clj index e6c9bf6ab..b355285e5 100644 --- a/backend/tests/app/tests/test_services_profile.clj +++ b/backend/tests/app/tests/test_services_profile.clj @@ -18,6 +18,9 @@ [app.rpc.mutations.profile :as profile] [app.tests.helpers :as th])) +;; TODO: profile deletion with teams +;; TODO: profile deletion with owner teams + (t/use-fixtures :once th/state-init) (t/use-fixtures :each th/database-reset) @@ -187,11 +190,6 @@ (t/testing "not allowed email domain" (t/is (false? (profile/email-domain-in-whitelist? whitelist "username@somedomain.com")))))) -;; TODO: profile deletion with teams -;; TODO: profile deletion with owner teams -;; TODO: profile registration -;; TODO: profile password recovery - (t/deftest test-register-when-registration-disabled (with-mocks [mock {:target 'app.config/get :return (th/mock-config-get-with @@ -267,8 +265,7 @@ (t/is (= (:code edata) :email-has-permanent-bounces)))))) (t/deftest test-register-profile-with-complained-email - (with-mocks [mock {:target 'app.emails/send! - :return nil}] + (with-mocks [mock {:target 'app.emails/send! :return nil}] (let [pool (:app.db/pool th/*system*) data {::th/type :register-profile :email "user@example.com" @@ -282,3 +279,86 @@ (let [result (:result out)] (t/is (= (:email data) (:email result))))))) + +(t/deftest test-email-change-request + (with-mocks [mock {:target 'app.emails/send! :return nil}] + (let [profile (th/create-profile* 1) + pool (:app.db/pool th/*system*) + data {::th/type :request-email-change + :profile-id (:id profile) + :email "user1@example.com"}] + + ;; without complaints + (let [out (th/mutation! data)] + ;; (th/print-result! out) + (t/is (nil? (:result out))) + (let [mock (deref mock)] + (t/is (= 1 (:call-count mock))) + (t/is (true? (:called? mock))))) + + ;; with complaints + (th/create-global-complaint-for pool {:type :complaint :email (:email data)}) + (let [out (th/mutation! data)] + ;; (th/print-result! out) + (t/is (nil? (:result out))) + (t/is (= 2 (:call-count (deref mock))))) + + ;; with bounces + (th/create-global-complaint-for pool {:type :bounce :email (:email data)}) + (let [out (th/mutation! data) + error (:error out)] + ;; (th/print-result! out) + (t/is (th/ex-info? error)) + (t/is (th/ex-of-type? error :validation)) + (t/is (th/ex-of-code? error :email-has-permanent-bounces)) + (t/is (= 2 (:call-count (deref mock)))))))) + +(t/deftest test-request-profile-recovery + (with-mocks [mock {:target 'app.emails/send! :return nil}] + (let [profile1 (th/create-profile* 1) + profile2 (th/create-profile* 2 {:is-active true}) + pool (:app.db/pool th/*system*) + data {::th/type :request-profile-recovery}] + + ;; with invalid email + (let [data (assoc data :email "foo@bar.com") + out (th/mutation! data)] + (t/is (nil? (:result out))) + (t/is (= 0 (:call-count (deref mock))))) + + ;; with valid email inactive user + (let [data (assoc data :email (:email profile1)) + out (th/mutation! data) + error (:error out)] + (t/is (= 0 (:call-count (deref mock)))) + (t/is (th/ex-info? error)) + (t/is (th/ex-of-type? error :validation)) + (t/is (th/ex-of-code? error :profile-not-verified))) + + ;; with valid email and active user + (let [data (assoc data :email (:email profile2)) + out (th/mutation! data)] + ;; (th/print-result! out) + (t/is (nil? (:result out))) + (t/is (= 1 (:call-count (deref mock))))) + + ;; with valid email and active user with global complaints + (th/create-global-complaint-for pool {:type :complaint :email (:email profile2)}) + (let [data (assoc data :email (:email profile2)) + out (th/mutation! data)] + ;; (th/print-result! out) + (t/is (nil? (:result out))) + (t/is (= 2 (:call-count (deref mock))))) + + ;; with valid email and active user with global bounce + (th/create-global-complaint-for pool {:type :bounce :email (:email profile2)}) + (let [data (assoc data :email (:email profile2)) + out (th/mutation! data) + error (:error out)] + ;; (th/print-result! out) + (t/is (= 2 (:call-count (deref mock)))) + (t/is (th/ex-info? error)) + (t/is (th/ex-of-type? error :validation)) + (t/is (th/ex-of-code? error :email-has-permanent-bounces))) + + ))) diff --git a/backend/tests/app/tests/test_services_teams.clj b/backend/tests/app/tests/test_services_teams.clj new file mode 100644 index 000000000..da6ddb688 --- /dev/null +++ b/backend/tests/app/tests/test_services_teams.clj @@ -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))))) + + ))) + + + + diff --git a/docker/devenv/files/nginx.conf b/docker/devenv/files/nginx.conf index 04c8fd453..a4b13cb73 100644 --- a/docker/devenv/files/nginx.conf +++ b/docker/devenv/files/nginx.conf @@ -99,6 +99,10 @@ http { proxy_pass http://127.0.0.1:6060/api; } + location /webhooks { + proxy_pass http://127.0.0.1:6060/webhooks; + } + location /dbg { proxy_pass http://127.0.0.1:6060/dbg; } diff --git a/frontend/resources/locales.json b/frontend/resources/locales.json index 46f51b44e..322c7bd72 100644 --- a/frontend/resources/locales.json +++ b/frontend/resources/locales.json @@ -836,6 +836,28 @@ "es" : "Autenticación con google esta dehabilitada en el servidor" } }, + + "errors.profile-is-muted" : { + "translations" : { + "en" : "Your profile has emails muted (spam reports or high bounces).", + "es" : "Tu perfil tiene los emails silenciados (por reportes de spam o alto indice de rebote)." + } + }, + + "errors.member-is-muted" : { + "translations" : { + "en" : "The profile you inviting has emails muted (spam reports or high bounces).", + "es" : "El perfil que esta invitando tiene los emails silenciados (por reportes de spam o alto indice de rebote)." + } + }, + + "errors.email-has-permanent-bounces" : { + "translations" : { + "en" : "The email «%s» has many permanent bounce reports.", + "es" : "El email «%s» tiene varios reportes de rebote permanente." + } + }, + "errors.auth.unauthorized" : { "used-in" : [ "src/app/main/ui/auth/login.cljs:89" ], "translations" : { diff --git a/frontend/src/app/main/ui/auth/recovery_request.cljs b/frontend/src/app/main/ui/auth/recovery_request.cljs index 19f4daadd..9a0e0db0b 100644 --- a/frontend/src/app/main/ui/auth/recovery_request.cljs +++ b/frontend/src/app/main/ui/auth/recovery_request.cljs @@ -18,9 +18,9 @@ [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr t]] [app.util.router :as rt] + [beicon.core :as rx] [cljs.spec.alpha :as s] [cuerdas.core :as str] - [beicon.core :as rx] [rumext.alpha :as mf])) (s/def ::email ::us/email) @@ -28,37 +28,41 @@ (mf/defc recovery-form [] - (let [form (fm/use-form :spec ::recovery-request-form - :initial {}) - + (let [form (fm/use-form :spec ::recovery-request-form :initial {}) submitted (mf/use-state false) - on-error - (mf/use-callback - (fn [{:keys [code] :as error}] - (reset! submitted false) - (if (= code :profile-not-verified) - (rx/of (dm/error (tr "auth.notifications.profile-not-verified") - {:timeout nil})) - - (rx/throw error)))) - on-success (mf/use-callback - (fn [] + (fn [data] (reset! submitted false) (st/emit! (dm/info (tr "auth.notifications.recovery-token-sent")) (rt/nav :auth-login)))) + on-error + (mf/use-callback + (fn [data {:keys [code] :as error}] + (reset! submitted false) + (case code + :profile-not-verified + (rx/of (dm/error (tr "auth.notifications.profile-not-verified") {:timeout nil})) + + :profile-is-muted + (rx/of (dm/error (tr "errors.profile-is-muted"))) + + :email-has-permanent-bounces + (rx/of (dm/error (tr "errors.email-has-permanent-bounces" (:email data)))) + + (rx/throw error)))) + on-submit (mf/use-callback (fn [] (reset! submitted true) - (->> (with-meta (:clean-data @form) - {:on-success on-success - :on-error on-error}) - (uda/request-profile-recovery) - (st/emit!))))] + (let [cdata (:clean-data @form) + params (with-meta cdata + {:on-success #(on-success cdata %) + :on-error #(on-error cdata %)})] + (st/emit! (uda/request-profile-recovery params)))))] [:& fm/form {:on-submit on-submit :form form} diff --git a/frontend/src/app/main/ui/auth/register.cljs b/frontend/src/app/main/ui/auth/register.cljs index 9d76bd2b9..96635d64b 100644 --- a/frontend/src/app/main/ui/auth/register.cljs +++ b/frontend/src/app/main/ui/auth/register.cljs @@ -64,13 +64,17 @@ (reset! submitted? false) (case (:code error) :registration-disabled - (st/emit! (dm/error (tr "errors.registration-disabled"))) + (rx/of (dm/error (tr "errors.registration-disabled"))) + + :email-has-permanent-bounces + (let [email (get @form [:data :email])] + (rx/of (dm/error (tr "errors.email-has-permanent-bounces" email)))) :email-already-exists (swap! form assoc-in [:errors :email] {:message "errors.email-already-exists"}) - (st/emit! (dm/error (tr "errors.unexpected-error")))))) + (rx/throw error)))) on-success (mf/use-callback diff --git a/frontend/src/app/main/ui/dashboard/team.cljs b/frontend/src/app/main/ui/dashboard/team.cljs index 85ea34d9d..2ab438bdd 100644 --- a/frontend/src/app/main/ui/dashboard/team.cljs +++ b/frontend/src/app/main/ui/dashboard/team.cljs @@ -97,13 +97,34 @@ (st/emitf (dm/success "Invitation sent successfully") (modal/hide))) + on-error + (mf/use-callback + (mf/deps team) + (fn [form {:keys [type code] :as error}] + (let [email (get @form [:data :email])] + (cond + (and (= :validation type) + (= :profile-is-muted code)) + (dm/error (tr "errors.profile-is-muted")) + + (and (= :validation type) + (= :member-is-muted code)) + (dm/error (tr "errors.member-is-muted")) + + (and (= :validation type) + (= :email-has-permanent-bounces)) + (dm/error (tr "errors.email-has-permanent-bounces" email)) + + :else + (dm/error (tr "errors.generic")))))) on-submit (mf/use-callback (mf/deps team) (fn [form] (let [params (:clean-data @form) - mdata {:on-success (partial on-success form)}] + mdata {:on-success (partial on-success form) + :on-error (partial on-error form)}] (st/emit! (dd/invite-team-member (with-meta params mdata))))))] [:div.modal.dashboard-invite-modal.form-container diff --git a/frontend/src/app/main/ui/settings/change_email.cljs b/frontend/src/app/main/ui/settings/change_email.cljs index cdad1a33e..f4c665f77 100644 --- a/frontend/src/app/main/ui/settings/change_email.cljs +++ b/frontend/src/app/main/ui/settings/change_email.cljs @@ -40,14 +40,20 @@ (s/keys :req-un [::email-1 ::email-2])) (defn- on-error - [form error] - (cond - (= (:code error) :email-already-exists) + [form {:keys [code] :as error}] + (case code + :email-already-exists (swap! form (fn [data] (let [error {:message (tr "errors.email-already-exists")}] (assoc-in data [:errors :email-1] error)))) - :else + :profile-is-muted + (rx/of (dm/error (tr "errors.profile-is-muted"))) + + :email-has-permanent-bounces + (let [email (get @form [:data email])] + (rx/of (dm/error (tr "errors.email-has-permanent-bounces" email)))) + (rx/throw error))) (defn- on-success