From ba536f5486c9bf639eb0e4879d7cc6a48425990f Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 29 Jul 2026 18:22:00 +0000 Subject: [PATCH] :bug: Add cooldown to prevent duplicate invitation emails --- .../app/rpc/commands/teams_invitations.clj | 91 ++++++++++++------- backend/test/backend_tests/rpc_team_test.clj | 40 ++++++++ 2 files changed, 96 insertions(+), 35 deletions(-) diff --git a/backend/src/app/rpc/commands/teams_invitations.clj b/backend/src/app/rpc/commands/teams_invitations.clj index 8b1a8c357c..7cd8933354 100644 --- a/backend/src/app/rpc/commands/teams_invitations.clj +++ b/backend/src/app/rpc/commands/teams_invitations.clj @@ -46,10 +46,29 @@ (def sql:upsert-organization-invitation "insert into team_invitation(id, team_id, org_id, email_to, created_by, role, valid_until) - values (?, null, ?, ?, ?, ?, ?) - on conflict(org_id, email_to) where team_id is null do - update set role = ?, valid_until = ?, updated_at = now() - returning *") + values (?, null, ?, ?, ?, ?, ?) + on conflict(org_id, email_to) where team_id is null do + update set role = ?, valid_until = ?, updated_at = now() + returning *") + +(def ^:private sql:check-recent-invitation + "SELECT 1 FROM team_invitation + WHERE team_id = ? AND email_to = ? + AND updated_at > now() - interval '5 minutes' + LIMIT 1") + +(def ^:private sql:check-recent-org-invitation + "SELECT 1 FROM team_invitation + WHERE org_id = ? AND email_to = ? + AND updated_at > now() - interval '5 minutes' + LIMIT 1") + +(defn- recently-invited? + [{:keys [::db/conn]} team-id org-id email] + (let [query (if org-id + [sql:check-recent-org-invitation org-id email] + [sql:check-recent-invitation team-id email])] + (some? (db/exec-one! conn query)))) (defn- create-invitation-token [cfg {:keys [profile-id valid-until organization-id organization-name team-id member-id member-email role]}] @@ -185,35 +204,36 @@ (teams/check-email-bounce conn email true) (teams/check-email-spam conn email true) - (let [id (uuid/next) - expire (if organization - (ct/in-future "876000h") ;; Organization invitations doesn't expire - (ct/in-future "168h")) ;; 7 days - invitation (db/exec-one! conn (if organization - [sql:upsert-organization-invitation id - (:id organization) - (str/lower email) - (:id profile) - (name role) expire - (name role) expire] - [sql:upsert-team-invitation id - (:id team) - (str/lower email) - (:id profile) - (name role) expire - (name role) expire])) - updated? (not= id (:id invitation)) - profile-id (:id profile) + (let [id (uuid/next) + expire (if organization + (ct/in-future "876000h") ;; Organization invitations doesn't expire + (ct/in-future "168h")) ;; 7 days + recent? (recently-invited? cfg (:id team) (:id organization) email) + invitation (db/exec-one! conn (if organization + [sql:upsert-organization-invitation id + (:id organization) + (str/lower email) + (:id profile) + (name role) expire + (name role) expire] + [sql:upsert-team-invitation id + (:id team) + (str/lower email) + (:id profile) + (name role) expire + (name role) expire])) + updated? (not= id (:id invitation)) + profile-id (:id profile) team-organization-id (get-in team [:organization :id]) - tprops {:profile-id profile-id - :invitation-id (:id invitation) - :valid-until expire - :team-id (:id team) - :organization-id (:id organization) - :organization-name (:name organization) - :member-email (:email-to invitation) - :member-id (:id member) - :role role} + tprops {:profile-id profile-id + :invitation-id (:id invitation) + :valid-until expire + :team-id (:id team) + :organization-id (:id organization) + :organization-name (:name organization) + :member-email (:email-to invitation) + :member-id (:id member) + :role role} audit-props (cond-> {:invitation-id (:id invitation) :valid-until expire @@ -234,8 +254,8 @@ (and team-organization-id member (contains? all-organization-member-ids (:id member)))))) - itoken (create-invitation-token cfg tprops) - ptoken (create-profile-identity-token cfg profile-id)] + itoken (create-invitation-token cfg tprops) + ptoken (create-profile-identity-token cfg profile-id)] (when (contains? cf/flags :log-invitation-tokens) (l/info :hint "invitation token" :token itoken)) @@ -251,7 +271,8 @@ (assoc :props props))] (audit/submit cfg event)) - (when (allow-invitation-emails? member) + (when (and (allow-invitation-emails? member) + (not recent?)) (if organization (when (contains? cf/flags :admin-console) (eml/send! {::eml/conn conn diff --git a/backend/test/backend_tests/rpc_team_test.clj b/backend/test/backend_tests/rpc_team_test.clj index 7c2b5d0552..5c0477ed17 100644 --- a/backend/test/backend_tests/rpc_team_test.clj +++ b/backend/test/backend_tests/rpc_team_test.clj @@ -1015,6 +1015,46 @@ out (th/command! data)] (t/is (th/success? out))))) +(t/deftest create-team-invitations-email-cooldown + (with-mocks [mock {:target 'app.email/send! :return nil}] + (let [profile1 (th/create-profile* 1 {:is-active true}) + team (th/create-team* 1 {:profile-id (:id profile1)}) + + data {::th/type :create-team-invitations + ::rpc/profile-id (:id profile1) + :team-id (:id team) + :role :editor + :emails ["cooldown-test@example.com"]}] + + ;; First invitation sends email + (let [out (th/command! data)] + (t/is (th/success? out)) + (t/is (= 1 (:call-count @mock)))) + + ;; Resending immediately should NOT send email (cooldown active) + (th/reset-mock! mock) + (let [out (th/command! data)] + (t/is (th/success? out)) + (t/is (= 0 (:call-count @mock)))) + + ;; Resending to a different email should send email + (th/reset-mock! mock) + (let [data (assoc data :emails ["different@example.com"]) + out (th/command! data)] + (t/is (th/success? out)) + (t/is (= 1 (:call-count @mock)))) + + ;; After cooldown expires, resending should send email + (th/reset-mock! mock) + (th/db-update! :team-invitation + {:updated-at (ct/in-past "10m")} + {:team-id (:id team) + :email-to "cooldown-test@example.com"}) + (let [data (assoc data :emails ["cooldown-test@example.com"]) + out (th/command! data)] + (t/is (th/success? out)) + (t/is (= 1 (:call-count @mock))))))) + (t/deftest update-team-with-invalid-name (let [profile (th/create-profile* 1 {:is-active true}) team (th/create-team* 1 {:profile-id (:id profile)})]