feat: new emails design

This commit is contained in:
fsylvain committed 2026-09-26 05:41:34 +02:00
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
+5 -5
View File
@@ -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`.
@@ -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)
@@ -1,18 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<body>
<h1>
<img src="{{.Host}}/static/renewcms-favicon-64.png" alt="G" style="width: 2.5rem"/>
RenewCMS
</h1>
<main>
<p>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.</p>
<p><b>If you want to validate the account creation, please <a
href="{{.Host}}/register/validate?c={{.VerificationCode}}" target="_blank">click HERE</a>.</b>
</p>
<p>If you can't click the link:</p>
<p>{{.Host}}/register/validate?c={{.VerificationCode}}</p>
</main>
</body>
</html>
@@ -0,0 +1,72 @@
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>Reset your password</title>
<style>
body { margin: 0; padding: 0; width: 100%; -webkit-text-size-adjust: 100%; }
table { border-collapse: collapse; }
a { color: #111111; }
@media (max-width: 620px) {
.rc-card { padding: 28px 20px !important; }
.rc-btn a { display: block !important; }
}
@media (prefers-color-scheme: dark) {
.rc-bg { background-color: #0a0a0a !important; }
.rc-card { background-color: #111111 !important; border-color: #333333 !important; }
.rc-h1, .rc-word { color: #fafafa !important; }
.rc-text { color: #e6e4e0 !important; }
.rc-muted { color: #999999 !important; }
.rc-mark { border: 1px solid #444444 !important; }
.rc-btn td { background-color: #fafafa !important; }
.rc-btn a { background-color: #fafafa !important; color: #111111 !important; border-color: #fafafa !important; }
.rc-url { color: #e6e4e0 !important; background-color: #1a1a1a !important; }
.rc-rule { border-color: #333333 !important; }
}
</style>
</head>
<body class="rc-bg" style="margin:0;padding:0;background-color:#fafafa;">
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;">Choose a new password for RenewCMS. The link expires in 1 hour.</div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" class="rc-bg" style="background-color:#fafafa;">
<tr>
<td align="center" style="padding:40px 12px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:600px;">
<tr>
<td style="padding:0 0 20px 0;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr>
<td class="rc-mark" width="32" height="32" align="center" valign="middle" bgcolor="#111111" style="width:32px;height:32px;background-color:#111111;color:#ffffff;font-family:Arial,Helvetica,sans-serif;font-size:19px;font-weight:bold;line-height:32px;">R</td>
<td class="rc-word" style="padding-left:10px;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:17px;font-weight:600;color:#111111;">RenewCMS</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="rc-card" style="background-color:#ffffff;border:1px solid #e6e4e0;padding:40px;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;">
<h1 class="rc-h1" style="margin:0 0 16px 0;font-size:24px;line-height:32px;font-weight:600;color:#111111;">Reset your password</h1>
<p class="rc-text" style="margin:0 0 28px 0;font-size:16px;line-height:26px;color:#222222;">Someone asked to reset the password for the RenewCMS account that uses {{.Email}} at {{.Host}}.</p>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" class="rc-btn">
<tr>
<td bgcolor="#111111" style="background-color:#111111;">
<a href="{{.Host}}/register/reset/validate?c={{.VerificationCode}}&amp;email={{.Email}}" target="_blank" style="display:inline-block;padding:14px 24px;border:1px solid #111111;background-color:#111111;color:#ffffff;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:16px;font-weight:600;line-height:20px;text-decoration:none;text-align:center;">Choose a new password</a>
</td>
</tr>
</table>
<p class="rc-muted" style="margin:16px 0 0 0;font-size:14px;line-height:22px;color:#666666;">This link expires in 1 hour and works once.</p>
<p class="rc-muted" style="margin:28px 0 8px 0;font-size:14px;line-height:22px;color:#666666;">If the button doesn't work, copy this address into your browser:</p>
<p class="rc-url" style="margin:0;padding:10px 12px;background-color:#f3f2f0;font-family:'IBM Plex Mono',Menlo,Consolas,monospace;font-size:13px;line-height:20px;color:#222222;word-break:break-all;">{{.Host}}/register/reset/validate?c={{.VerificationCode}}&amp;email={{.Email}}</p>
<p class="rc-text rc-rule" style="margin:28px 0 0 0;padding-top:20px;border-top:1px solid #e6e4e0;font-size:14px;line-height:22px;color:#222222;">If this wasn't you, ignore this email; nothing will change.</p>
</td>
</tr>
<tr>
<td class="rc-muted" style="padding:20px 0 0 0;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:12px;line-height:18px;color:#666666;">RenewCMS · {{.Host}}</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
@@ -1,18 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<body>
<h1>
<img src="{{.Host}}/static/renewcms-favicon-64.png" alt="G" style="width: 2.5rem"/>
RenewCMS
</h1>
<main>
<p>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.</p>
<p><b>If you want to reset your password, please <a
href="{{.Host}}/register/reset/validate?c={{.VerificationCode}}&email={{.Email}}" target="_blank">click HERE</a>.</b>
</p>
<p>If you can't click the link:</p>
<p>{{.Host}}/register/reset/validate?c={{.VerificationCode}}&email={{.Email}}</p>
</main>
</body>
</html>
@@ -0,0 +1,72 @@
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>Verify your account</title>
<style>
body { margin: 0; padding: 0; width: 100%; -webkit-text-size-adjust: 100%; }
table { border-collapse: collapse; }
a { color: #111111; }
@media (max-width: 620px) {
.rc-card { padding: 28px 20px !important; }
.rc-btn a { display: block !important; }
}
@media (prefers-color-scheme: dark) {
.rc-bg { background-color: #0a0a0a !important; }
.rc-card { background-color: #111111 !important; border-color: #333333 !important; }
.rc-h1, .rc-word { color: #fafafa !important; }
.rc-text { color: #e6e4e0 !important; }
.rc-muted { color: #999999 !important; }
.rc-mark { border: 1px solid #444444 !important; }
.rc-btn td { background-color: #fafafa !important; }
.rc-btn a { background-color: #fafafa !important; color: #111111 !important; border-color: #fafafa !important; }
.rc-url { color: #e6e4e0 !important; background-color: #1a1a1a !important; }
.rc-rule { border-color: #333333 !important; }
}
</style>
</head>
<body class="rc-bg" style="margin:0;padding:0;background-color:#fafafa;">
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;">Confirm your email to finish setting up RenewCMS. The link expires in 2 hours.</div>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" class="rc-bg" style="background-color:#fafafa;">
<tr>
<td align="center" style="padding:40px 12px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:600px;">
<tr>
<td style="padding:0 0 20px 0;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
<tr>
<td class="rc-mark" width="32" height="32" align="center" valign="middle" bgcolor="#111111" style="width:32px;height:32px;background-color:#111111;color:#ffffff;font-family:Arial,Helvetica,sans-serif;font-size:19px;font-weight:bold;line-height:32px;">R</td>
<td class="rc-word" style="padding-left:10px;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:17px;font-weight:600;color:#111111;">RenewCMS</td>
</tr>
</table>
</td>
</tr>
<tr>
<td class="rc-card" style="background-color:#ffffff;border:1px solid #e6e4e0;padding:40px;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;">
<h1 class="rc-h1" style="margin:0 0 16px 0;font-size:24px;line-height:32px;font-weight:600;color:#111111;">Verify your account</h1>
<p class="rc-text" style="margin:0 0 28px 0;font-size:16px;line-height:26px;color:#222222;">Someone, hopefully you, created the owner account for RenewCMS at {{.Host}}. Verify this email address to finish setting it up.</p>
<table role="presentation" cellpadding="0" cellspacing="0" border="0" class="rc-btn">
<tr>
<td bgcolor="#111111" style="background-color:#111111;">
<a href="{{.Host}}/register/validate?c={{.VerificationCode}}" target="_blank" style="display:inline-block;padding:14px 24px;border:1px solid #111111;background-color:#111111;color:#ffffff;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:16px;font-weight:600;line-height:20px;text-decoration:none;text-align:center;">Verify my account</a>
</td>
</tr>
</table>
<p class="rc-muted" style="margin:16px 0 0 0;font-size:14px;line-height:22px;color:#666666;">This link expires in 2 hours.</p>
<p class="rc-muted" style="margin:28px 0 8px 0;font-size:14px;line-height:22px;color:#666666;">If the button doesn't work, copy this address into your browser:</p>
<p class="rc-url" style="margin:0;padding:10px 12px;background-color:#f3f2f0;font-family:'IBM Plex Mono',Menlo,Consolas,monospace;font-size:13px;line-height:20px;color:#222222;word-break:break-all;">{{.Host}}/register/validate?c={{.VerificationCode}}</p>
<p class="rc-text rc-rule" style="margin:28px 0 0 0;padding-top:20px;border-top:1px solid #e6e4e0;font-size:14px;line-height:22px;color:#222222;">If this wasn't you, ignore this email; nothing will change.</p>
</td>
</tr>
<tr>
<td class="rc-muted" style="padding:20px 0 0 0;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:12px;line-height:18px;color:#666666;">RenewCMS · {{.Host}}</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
@@ -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 {
@@ -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,
@@ -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)
}
})
+4 -4
View File
@@ -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)
}
})
+2 -2
View File
@@ -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 {
+119
View File
@@ -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&amp;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)
}
}
})
}