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 @@
-
-
-
-
-
- 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.
+
+
+
+
+
+ |
+
+ |
+
+
+
+ Reset your password
+ Someone asked to reset the password for the RenewCMS account that uses {{.Email}} at {{.Host}}.
+
+ This link expires in 1 hour and works once.
+ If the button doesn't work, copy this address into your browser:
+ {{.Host}}/register/reset/validate?c={{.VerificationCode}}&email={{.Email}}
+ If this wasn't you, ignore this email; nothing will change.
+ |
+
+
+ | RenewCMS · {{.Host}} |
+
+
+ |
+
+
+
+
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 @@
-
-
-
-
-
- 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.
+
+
+
+
+
+ |
+
+ |
+
+
+
+ Verify your account
+ Someone, hopefully you, created the owner account for RenewCMS at {{.Host}}. Verify this email address to finish setting it up.
+
+ This link expires in 2 hours.
+ If the button doesn't work, copy this address into your browser:
+ {{.Host}}/register/validate?c={{.VerificationCode}}
+ If this wasn't you, ignore this email; nothing will change.
+ |
+
+
+ | RenewCMS · {{.Host}} |
+
+
+ |
+
+
+
+
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)
+ }
+ }
+ })
+}