🐛 Add cooldown to prevent duplicate invitation emails (#11063)

This commit is contained in:
Andrey Antukh 2026-08-05 17:53:15 +02:00 committed by GitHub
parent 5906312dff
commit bf62e59f73
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 96 additions and 35 deletions

View File

@ -51,6 +51,25 @@
update set role = ?, valid_until = ?, updated_at = now() update set role = ?, valid_until = ?, updated_at = now()
returning *") 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 (defn- create-invitation-token
[cfg {:keys [profile-id valid-until organization-id organization-name team-id member-id member-email role]}] [cfg {:keys [profile-id valid-until organization-id organization-name team-id member-id member-email role]}]
(tokens/generate cfg (tokens/generate cfg
@ -189,6 +208,7 @@
expire (if organization expire (if organization
(ct/in-future "876000h") ;; Organization invitations doesn't expire (ct/in-future "876000h") ;; Organization invitations doesn't expire
(ct/in-future "168h")) ;; 7 days (ct/in-future "168h")) ;; 7 days
recent? (recently-invited? cfg (:id team) (:id organization) email)
invitation (db/exec-one! conn (if organization invitation (db/exec-one! conn (if organization
[sql:upsert-organization-invitation id [sql:upsert-organization-invitation id
(:id organization) (:id organization)
@ -251,7 +271,8 @@
(assoc :props props))] (assoc :props props))]
(audit/submit cfg event)) (audit/submit cfg event))
(when (allow-invitation-emails? member) (when (and (allow-invitation-emails? member)
(not recent?))
(if organization (if organization
(when (contains? cf/flags :admin-console) (when (contains? cf/flags :admin-console)
(eml/send! {::eml/conn conn (eml/send! {::eml/conn conn

View File

@ -1015,6 +1015,46 @@
out (th/command! data)] out (th/command! data)]
(t/is (th/success? out))))) (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 (t/deftest update-team-with-invalid-name
(let [profile (th/create-profile* 1 {:is-active true}) (let [profile (th/create-profile* 1 {:is-active true})
team (th/create-team* 1 {:profile-id (:id profile)})] team (th/create-team* 1 {:profile-id (:id profile)})]