diff --git a/backend/src/app/srepl/main.clj b/backend/src/app/srepl/main.clj index 8a475e2cae..10656d0c28 100644 --- a/backend/src/app/srepl/main.clj +++ b/backend/src/app/srepl/main.clj @@ -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. diff --git a/backend/test/backend_tests/srepl_main_test.clj b/backend/test/backend_tests/srepl_main_test.clj index aebc99c9bf..3b44e7352b 100644 --- a/backend/test/backend_tests/srepl_main_test.clj +++ b/backend/test/backend_tests/srepl_main_test.clj @@ -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]))))))))