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`.
|
||||
@@ -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}}&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}}&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)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user