From 30a4df47b1dfa861a2262b5d45942f734929cc71 Mon Sep 17 00:00:00 2001 From: Florian Sylvain Date: Sat, 26 Sep 2026 05:41:34 +0200 Subject: [PATCH] feat: new emails design --- ...ration_gate_and_verification_email.feature | 8 +- features/request_password_reset.feature | 10 +- .../transactional_email_templates.feature | 114 +++++++++++++++++ features/transactional_email_templates.qa.md | 71 +++++++++++ .../infrastructure/mailer/mailRepository.go | 40 ++++-- .../mailer/templates/mailValidation.html | 18 --- .../mailer/templates/password-reset.html | 72 +++++++++++ .../mailer/templates/passwordReset.html | 18 --- .../mailer/templates/verification.html | 72 +++++++++++ .../infrastructure/useCases/CreateUser.go | 2 +- .../useCases/RequestPasswordReset.go | 2 +- ...ration_gate_and_verification_email_test.go | 8 +- test/request_password_reset_http_test.go | 8 +- test/request_password_reset_test.go | 4 +- test/transactional_email_templates_test.go | 119 ++++++++++++++++++ 15 files changed, 498 insertions(+), 68 deletions(-) create mode 100644 features/transactional_email_templates.feature create mode 100644 features/transactional_email_templates.qa.md delete mode 100644 internal/infrastructure/mailer/templates/mailValidation.html create mode 100644 internal/infrastructure/mailer/templates/password-reset.html delete mode 100644 internal/infrastructure/mailer/templates/passwordReset.html create mode 100644 internal/infrastructure/mailer/templates/verification.html create mode 100644 test/transactional_email_templates_test.go diff --git a/features/registration_gate_and_verification_email.feature b/features/registration_gate_and_verification_email.feature index ef3023c..d501cc2 100644 --- a/features/registration_gate_and_verification_email.feature +++ b/features/registration_gate_and_verification_email.feature @@ -28,8 +28,8 @@ Feature: Registration is only open while no verified account exists, and it send both injected from config.Settings.PublicURL in api/dependecyInjection.go; the handler no longer sends mail and no longer holds a SendMailUseCase or PublicURL itself And after user.Repository.Create succeeds, CreateUserUseCase.CreateUser calls - mail.Repository.Send with the user's email, template "mailValidation" - (internal/infrastructure/mailer/templates/mailValidation.html, which renders a link from + mail.Repository.Send with the user's email, template "verification" + (internal/infrastructure/mailer/templates/verification.html, which renders a link from "{{.Host}}/register/validate?c={{.VerificationCode}}"), and data whose Host is the injected public URL and whose VerificationCode is the raw (unhashed) code And if mail.Repository.Send returns an error, CreateUserUseCase.CreateUser deletes the @@ -45,7 +45,7 @@ Feature: Registration is only open while no verified account exists, and it send email "alice@example.com" Then the response status is 200 And a jwt cookie is set - And exactly one email was sent to "alice@example.com" using template "mailValidation" + And exactly one email was sent to "alice@example.com" using template "verification" And that email's data has VerificationCode equal to the raw code returned by CreateUser, not its stored hash @@ -100,7 +100,7 @@ Feature: Registration is only open while no verified account exists, and it send When CreateUser is called with username "carol", password "hunter2example12", email "carol@example.com" Then the call succeeds and returns a user with IsVerified false - And exactly one email was sent to "carol@example.com" using template "mailValidation" + And exactly one email was sent to "carol@example.com" using template "verification" Scenario: The create-user use case rolls back the user when the mail repository fails Given an empty user.Repository fake diff --git a/features/request_password_reset.feature b/features/request_password_reset.feature index a900b46..e3b421a 100644 --- a/features/request_password_reset.feature +++ b/features/request_password_reset.feature @@ -39,8 +39,8 @@ Feature: Anyone can request a password reset, and the response never reveals whe most recently requested code and expiration are ever valid; a user who requests a reset twice can only use the second code, and the first one no longer matches once overwritten And after storing succeeds, RequestPasswordReset calls mail.Repository.Send with the user's - email, template "passwordReset" (the already-existing - internal/infrastructure/mailer/templates/passwordReset.html, which renders a link from + email, template "password-reset" (the already-existing + internal/infrastructure/mailer/templates/password-reset.html, which renders a link from "{{.Host}}/register/reset/validate?c={{.VerificationCode}}&email={{.Email}}"), and a MailPasswordResetData value whose Host is the injected public URL, whose VerificationCode is the raw (unhashed) code, and whose Email is the user's email @@ -69,7 +69,7 @@ Feature: Anyone can request a password reset, and the response never reveals whe When the client sends POST /v1/auth/password-reset with body {"email": "alice@example.com"} Then the response status is 200 And the response body is {"message": "If an account exists for that email, a password reset link has been sent."} - And exactly one email was sent to "alice@example.com" using template "passwordReset" + And exactly one email was sent to "alice@example.com" using template "password-reset" And that email's data has VerificationCode equal to the raw code stored in the repository's hashed PasswordResetCode for alice, not the hash itself, and Email equal to "alice@example.com" @@ -116,7 +116,7 @@ Feature: Anyone can request a password reset, and the response never reveals whe When the client sends POST /v1/auth/password-reset with body {"email": "bob@example.com"} Then the response status is 200 And the response body is {"message": "If an account exists for that email, a password reset link has been sent."} - And exactly one email was sent to "bob@example.com" using template "passwordReset" + And exactly one email was sent to "bob@example.com" using template "password-reset" Scenario Outline: A malformed request is still rejected, since this is not an enumeration concern When the client sends POST /v1/auth/password-reset with body @@ -136,7 +136,7 @@ Feature: Anyone can request a password reset, and the response never reveals whe Then the call returns nil And the fake repository's UpdatePasswordResetCode method was invoked exactly once with userId 3, a hashed code, and an expiration within 5 seconds of time.Now().Add(1 * time.Hour) - And exactly one email was sent to "alice@example.com" using template "passwordReset" whose data + And exactly one email was sent to "alice@example.com" using template "password-reset" whose data carries the same raw code that was hashed, and Host "https://cms.example.com" Scenario: The use case swallows an unknown email and returns nil without touching the repository or mailer diff --git a/features/transactional_email_templates.feature b/features/transactional_email_templates.feature new file mode 100644 index 0000000..9cee5fa --- /dev/null +++ b/features/transactional_email_templates.feature @@ -0,0 +1,114 @@ +Feature: The two transactional email templates are replaced by the design's ready-to-use files + + As the maintainer of RenewCMS + I want the verification and password-reset emails to use the exact Go html/template files from + docs/design/source/emails/, renamed to match the design's naming, instead of the old ad hoc + mailValidation.html and passwordReset.html files + So that account-verification and password-reset emails match the visual spec (bulletproof button, + preheader, dark-mode overrides), and the template name used at the call site reflects the file it + now loads + + Background: + Given internal/infrastructure/mailer/templates/mailValidation.html is removed and replaced by + internal/infrastructure/mailer/templates/verification.html, copied verbatim from + docs/design/source/emails/verification.html, still driven by a Go struct with a Host field and + a VerificationCode field (the existing MailValidationData shape in CreateUser.go is unchanged) + And internal/infrastructure/mailer/templates/passwordReset.html is removed and replaced by + internal/infrastructure/mailer/templates/password-reset.html, copied verbatim from + docs/design/source/emails/password-reset.html, still driven by a Go struct with a Host field, a + VerificationCode field and an Email field (the existing MailPasswordResetData shape in + RequestPasswordReset.go is unchanged) + And MailRepository.Send (internal/infrastructure/mailer/mailRepository.go) is unchanged code: it + already loads "templates/" + templateName + ".html" via template.ParseFS, so it now resolves + the templateName "verification" to the new verification.html file and "password-reset" to the + new password-reset.html file, purely because of the rename, with no change to Send's own logic + And CreateUserUseCase.CreateUser (internal/infrastructure/useCases/CreateUser.go) now calls + mail.Repository.Send with template "verification" instead of the old "mailValidation"; nothing + else about that call changes: the receiver address, and the Host/VerificationCode data, are as + before + And RequestPasswordResetUseCase.RequestPasswordReset + (internal/infrastructure/useCases/RequestPasswordReset.go) now calls mail.Repository.Send with + template "password-reset" instead of the old "passwordReset"; nothing else about that call + changes: the receiver address, and the Host/VerificationCode/Email data, are as before + And the verification email's link is still built as + "{{.Host}}/register/validate?c={{.VerificationCode}}" and the password-reset email's link is + still built as + "{{.Host}}/register/reset/validate?c={{.VerificationCode}}&email={{.Email}}", matching both the + old templates and the use cases that populate them, so no handler, use case or route changes + as a result of this slice + And MailRepository (internal/infrastructure/mailer/mailRepository.go) gains an unexported render + step, e.g. render(templateName string, data any) (subject string, body string, err error), + that Send calls before dialing; render selects the subject by templateName ("Verify your + RenewCMS account" for "verification", "Reset your RenewCMS password" for "password-reset", + matching docs/design/emails.md's suggested subjects exactly) and parses/executes the + corresponding templates/.html file, returning its rendered body; Send then uses + that subject and body to build and dial the message exactly as before, and any ParseFS or + Execute error from render is returned by Send unchanged, with the same log.Println call sites + as today + And an unrecognized templateName still reaches template.ParseFS, which fails to find + "templates/.html" and returns its usual error; render does not special-case + unknown names beyond the two known subjects, so an unknown templateName's subject falls back to + the same literal "RenewCMS | Action required" used before this slice, and Send still returns + the ParseFS error without attempting to dial + And the two feature files that already assert a template-name string literal, + registration_gate_and_verification_email.feature and request_password_reset.feature, are + updated in this same slice so every occurrence of "mailValidation" reads "verification" and + every occurrence of "passwordReset" reads "password-reset"; their behaviour is otherwise + unchanged + + Scenario: Registering a user sends the verification email under its new template name + Given the user repository is empty + When the client sends POST /v1/auth/register with username "alice", password "hunter2example12", + email "alice@example.com" + Then the response status is 200 + And exactly one email was sent to "alice@example.com" using template "verification" + And that email's data has Host and VerificationCode populated exactly as before this slice + + Scenario: Requesting a password reset sends the reset email under its new template name + Given the user repository contains a user "alice" with email "alice@example.com" + When the client sends POST /v1/auth/password-reset with body {"email": "alice@example.com"} + Then the response status is 200 + And exactly one email was sent to "alice@example.com" using template "password-reset" + And that email's data has Host, VerificationCode and Email populated exactly as before this slice + + Scenario: render() produces the verification email's subject, link and copy + Given a real *mailer.MailRepository + When its render step is called with templateName "verification" and data whose Host is + "https://cms.example.com" and whose VerificationCode is "abc123" + Then the returned subject is "Verify your RenewCMS account" + And the returned body contains the link "https://cms.example.com/register/validate?c=abc123" + And the returned body contains the button text "Verify my account" + And the returned body contains the text "This link expires in 2 hours." + + Scenario: render() produces the password-reset email's subject, link and copy + Given a real *mailer.MailRepository + When its render step is called with templateName "password-reset" and data whose Host is + "https://cms.example.com", whose VerificationCode is "abc123" and whose Email is + "alice@example.com" + Then the returned subject is "Reset your RenewCMS password" + And the returned body contains the link + "https://cms.example.com/register/reset/validate?c=abc123&email=alice%40example.com" + And the returned body contains the button text "Choose a new password" + And the returned body contains the text "This link expires in 1 hour and works once." + + Scenario: render() falls back to the old default subject for an unrecognized template name + Given a real *mailer.MailRepository + When its render step is called with templateName "does-not-exist" and any data + Then the returned subject is "RenewCMS | Action required" + And an error is returned because templates/does-not-exist.html cannot be found + And the returned body is empty + + Scenario: A template execution failure is returned without sending + Given a real *mailer.MailRepository + And data that causes Execute to fail for a known templateName (e.g. a type without the fields + the template references) + When its render step is called with that templateName and that data + Then render returns an error and MailRepository.Send never reaches DialAndSend + + Scenario Outline: The old template files no longer exist under their previous names + Then the file internal/infrastructure/mailer/templates/ does not exist on disk + + Examples: + | old file | + | mailValidation.html | + | passwordReset.html | diff --git a/features/transactional_email_templates.qa.md b/features/transactional_email_templates.qa.md new file mode 100644 index 0000000..54a7444 --- /dev/null +++ b/features/transactional_email_templates.qa.md @@ -0,0 +1,71 @@ +# QA procedure: transactional email templates + +## Automated (Go, `test/`) + +This slice touches a use-case call site (a string literal) and two static template files; it has +no new HTTP surface of its own, so no new handler/router wiring is exercised here. Reuse the +existing fakes already in `test/` (`mockMailRepo` from `test/mail_test.go`, the fake `user.Repository` +used by the registration and password-reset tests) rather than building new ones. + +1. **Registration sends "verification".** + In the existing registration test file (or its replacement after this slice), build + `CreateUserUseCase` over an empty fake `user.Repository`, a real hasher, and `mockMailRepo`, call + `CreateUser` with username `alice`, password `hunter2example12`, email `alice@example.com`, assert + the call succeeds, then assert `mockMailRepo.SentEmails` has exactly one entry whose `Template` + field equals `"verification"` (not `"mailValidation"`). + +2. **Password reset sends "password-reset".** + Build `RequestPasswordResetUseCase` over a fake `user.Repository` seeded with a user whose email is + `alice@example.com`, a real hasher, `mockMailRepo` and a public URL, call `RequestPasswordReset("alice@example.com")`, + assert it returns `nil`, then assert `mockMailRepo.SentEmails` has exactly one entry whose + `Template` field equals `"password-reset"` (not `"passwordReset"`). + +3. **render() produces the right subject, link and copy for each known template.** + Construct a real `mailer.NewMailRepository(config.SmtpSettings{})` (SMTP fields are irrelevant + since `DialAndSend` is never called here) and call its render step directly (test lives in + package `test`, so this exercises the exported behavior surface of a real `*mailer.MailRepository` + without opening a network connection): + - `render("verification", )` + → subject `"Verify your RenewCMS account"`, body contains + `https://cms.example.com/register/validate?c=abc123`, `Verify my account`, + `This link expires in 2 hours.` + - `render("password-reset", )` → subject `"Reset your RenewCMS password"`, body contains + `https://cms.example.com/register/reset/validate?c=abc123&email=alice%40example.com`, + `Choose a new password`, `This link expires in 1 hour and works once.` + + (`html/template` auto-escapes `Email` in the query string, turning `@` into `%40`; assert the + escaped form, matching what `MailRepository.Send` will actually produce.) + +3b. **render() fallback and failure paths.** + - `render("does-not-exist", nil)` returns an error (template file not found) and the returned + subject is still the old default `"RenewCMS | Action required"`, with an empty body. + - A `render(, )` call returns an error; assert + `MailRepository.Send` with the same inputs returns an error too and never gets to + `DialAndSend` (no network dependency needed to prove this: a failing `render` return is + sufficient to prove `Send` short-circuits before dialing, since `Send`'s existing structure + calls render, then returns immediately on its error). + +4. **Old template files are gone.** + `os.Stat("internal/infrastructure/mailer/templates/mailValidation.html")` and the `passwordReset.html` + equivalent each return an error satisfying `os.IsNotExist`. + +5. **Existing slice tests still pass with updated literals.** + Run the full suite (`task test`) and confirm the tests backing + `registration_gate_and_verification_email.feature` and `request_password_reset.feature` still pass + now that their scenario text and any Go assertions inside their test files use `"verification"` and + `"password-reset"` instead of the old strings. If those test files still contain the old literals + as Go string constants (not just prose in the `.feature` files), they need the same rename; point + this out to `coder` rather than silently doing a code edit here. + +## Manual verification (not covered by the automated Go tests above) + +- **Rendered appearance in an actual mail client.** The bulletproof button (table-cell background + + padded link), the hidden preheader text, and the `prefers-color-scheme: dark` overrides can only be + judged by opening the rendered HTML in a real or preview mail client (or the board file + `docs/design/source/RenewCMS Emails.dc.html` next to a manually triggered send through a real SMTP + server) — this is not something `os.ReadFile` + `html/template.Execute` can verify. Confirm both + light and dark rendering for both templates. +- **Real SMTP delivery end to end.** Sending an actual email via `SMTP_HOST`/`SMTP_PORT` and eyeballing + the arrived message (subject line, sender name "RenewCMS <...>", clickable button, monospace + fallback URL) is a manual item; nothing here exercises `gomail.Dialer.DialAndSend`. diff --git a/internal/infrastructure/mailer/mailRepository.go b/internal/infrastructure/mailer/mailRepository.go index 891b0a0..9dc4d43 100644 --- a/internal/infrastructure/mailer/mailRepository.go +++ b/internal/infrastructure/mailer/mailRepository.go @@ -22,6 +22,32 @@ func NewMailRepository(smtp config.SmtpSettings) *MailRepository { return &MailRepository{smtp: smtp} } +const defaultSubject = "RenewCMS | Action required" + +func (m MailRepository) Render(templateName string, data any) (string, string, error) { + subject := defaultSubject + switch templateName { + case "verification": + subject = "Verify your RenewCMS account" + case "password-reset": + subject = "Reset your RenewCMS password" + } + + tmpl, err := template.ParseFS(mailTemplateFiles, "templates/"+templateName+".html") + if err != nil { + log.Println("error when template.ParseFS:", err) + return subject, "", err + } + + var body bytes.Buffer + if err := tmpl.Execute(&body, data); err != nil { + log.Println("error when tmpl.Execute:", err) + return subject, "", err + } + + return subject, body.String(), nil +} + func (m MailRepository) Send(receiverAddress string, templateName string, data any) error { login := m.smtp.Login sender := m.smtp.Sender @@ -31,16 +57,8 @@ func (m MailRepository) Send(receiverAddress string, templateName string, data a d := gomail.NewDialer(smtpHost, smtpPort, login, password) - tmpl, err := template.ParseFS(mailTemplateFiles, "templates/"+templateName+".html") + subject, body, err := m.Render(templateName, data) if err != nil { - log.Println("error when template.ParseFS:", err) - return err - } - - var body bytes.Buffer - err = tmpl.Execute(&body, data) - if err != nil { - log.Println("error when tmpl.Execute:", err) return err } @@ -48,9 +66,9 @@ func (m MailRepository) Send(receiverAddress string, templateName string, data a msg.SetHeaders(map[string][]string{ "From": {"RenewCMS <" + sender + ">"}, "To": {receiverAddress}, - "Subject": {"RenewCMS | Action required"}, + "Subject": {subject}, }) - msg.SetBody("text/html", body.String()) + msg.SetBody("text/html", body) if err := d.DialAndSend(msg); err != nil { log.Println("error when sending email:", err) diff --git a/internal/infrastructure/mailer/templates/mailValidation.html b/internal/infrastructure/mailer/templates/mailValidation.html deleted file mode 100644 index 27bb96a..0000000 --- a/internal/infrastructure/mailer/templates/mailValidation.html +++ /dev/null @@ -1,18 +0,0 @@ - - - -

