feat: new emails design
This commit is contained in:
1 parent
4e41d60142
commit
30a4df47b1
15 files changed
+498
-68
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 <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
|
||||
|
||||
@@ -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/<templateName>.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/<templateName>.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/<old file> does not exist on disk
|
||||
|
||||
Examples:
|
||||
| old file |
|
||||
| mailValidation.html |
|
||||
| passwordReset.html |
|
||||
@@ -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", <data with Host "https://cms.example.com", VerificationCode "abc123">)`
|
||||
→ 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", <data with Host "https://cms.example.com", VerificationCode "abc123",
|
||||
Email "alice@example.com">)` → 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(<known templateName>, <data that fails Execute>)` 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`.
|
||||
Reference in new issue
Block a user