penpot/backend/test/backend_tests/email_sending_test.clj
Andrey Antukh 9234e829d9
✨ Render email templates inside the mail job (#12448)
* ✨ Render email templates inside the mail job

The web process rendered each email to full subject and HTML body
before submitting it, so every queued job carried a huge params
payload. The queue now stores only the template id plus the raw
template data, and the new :send-templated-mail job renders and
sends the email inside the worker.

The old :sendmail job stays in place to drain jobs submitted in the
previous format; the quota notice and the REPL test mail keep using
it. All templated callers (auth, profile, feedback, invitations,
comments, Nitrate) submit recipes through eml/send-template.

Closes #12445

AI-assisted-by: muse-spark-1.3-contributor

* ♻️ Unify templated mail on template id strings

The ::eml/template value was a namespaced keyword whose name had to
match the registry key, with one live mismatch (user-feedback vs
feedback). Callers now pass the registry id string itself, so keys
stay ::eml/-namespaced and values stay plain data, and the single
templates set rejects unsendable mail both at submit and in the
worker.

Closes #12445

AI-assisted-by: muse-spark-1.3-contributor

* 🐛 Encode templated mail data with the template schema

The job params column is plain JSON, so the URIs of an organization
(:logo, :avatar-bg-url) were stored as record maps and the template
check rejected them in the worker. Invites for organization teams,
organization invites and renewal notices failed without a retry.

Each registry entry now carries the JSON encoder and decoder of its
template schema: send-template encodes the data before submit and the
worker decodes it before rendering. The job schema keeps only the mail
envelope, as the template schemas own the typed data.

Closes #12445

AI-assisted-by: claude-opus-5-5

---------
2026-11-05 17:46:48 +01:00

312 lines
15 KiB
Clojure

;; 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/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns backend-tests.email-sending-test
(:require
[app.config :as cf]
[app.db :as db]
[app.email :as emails]
[app.jobs :as jobs]
[app.metrics :as-alias mtx]
[backend-tests.helpers :as th]
[clojure.test :as t]
[cuerdas.core :as str]
[integrant.core :as ig]
[lambdaisland.uri :as u]
[promesa.core :as p]))
(t/use-fixtures :once th/state-init)
(t/use-fixtures :each th/database-reset)
(t/deftest register-email-rendering
(let [result (emails/render emails/register {:to "example@app.io" :name "foo"})]
(t/is (map? result))
(t/is (contains? result :subject))
(t/is (contains? result :body))
(t/is (contains? result :to))
#_(t/is (contains? result :reply-to))
(t/is (map? (:body result)))))
(def ^:private sso-notice-snippet
"has set up single sign-on (SSO) in Penpot")
(defn- email-text-body
[result]
(get-in result [:body "text/plain"]))
(defn- invite-email-params
[organization]
{:to "invitee@example.com"
:public-uri (cf/get :public-uri)
:invited-by "Owner User"
:user-name "Invitee User"
:token "test-token"
:organization organization})
(t/deftest invite-to-organization-includes-sso-notice-when-active
(let [result (emails/render emails/invite-to-organization
(invite-email-params {:name "Acme Inc"
:sso-active true}))]
(t/is (str/includes? (email-text-body result) sso-notice-snippet))
(t/is (str/includes? (get-in result [:body "text/html"]) sso-notice-snippet))))
(t/deftest invite-to-organization-omits-sso-notice-when-inactive
(let [result (emails/render emails/invite-to-organization
(invite-email-params {:name "Acme Inc"
:sso-active false}))]
(t/is (not (str/includes? (email-text-body result) sso-notice-snippet)))
(t/is (not (str/includes? (get-in result [:body "text/html"]) sso-notice-snippet)))))
(t/deftest invite-to-team-includes-sso-notice-when-active
(let [result (emails/render emails/invite-to-team
{:to "invitee@example.com"
:public-uri (cf/get :public-uri)
:invited-by "Owner User"
:team "Design Team"
:token "test-token"
:organization {:name "Acme Inc"
:sso-active true}})]
(t/is (str/includes? (email-text-body result) sso-notice-snippet))
(t/is (str/includes? (get-in result [:body "text/html"]) sso-notice-snippet))))
(t/deftest invite-to-team-omits-sso-notice-when-inactive
(let [result (emails/render emails/invite-to-team
{:to "invitee@example.com"
:public-uri (cf/get :public-uri)
:invited-by "Owner User"
:team "Design Team"
:token "test-token"
:organization {:name "Acme Inc"
:sso-active false}})]
(t/is (not (str/includes? (email-text-body result) sso-notice-snippet)))
(t/is (not (str/includes? (get-in result [:body "text/html"]) sso-notice-snippet)))))
(t/deftest invite-to-team-omits-sso-notice-without-organization
(let [result (emails/render emails/invite-to-team
{:to "invitee@example.com"
:public-uri (cf/get :public-uri)
:invited-by "Owner User"
:team "Design Team"
:token "test-token"})]
(t/is (not (str/includes? (email-text-body result) sso-notice-snippet)))
(t/is (not (str/includes? (get-in result [:body "text/html"]) sso-notice-snippet)))))
(defn- renewal-notice-params
[user-name]
{:to "billing@example.com"
:public-uri (cf/get :public-uri)
:user-name user-name
:renewal-date "2026-01-01"
:estimated-amount "$42.00"
:organizations [{:name "Acme"
:initials "AC"}]})
(t/deftest renewal-notice-greets-by-name-when-user-name-present
(let [result (emails/render emails/renewal-notice (renewal-notice-params "Acme Org"))]
(t/is (str/includes? (email-text-body result) "Hi Acme Org,"))
(t/is (str/includes? (get-in result [:body "text/html"]) "Hi Acme Org,"))))
(t/deftest renewal-notice-omits-space-before-comma-when-user-name-is-empty
(let [result (emails/render emails/renewal-notice (renewal-notice-params ""))]
(t/is (str/includes? (email-text-body result) "Hi,"))
(t/is (not (str/includes? (email-text-body result) "Hi ,")))
(t/is (str/includes? (get-in result [:body "text/html"]) "Hi,"))
(t/is (not (str/includes? (get-in result [:body "text/html"]) "Hi ,")))))
(t/deftest renewal-notice-omits-space-before-comma-when-user-name-is-nil
(let [result (emails/render emails/renewal-notice (renewal-notice-params nil))]
(t/is (str/includes? (email-text-body result) "Hi,"))
(t/is (not (str/includes? (email-text-body result) "Hi ,")))
(t/is (str/includes? (get-in result [:body "text/html"]) "Hi,"))
(t/is (not (str/includes? (get-in result [:body "text/html"]) "Hi ,")))))
(t/deftest send-with-reuse-conn-and-no-conn-raises
(t/testing "reuse-conn without a caller connection fails fast"
(t/is (thrown-with-msg? clojure.lang.ExceptionInfo #"reuse-conn"
(emails/send {}
{::emails/reuse-conn true
::emails/factory (fn [_] {:to "a@example.com"})})))))
(t/deftest send-with-reuse-conn-joins-caller-transaction
(let [job-def (ig/init-key ::emails/job-def
{::emails/sendmail (fn [_] nil)})
defs {:sendmail job-def}
email {:to "a@example.com" :subject "hi" :body "hello"}
job-id (db/tx-run! {::db/pool th/*pool*}
(fn [cfg]
(emails/send (assoc cfg
::jobs/defs defs
::mtx/metrics (get th/*system* :app.metrics/metrics))
(assoc email ::emails/reuse-conn true))))]
(t/testing "the email job is submitted through the caller conn"
(t/is (= "sendmail" (:name (th/db-get :job {:id job-id})))))))
(defn- templated-job-def
[sendmail]
(ig/init-key ::emails/templated-job-def
{::emails/sendmail sendmail}))
(defn- submit-templated
[job-def params]
(db/tx-run! {::db/pool th/*pool*}
(fn [cfg]
(emails/send-template (assoc cfg
::jobs/defs {:send-templated-mail job-def}
::mtx/metrics (get th/*system* :app.metrics/metrics))
(assoc params ::emails/reuse-conn true)))))
(def ^:private invite-to-team-recipe
{:to "invitee@example.com"
:public-uri (cf/get :public-uri)
:invited-by "Owner User"
:team "Design Team"
:token "test-token"
:organization {:name "Acme Inc"
:sso-active false}})
(t/deftest templated-registry-covers-all-factories
(t/is (= #{"feedback" "register" "password-recovery" "password-changed"
"change-email" "invite-to-team" "invite-to-organization"
"organization-setup-sso" "renewal-notice" "join-team"
"request-file-access" "request-file-access-yourpenpot"
"request-file-access-yourpenpot-view" "request-team-access"
"comment-mention" "comment-thread" "comment-notification"}
(set (keys emails/templates)))))
(t/deftest send-template-validation
(t/testing "missing template fails fast"
(t/is (thrown-with-msg? clojure.lang.ExceptionInfo #"template"
(emails/send-template {} {:to "a@example.com"}))))
(t/testing "unknown template fails fast"
(t/is (thrown-with-msg? clojure.lang.ExceptionInfo #"unknown email template"
(emails/send-template {} {:to "a@example.com"
::emails/template "nope"}))))
(t/testing "template ids are strings, never keywords"
(t/is (thrown-with-msg? clojure.lang.ExceptionInfo #"unknown email template"
(emails/send-template {} {:to "a@example.com"
::emails/template :app.email/register}))))
(t/testing "reuse-conn without a caller connection fails fast"
(t/is (thrown-with-msg? clojure.lang.ExceptionInfo #"reuse-conn"
(emails/send-template {} {:to "a@example.com"
::emails/reuse-conn true
::emails/template "register"})))))
(t/deftest send-template-stores-recipe-not-rendered-mail
(let [job-def (templated-job-def (fn [_] nil))
job-id (submit-templated job-def (assoc invite-to-team-recipe
::emails/template "invite-to-team"))
row (th/db-get :job {:id job-id})
params (jobs/decode-params job-def (:params row))
rendered (emails/render emails/invite-to-team invite-to-team-recipe)]
(t/testing "the job lands on the new queue"
(t/is (= "send-templated-mail" (:name row))))
(t/testing "stored params carry the template id and no rendered content"
(t/is (= "invite-to-team" (:template params)))
(t/is (not (contains? params :subject)))
(t/is (not (contains? params :body))))
(t/testing "stored params are smaller than the rendered html alone"
(let [stored-size (count (str (:params row)))
rendered-html (count (get-in rendered [:body "text/html"]))]
(t/is (< stored-size rendered-html))))
(t/testing "the worker renders the same mail from the stored recipe"
(t/is (= rendered (emails/render-template (:template params)
(dissoc params :template)))))))
(defn- decode-template-data
[params]
(let [decode (get-in emails/templates [(:template params) ::emails/decode])]
(decode (dissoc params :template))))
(t/deftest send-template-roundtrips-uuids-and-profile
(let [sent (atom nil)
job-def (templated-job-def (fn [_ email] (reset! sent email)))
team-id (random-uuid)
recipe {:to "member@example.com"
:public-uri (cf/get :public-uri)
:invited-by "Owner User"
:team "Design Team"
:team-id team-id}
job-id (submit-templated job-def (assoc recipe
::emails/template "join-team"))
params (jobs/decode-params job-def (:params (th/db-get :job {:id job-id})))]
(t/testing "uuids survive the plain-JSON queue roundtrip typed"
(let [data (decode-template-data params)]
(t/is (uuid? (:team-id data)))
(t/is (= team-id (:team-id data)))))
(t/testing "the handler renders and delivers the mail"
((::jobs/handler job-def) nil params)
(t/is (= (:subject (emails/render emails/join-team recipe))
(:subject @sent)))
(t/is (= "member@example.com" (:to @sent)))))
(let [sent (atom nil)
job-def (templated-job-def (fn [_ email] (reset! sent email)))
recipe {:to "dest@example.com"
:reply-to "user@example.com"
:email "user@example.com"
:attachments {"error-report.txt" "trace"}
:feedback-subject "subj"
:feedback-type "bug"
:feedback-content "content"
:profile {:id (random-uuid) :fullname "User" :email "user@example.com"}}
job-id (submit-templated job-def (assoc recipe
::emails/template "feedback"))
params (jobs/decode-params job-def (:params (th/db-get :job {:id job-id})))]
(t/testing "nested profile id survives the roundtrip typed"
(t/is (uuid? (get-in (decode-template-data params) [:profile :id]))))
(t/testing "the handler renders the feedback mail"
((::jobs/handler job-def) nil params)
(t/is (= (:subject (emails/render emails/user-feedback recipe))
(:subject @sent))))))
(def ^:private organization-with-uris
{:id (random-uuid)
:name "Acme Inc"
:initials "AI"
:logo (u/uri "https://example.com/assets/by-id/logo")
:avatar-bg-url (u/uri "https://example.com/images/avatar-bg.jpg")
:sso-active false})
(t/deftest send-template-roundtrips-organization-uris
(doseq [[template factory recipe]
[["invite-to-team" emails/invite-to-team
{:to "invitee@example.com"
:public-uri (cf/get :public-uri)
:invited-by "Owner User"
:team "Design Team"
:organization organization-with-uris
:token "test-token"}]
["invite-to-organization" emails/invite-to-organization
{:to "invitee@example.com"
:public-uri (cf/get :public-uri)
:invited-by "Owner User"
:user-name "Invitee"
:organization organization-with-uris
:token "test-token"}]
["renewal-notice" emails/renewal-notice
{:to "billing@example.com"
:public-uri (cf/get :public-uri)
:user-name "Billing"
:renewal-date "2026-12-01"
:estimated-amount "$10.00"
:organizations [organization-with-uris]}]]]
(t/testing template
(let [sent (atom nil)
job-def (templated-job-def (fn [_ email] (reset! sent email)))
job-id (submit-templated job-def (assoc recipe ::emails/template template))
params (jobs/decode-params job-def (:params (th/db-get :job {:id job-id})))]
((::jobs/handler job-def) nil params)
(t/is (= (emails/render factory recipe) @sent))))))
(t/deftest send-templated-mail-handler-rejects-unknown-template
(let [job-def (templated-job-def (fn [_] nil))
code (try
((::jobs/handler job-def) nil {:template "nope"
:to "a@example.com"})
nil
(catch clojure.lang.ExceptionInfo e
(-> e ex-data :code)))]
(t/is (= :unknown-email-template code))))