- G - RenewCMS -

-
-

You or someone tried to create a RenewCMS admin account with this e-mail address. If you are not responsible for - this, please do not validate the account creation.

-

If you want to validate the account creation, please click HERE. -

-

If you can't click the link:

-

{{.Host}}/register/validate?c={{.VerificationCode}}

-
- - diff --git a/internal/infrastructure/mailer/templates/password-reset.html b/internal/infrastructure/mailer/templates/password-reset.html new file mode 100644 index 0000000..638ac97 --- /dev/null +++ b/internal/infrastructure/mailer/templates/password-reset.html @@ -0,0 +1,72 @@ + + + + + + + + Reset your password + + + +
Choose a new password for RenewCMS. The link expires in 1 hour.
+ + + + + + + diff --git a/internal/infrastructure/mailer/templates/passwordReset.html b/internal/infrastructure/mailer/templates/passwordReset.html deleted file mode 100644 index 5a4555f..0000000 --- a/internal/infrastructure/mailer/templates/passwordReset.html +++ /dev/null @@ -1,18 +0,0 @@ - - - -

- G - RenewCMS -

-
-

You or someone tried to create reset your RenewCMS account with this e-mail address. If you are not responsible for - this, please do not validate the password reset.

-

If you want to reset your password, please click HERE. -

