🎉 Add SREPL helpers to update emails and clear bounce reports (#12668)

Support currently fixes wrong profile emails and false bounce marks with
raw SQL. Add update-profile-email! (rejects collisions) and
clear-email-complaint-reports! (deletes global and profile reports and
unmutes the matching profile) so these ops run through the REPL.

Closes #12663
This commit is contained in:
Alejandro Alonso 2026-10-28 10:25:43 +01:00 committed by GitHub
parent f1d15aee4b
commit 00dec9143d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 184 additions and 0 deletions

View File

@ -169,6 +169,89 @@
(db/get-update-count)
(pos?)))))))
(defn update-profile-email!
"Support helper: change a profile's email address.
Looks up the profile by its current email. Rejects with
`:email-already-exists` when the new address is already used by
another non-deleted profile. Does not clear bounce/complaint
reports; call `clear-email-complaint-reports!` for that."
[& {:keys [email new-email]}]
(assert (string? email) "expected email")
(assert (string? new-email) "expected new-email")
(let [email (profile/clean-email email)
new-email (profile/clean-email new-email)]
(db/tx-run! sys/system
(fn [{:keys [::db/conn] :as system}]
(let [profile (profile/get-profile-by-email conn email)]
(when-not profile
(ex/raise :type :not-found
:code :profile-not-found
:hint "profile not found for email"
:email email))
(if (= email new-email)
:unchanged
(do
(when (profile/get-profile-by-email conn new-email)
(ex/raise :type :validation
:code :email-already-exists))
(db/update! conn :profile
{:email new-email}
{:id (:id profile)}
{::db/return-keys false})
(audit/insert system
{:name "update-profile-email"
:type "action"
:props {:id (:id profile)
:email email
:new-email new-email}
:context {:triggered-by "srepl"
:cause "explicit call to update-profile-email!"}})
:updated)))))))
(defn clear-email-complaint-reports!
"Support helper: delete bounce and complaint reports for an email
from `global_complaint_report` and, when a profile owns that email,
from `profile_complaint_report`. Also unmutes that profile so mail
can be sent again after a false-positive bounce or complaint."
[email]
(assert (string? email) "expected email")
(let [email (profile/clean-email email)]
(db/tx-run! sys/system
(fn [{:keys [::db/conn] :as system}]
(let [profile (db/get* conn :profile {:email email})
global-deleted
(-> (db/delete! conn :global-complaint-report {:email email})
(db/get-update-count))
profile-deleted
(if profile
(-> (db/delete! conn :profile-complaint-report
{:profile-id (:id profile)})
(db/get-update-count))
0)]
(when profile
(db/update! conn :profile
{:is-muted false}
{:id (:id profile)}
{::db/return-keys false}))
(when (or profile (pos? global-deleted))
(audit/insert system
{:name "clear-email-complaint-reports"
:type "action"
:props (cond-> {:email email
:global-deleted global-deleted
:profile-deleted profile-deleted}
profile (assoc :id (:id profile)))
:context {:triggered-by "srepl"
:cause "explicit call to clear-email-complaint-reports!"}})
:cleared))))))
(defn parse-emails
"Parse the emails into a seq of cleaned emails. Accepts a single
email, a comma separated list of emails or a coll of emails.

View File

@ -205,3 +205,104 @@
;; to the zero one.
(t/is (nil? (:profile-id event)))
(t/is (= (:id profile) (get-in event [:props :id])))))))))
(t/deftest update-profile-email-changes-email
(with-mocks [audit-mock {:target 'app.loggers.audit/insert :return nil}]
(with-redefs [sys/system th/*system*]
(let [profile (th/create-profile* 1 {:is-active true})
result (srepl/update-profile-email!
:email (:email profile)
:new-email "corrected@example.com")]
(t/is (= :updated result))
(t/is (= "corrected@example.com"
(:email (th/db-get :profile {:id (:id profile)}))))
(let [event (->> (:call-args-list @audit-mock)
(map second)
first)]
(t/is (= "update-profile-email" (:name event)))
(t/is (= "action" (:type event)))
(t/is (= (:id profile) (get-in event [:props :id])))
(t/is (= (:email profile) (get-in event [:props :email])))
(t/is (= "corrected@example.com"
(get-in event [:props :new-email]))))))))
(t/deftest update-profile-email-rejects-collision
(with-redefs [sys/system th/*system*]
(let [profile1 (th/create-profile* 1 {:is-active true})
profile2 (th/create-profile* 2 {:is-active true})
error (try
(srepl/update-profile-email!
:email (:email profile1)
:new-email (:email profile2))
(catch Throwable e e))]
(t/is (th/ex-of-code? error :email-already-exists))
(t/is (= (:email profile1)
(:email (th/db-get :profile {:id (:id profile1)})))))))
(t/deftest update-profile-email-unchanged-when-same
(with-mocks [audit-mock {:target 'app.loggers.audit/insert :return nil}]
(with-redefs [sys/system th/*system*]
(let [profile (th/create-profile* 1 {:is-active true})
result (srepl/update-profile-email!
:email (:email profile)
:new-email (:email profile))]
(t/is (= :unchanged result))
(t/is (empty? (:call-args-list @audit-mock)))))))
(t/deftest update-profile-email-not-found
(with-mocks [audit-mock {:target 'app.loggers.audit/insert :return nil}]
(with-redefs [sys/system th/*system*]
(let [error (try
(srepl/update-profile-email!
:email "missing@example.com"
:new-email "corrected@example.com")
(catch Throwable e e))]
(t/is (th/ex-of-code? error :profile-not-found))
(t/is (= :not-found (:type (ex-data error))))
(t/is (empty? (:call-args-list @audit-mock)))))))
(t/deftest clear-email-complaint-reports-deletes-and-unmutes
(with-mocks [audit-mock {:target 'app.loggers.audit/insert :return nil}]
(with-redefs [sys/system th/*system*]
(let [profile (th/create-profile* 1 {:is-active true :is-muted true
:email "bounced@example.com"})
_ (th/create-complaint-for th/*system*
{:id (:id profile) :type :bounce})
_ (th/create-complaint-for th/*system*
{:id (:id profile) :type :complaint})
_ (th/create-global-complaint-for th/*system*
{:email (:email profile)
:type :bounce})
_ (th/create-global-complaint-for th/*system*
{:email (:email profile)
:type :complaint})
result (srepl/clear-email-complaint-reports! (:email profile))]
(t/is (= :cleared result))
(t/is (empty? (th/db-query :global-complaint-report
{:email (:email profile)})))
(t/is (empty? (th/db-query :profile-complaint-report
{:profile-id (:id profile)})))
(t/is (false? (:is-muted (th/db-get :profile {:id (:id profile)}))))
(let [event (->> (:call-args-list @audit-mock)
(map second)
first)]
(t/is (= "clear-email-complaint-reports" (:name event)))
(t/is (= "action" (:type event)))
(t/is (= (:id profile) (get-in event [:props :id])))
(t/is (= (:email profile) (get-in event [:props :email]))))))))
(t/deftest clear-email-complaint-reports-without-profile
(with-mocks [audit-mock {:target 'app.loggers.audit/insert :return nil}]
(with-redefs [sys/system th/*system*]
(let [email "orphan-bounce@example.com"
_ (th/create-global-complaint-for th/*system*
{:email email :type :bounce})
result (srepl/clear-email-complaint-reports! email)]
(t/is (= :cleared result))
(t/is (empty? (th/db-query :global-complaint-report {:email email})))
(let [event (->> (:call-args-list @audit-mock)
(map second)
first)]
(t/is (= "clear-email-complaint-reports" (:name event)))
(t/is (= email (get-in event [:props :email])))
(t/is (nil? (get-in event [:props :id]))))))))