-

If you can't click the link:

-

{{.Host}}/register/reset/validate?c={{.VerificationCode}}&email={{.Email}}

-
- - diff --git a/internal/infrastructure/mailer/templates/verification.html b/internal/infrastructure/mailer/templates/verification.html new file mode 100644 index 0000000..a7768f1 --- /dev/null +++ b/internal/infrastructure/mailer/templates/verification.html @@ -0,0 +1,72 @@ + + + + + + + + Verify your account + + + +
Confirm your email to finish setting up RenewCMS. The link expires in 2 hours.
+ + + + + + + diff --git a/internal/infrastructure/useCases/CreateUser.go b/internal/infrastructure/useCases/CreateUser.go index 33c2ff3..3596723 100644 --- a/internal/infrastructure/useCases/CreateUser.go +++ b/internal/infrastructure/useCases/CreateUser.go @@ -65,7 +65,7 @@ func (g *CreateUserUseCase) CreateUser(createUser CreateUserCommand) (user.User, createdUser.VerificationCode = rawUuid - if err := g.mailRepository.Send(createdUser.Email, "mailValidation", MailValidationData{ + if err := g.mailRepository.Send(createdUser.Email, "verification", MailValidationData{ Host: g.publicURL, VerificationCode: rawUuid, }); err != nil { diff --git a/internal/infrastructure/useCases/RequestPasswordReset.go b/internal/infrastructure/useCases/RequestPasswordReset.go index 9c8a58d..fbe00e6 100644 --- a/internal/infrastructure/useCases/RequestPasswordReset.go +++ b/internal/infrastructure/useCases/RequestPasswordReset.go @@ -46,7 +46,7 @@ func (g *RequestPasswordResetUseCase) RequestPasswordReset(email string) error { return nil } - if err := g.mailRepository.Send(foundUser.Email, "passwordReset", MailPasswordResetData{ + if err := g.mailRepository.Send(foundUser.Email, "password-reset", MailPasswordResetData{ Host: g.publicURL, VerificationCode: rawCode, Email: foundUser.Email, diff --git a/test/registration_gate_and_verification_email_test.go b/test/registration_gate_and_verification_email_test.go index f2403c7..409085e 100644 --- a/test/registration_gate_and_verification_email_test.go +++ b/test/registration_gate_and_verification_email_test.go @@ -90,8 +90,8 @@ func TestRegistrationGateAndVerificationEmailHttp(t *testing.T) { if sent.Address != "alice@example.com" { t.Errorf("expected email sent to alice@example.com, got %q", sent.Address) } - if sent.Template != "mailValidation" { - t.Errorf("expected template mailValidation, got %q", sent.Template) + if sent.Template != "verification" { + t.Errorf("expected template verification, got %q", sent.Template) } storedUser, err := userRepo.GetByUsername("alice") @@ -260,8 +260,8 @@ func TestRegistrationGateAndVerificationEmailUseCase(t *testing.T) { if mailRepo.SentEmails[0].Address != "carol@example.com" { t.Errorf("expected email sent to carol@example.com, got %q", mailRepo.SentEmails[0].Address) } - if mailRepo.SentEmails[0].Template != "mailValidation" { - t.Errorf("expected template mailValidation, got %q", mailRepo.SentEmails[0].Template) + if mailRepo.SentEmails[0].Template != "verification" { + t.Errorf("expected template verification, got %q", mailRepo.SentEmails[0].Template) } }) diff --git a/test/request_password_reset_http_test.go b/test/request_password_reset_http_test.go index 172be50..27ff44d 100644 --- a/test/request_password_reset_http_test.go +++ b/test/request_password_reset_http_test.go @@ -65,8 +65,8 @@ func TestRequestPasswordResetHTTP(t *testing.T) { if mailRepo.SentEmails[0].Address != "alice@example.com" { t.Errorf("expected email to alice@example.com, got %s", mailRepo.SentEmails[0].Address) } - if mailRepo.SentEmails[0].Template != "passwordReset" { - t.Errorf("expected template passwordReset, got %s", mailRepo.SentEmails[0].Template) + if mailRepo.SentEmails[0].Template != "password-reset" { + t.Errorf("expected template password-reset, got %s", mailRepo.SentEmails[0].Template) } }) @@ -204,8 +204,8 @@ func TestRequestPasswordResetHTTP(t *testing.T) { if mailRepo.SentEmails[0].Address != "bob@example.com" { t.Errorf("expected email to bob@example.com, got %s", mailRepo.SentEmails[0].Address) } - if mailRepo.SentEmails[0].Template != "passwordReset" { - t.Errorf("expected template passwordReset, got %s", mailRepo.SentEmails[0].Template) + if mailRepo.SentEmails[0].Template != "password-reset" { + t.Errorf("expected template password-reset, got %s", mailRepo.SentEmails[0].Template) } }) diff --git a/test/request_password_reset_test.go b/test/request_password_reset_test.go index 6d423a1..e81f636 100644 --- a/test/request_password_reset_test.go +++ b/test/request_password_reset_test.go @@ -54,8 +54,8 @@ func TestRequestPasswordResetUseCase(t *testing.T) { if sent.Address != "alice@example.com" { t.Errorf("expected email to alice@example.com, got %s", sent.Address) } - if sent.Template != "passwordReset" { - t.Errorf("expected template passwordReset, got %s", sent.Template) + if sent.Template != "password-reset" { + t.Errorf("expected template password-reset, got %s", sent.Template) } data, ok := sent.Data.(useCases.MailPasswordResetData) if !ok { diff --git a/test/transactional_email_templates_test.go b/test/transactional_email_templates_test.go new file mode 100644 index 0000000..3d45ef3 --- /dev/null +++ b/test/transactional_email_templates_test.go @@ -0,0 +1,119 @@ +package test + +import ( + "RenewCMS/internal/infrastructure/config" + "RenewCMS/internal/infrastructure/mailer" + "RenewCMS/internal/infrastructure/useCases" + "os" + "strings" + "testing" +) + +func TestTransactionalEmailTemplates(t *testing.T) { + t.Run("render() produces the verification email's subject, link and copy", func(t *testing.T) { + repo := mailer.NewMailRepository(config.SmtpSettings{}) + + subject, body, err := repo.Render("verification", useCases.MailValidationData{ + Host: "https://cms.example.com", + VerificationCode: "abc123", + }) + + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if subject != "Verify your RenewCMS account" { + t.Errorf("expected subject %q, got %q", "Verify your RenewCMS account", subject) + } + if !strings.Contains(body, "https://cms.example.com/register/validate?c=abc123") { + t.Errorf("expected body to contain the verification link, got %q", body) + } + if !strings.Contains(body, "Verify my account") { + t.Errorf("expected body to contain the button text, got %q", body) + } + if !strings.Contains(body, "This link expires in 2 hours.") { + t.Errorf("expected body to contain the expiry copy, got %q", body) + } + }) + + t.Run("render() produces the password-reset email's subject, link and copy", func(t *testing.T) { + repo := mailer.NewMailRepository(config.SmtpSettings{}) + + subject, body, err := repo.Render("password-reset", useCases.MailPasswordResetData{ + Host: "https://cms.example.com", + VerificationCode: "abc123", + Email: "alice@example.com", + }) + + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if subject != "Reset your RenewCMS password" { + t.Errorf("expected subject %q, got %q", "Reset your RenewCMS password", subject) + } + if !strings.Contains(body, "https://cms.example.com/register/reset/validate?c=abc123&email=alice%40example.com") { + t.Errorf("expected body to contain the password-reset link, got %q", body) + } + if !strings.Contains(body, "Choose a new password") { + t.Errorf("expected body to contain the button text, got %q", body) + } + if !strings.Contains(body, "This link expires in 1 hour and works once.") { + t.Errorf("expected body to contain the expiry copy, got %q", body) + } + }) + + t.Run("render() falls back to the old default subject for an unrecognized template name", func(t *testing.T) { + repo := mailer.NewMailRepository(config.SmtpSettings{}) + + subject, body, err := repo.Render("does-not-exist", nil) + + if subject != "RenewCMS | Action required" { + t.Errorf("expected fallback subject, got %q", subject) + } + if err == nil { + t.Fatal("expected an error because templates/does-not-exist.html cannot be found") + } + if body != "" { + t.Errorf("expected empty body, got %q", body) + } + }) + + t.Run("A template execution failure is returned without sending", func(t *testing.T) { + repo := mailer.NewMailRepository(config.SmtpSettings{}) + + type incompatibleData struct { + Unrelated int + } + + _, body, err := repo.Render("verification", incompatibleData{Unrelated: 1}) + + if err == nil { + t.Fatal("expected an error from a failed template execution") + } + if body != "" { + t.Errorf("expected empty body, got %q", body) + } + }) + + t.Run("Send() returns a render error without dialing SMTP", func(t *testing.T) { + repo := mailer.NewMailRepository(config.SmtpSettings{}) + + _, _, renderErr := repo.Render("does-not-exist", nil) + err := repo.Send("someone@example.com", "does-not-exist", nil) + + if err == nil { + t.Fatal("expected an error from a failed render, got nil") + } + if err.Error() != renderErr.Error() { + t.Errorf("expected Send to return the render error %q, got %q", renderErr, err) + } + }) + + t.Run("The old template files no longer exist under their previous names", func(t *testing.T) { + for _, oldFile := range []string{"mailValidation.html", "passwordReset.html"} { + path := "../internal/infrastructure/mailer/templates/" + oldFile + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Errorf("expected %s to not exist, got err=%v", path, err) + } + } + }) +}