Skip to content

fix(auth): guard against empty DashboardURL in invite + password-reset email construction (part 1 of #355) - #362

Merged
cristim merged 2 commits into
feat/multicloud-web-frontendfrom
fix/email-urls-and-html
May 13, 2026
Merged

fix(auth): guard against empty DashboardURL in invite + password-reset email construction (part 1 of #355)#362
cristim merged 2 commits into
feat/multicloud-web-frontendfrom
fix/email-urls-and-html

Conversation

@cristim

@cristim cristim commented May 13, 2026

Copy link
Copy Markdown
Member

Summary

Two stacked URL construction sites in the auth service produced broken relative links when s.dashboardURL was empty, breaking the invite email AND the forgot-password email. This PR is part 1 of #355 — the URL-construction guards. Part 2 (HTML pretty-button templates) lands in follow-up commits on this same branch.

Closes #355 once both parts land.

Part 1 (this commit) — URL construction guards

Site Before After
internal/auth/service_user.go invite path fmt.Sprintf("%s/reset-password?token=%s", "", token)/reset-password?... unclickable Refuse send if dashboardURL == "". InviteEmailError set so admin sees a clear "fix DASHBOARD_URL" hint.
internal/auth/service_password.go RequestPasswordReset same shape Refuse send if dashboardURL == "". Silent to preserve email-enumeration protection, but ERROR-logged so the operator sees the failure in CloudWatch.
internal/auth/service.go NewService accepted empty URL silently Logs a fatal-class WARN at startup naming the missing env var.

Extracted sendInviteEmail from Service.CreateUser so the parent stays under gocyclo's complexity threshold.

Part 2 (follow-up commits on this branch)

  • HTML variant of userInviteTemplate with a styled CTA button, modelled on the purchase-approval HTML template at internal/email/templates.go:367.
  • HTML variant of passwordResetTemplate.
  • HTML variant of welcomeUserTemplate.
  • Shared sendMultipartVia helper extracted from sendPurchaseApprovalRequestVia so the new flows don't drift from each other.

Out of scope (separate follow-up issues)

  • HTML variants for purchaseConfirmationTemplate, purchaseFailedTemplate, riExchangePendingApprovalTemplate, riExchangeCompletedTemplate, registrationReceivedTemplate, registrationDecisionTemplate. All currently plain-text only with raw URLs; admin-facing so lower priority.
  • The Terraform/CDK change to actually set DASHBOARD_URL on the live Lambda. The code change alone closes the loophole at the service layer, but the deployment also needs the env var pointed at the dashboard origin.

Sister fix

PR #357 (closes #356, P0/critical) base64-decode fix on the resetPassword API handler. Without that, the password the user "sets" via the reset flow doesn't work even when the URL is correct. Must land ahead of this PR for the end-to-end invite flow to work.

Test plan

  • go test github.com/LeanerCloud/CUDly/internal/auth — passes
  • go build ./... — clean
  • End-to-end on the deployed env (post-merge + post-DASHBOARD_URL-config):
    • Admin invites a user → recipient gets an email with a proper https://<dashboard>/reset-password?token=... link.
    • Forgot-password from the sign-in page → same.
    • When DASHBOARD_URL is unset, gh logs shows the startup WARN + per-send ERROR; the invite API response carries invite_email_sent=false + the explanatory invite_email_error.

Summary by CodeRabbit

  • New Features

    • Emails now include styled HTML alongside plain-text (multipart) when available.
  • Bug Fixes

    • Startup warns when dashboard URL is missing to prevent broken links in emails.
    • Password reset and invite flows skip sending unusable links and record clear error/status hints instead of failing the request.
  • Tests

    • Added HTML email template tests to verify links, CTAs, salutations, and role rendering.

Review Change Stack

…pty (#355)

Two stacked URL construction sites in the auth service produced broken
relative links when s.dashboardURL was empty:

- internal/auth/service_user.go invite path
  setupURL = fmt.Sprintf("%s/reset-password?token=%s", "", token)
  yields "/reset-password?token=..." which is unclickable in any MUA.
- internal/auth/service_password.go RequestPasswordReset has the same shape.

Both now check s.dashboardURL early and refuse to send. The invite path
sets InviteEmailError on the result so the admin sees a clear "fix
DASHBOARD_URL" hint in the API response rather than a misleading
"email sent: true" with a dead link. The forgot-password path skips
the send silently to preserve the existing email-enumeration protection
but logs an ERROR so the operator sees the failure in CloudWatch.

NewService also logs a fatal-class WARN at startup when DashboardURL
is empty — the misconfiguration is now visible at process startup
instead of only when the first invite goes out.

This is the URL-construction half of #355. The HTML pretty-button
half (multipart bodies, CTA buttons modelled on the purchase-approval
email at templates.go:367) lands in subsequent commits on this branch.

Sister fix already merged ahead of this one: #357 (resetPassword
handler base64-decode). Without that, the password the user sets
via the reset flow does not work even when the URL is correct.
@cristim cristim added bug Something isn't working triaged Item has been triaged priority/p1 Next up; this sprint severity/high Significant harm urgency/this-sprint Within the current sprint impact/many Affects most users effort/s Hours type/bug Defect labels May 13, 2026
@cristim

cristim commented May 13, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@cristim cristim added bug Something isn't working triaged Item has been triaged priority/p1 Next up; this sprint severity/high Significant harm urgency/this-sprint Within the current sprint impact/many Affects most users effort/s Hours type/bug Defect labels May 13, 2026
@coderabbitai

coderabbitai Bot commented May 13, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai

coderabbitai Bot commented May 13, 2026

Copy link
Copy Markdown
Contributor

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 76ad4ebd-6396-4979-af7d-e5cb5111abee

📥 Commits

Reviewing files that changed from the base of the PR and between ebbddea and af27221.

📒 Files selected for processing (4)
  • internal/email/smtp_sender.go
  • internal/email/template_renderers.go
  • internal/email/template_renderers_test.go
  • internal/email/templates.go

📝 Walkthrough

Walkthrough

Adds a startup warning for empty DashboardURL, guards password-reset and invite sends to skip email generation when the dashboard URL is unset, refactors invite email sending, and adds HTML email templates, renderers, multipart send logic, and tests.

Changes

DashboardURL Configuration Safeguards

Layer / File(s) Summary
Startup validation and warning
internal/auth/service.go
NewService logs a warning if DashboardURL is empty, explaining that password reset and invite emails will generate unusable relative links.
Password reset email safeguard
internal/auth/service_password.go
RequestPasswordReset checks for empty dashboardURL and skips sending the reset email, logging the reason while preserving email-enumeration protection.
Invite email refactor with safeguard
internal/auth/service_user.go
CreateUser now calls sendInviteEmail which checks dashboardURL, sets InviteEmailSent/InviteEmailError, logs when skipping, and otherwise builds and sends the setup invite.
HTML templates and renderers
internal/email/templates.go, internal/email/template_renderers.go
Adds styled HTML templates and new exporters RenderPasswordResetEmailHTML, RenderWelcomeEmailHTML, and RenderUserInviteEmailHTML.
SMTP multipart send integration
internal/email/smtp_sender.go, internal/email/templates.go
SMTPSender methods were reworked to use sendMultipartVia and send multipart/alternative emails (text + HTML); HTML render errors fall back to text-only.
HTML template tests
internal/email/template_renderers_test.go
Adds tests verifying reset, invite, and welcome HTML include CTAs, salutations, and expected content.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related issues

  • LeanerCloud/CUDly#355: Implements startup warn and per-send guards for empty DashboardURL as described in the issue.

Possibly related PRs

  • LeanerCloud/CUDly#348: Related changes to invite-user flow and SendUserInviteEmail; both PRs touch invite-email construction and sending.

Poem

🐰 A dashboard URL left bare and thin,
Would send poor links that never could begin.
We warn at start and skip the broken send,
Render HTML kindly, then to text we bend.
Soft invites hop safe to every friend's inn.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately and specifically describes the main change: adding guards against empty DashboardURL in invite and password-reset email construction, with clear reference to the issue scope (part 1 of #355).
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/email-urls-and-html

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/auth/service_password.go`:
- Around line 275-284: In RequestPasswordReset, the s.dashboardURL empty check
runs after generating and persisting a reset token so it can rotate a token
without sending email; move the s.dashboardURL == "" guard to run before any
token creation or persistence logic (i.e., before the code that generates/saves
the reset token) so that no token is created/saved when DashboardURL is unset,
and keep the same non-error early return and logging behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8d320826-83aa-436b-9fe9-37c490f070b9

📥 Commits

Reviewing files that changed from the base of the PR and between 94b4f96 and ebbddea.

📒 Files selected for processing (3)
  • internal/auth/service.go
  • internal/auth/service_password.go
  • internal/auth/service_user.go

Comment on lines +275 to +284
// Skip the email entirely if dashboardURL is unconfigured — a broken
// relative link in an inbox is worse than no email; the operator's
// startup-time WARN already names the missing env var. Don't return an
// error to the caller to preserve the email-enumeration protection
// (RequestPasswordReset must look identical whether or not the email
// exists, and that includes whether or not a send happened). Issue #355.
if s.dashboardURL == "" {
logging.Errorf("RequestPasswordReset: skipping send — DashboardURL empty would produce a broken relative link (set DASHBOARD_URL).")
return nil
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Move the empty-DashboardURL guard before token persistence.

At Line 281, the function returns early only after generating and saving a new reset token. When DashboardURL is empty, this still rotates the token without sending any email, which can invalidate a previously delivered reset link and strand the user.

💡 Suggested fix
 func (s *Service) RequestPasswordReset(ctx context.Context, email string) error {
 	user, err := s.store.GetUserByEmail(ctx, email)
 	if err != nil {
 		if errors.Is(err, pgx.ErrNoRows) {
 			// Don't reveal if email exists
 			logging.Debugf("Password reset requested for non-existent email: %s", redactEmail(email))
 			return nil
 		}
 		return err
 	}
 	if user == nil {
 		// Don't reveal if email exists
 		logging.Debugf("Password reset requested for non-existent email: %s", redactEmail(email))
 		return nil
 	}
+
+	// Skip before mutating reset-token state if DashboardURL is unconfigured.
+	if s.dashboardURL == "" {
+		logging.Errorf("RequestPasswordReset: skipping send — DashboardURL empty would produce a broken relative link (set DASHBOARD_URL).")
+		return nil
+	}
 
 	// Generate reset token
 	token, err := generateToken()
 	if err != nil {
 		return fmt.Errorf("failed to generate reset token: %w", err)
 	}
@@
-	// Skip the email entirely if dashboardURL is unconfigured — a broken
-	// relative link in an inbox is worse than no email; the operator's
-	// startup-time WARN already names the missing env var. Don't return an
-	// error to the caller to preserve the email-enumeration protection
-	// (RequestPasswordReset must look identical whether or not the email
-	// exists, and that includes whether or not a send happened). Issue `#355`.
-	if s.dashboardURL == "" {
-		logging.Errorf("RequestPasswordReset: skipping send — DashboardURL empty would produce a broken relative link (set DASHBOARD_URL).")
-		return nil
-	}
-
 	// Send reset email (use unhashed token in URL)
 	resetURL := fmt.Sprintf("%s/reset-password?token=%s", s.dashboardURL, token)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/auth/service_password.go` around lines 275 - 284, In
RequestPasswordReset, the s.dashboardURL empty check runs after generating and
persisting a reset token so it can rotate a token without sending email; move
the s.dashboardURL == "" guard to run before any token creation or persistence
logic (i.e., before the code that generates/saves the reset token) so that no
token is created/saved when DashboardURL is unset, and keep the same non-error
early return and logging behavior.

…come (#355)

Part 2 of #355. Replaces the raw-URL plain-text shape with a styled
HTML email that carries a single CTA button, falling back to the
existing plain-text body for MUAs that strip HTML. The visual idiom
mirrors purchaseApprovalRequestHTMLTemplate (templates.go:367) —
inline styles only, since most email clients (Outlook, mobile Gmail)
ignore class-based CSS.

New constants in internal/email/templates.go:
  - passwordResetHTMLTemplate (CTA "Reset your password", blue button)
  - welcomeUserHTMLTemplate    (CTA "Open dashboard",      green button)
  - userInviteHTMLTemplate     (CTA "Set your password",   green button)

Each template includes a "if the button doesn't work, paste this URL"
fallback that surfaces the absolute URL verbatim — keeps the link
recoverable even in stripped-HTML clients without forcing users to
deal with a button that doesn't render.

New render functions (template_renderers.go):
  - RenderPasswordResetEmailHTML
  - RenderWelcomeEmailHTML
  - RenderUserInviteEmailHTML

Shared multipart dispatch helper (templates.go):
  - sendMultipartVia(ctx, s, recipient, subject, kind, renderText, renderHTML)
    extracted from the existing sendPurchaseApprovalRequestVia pattern.
    HTML render failures degrade to text-only delivery — a template bug
    never drops the email.

Both AWS-SES (Sender) and Azure-ACS/GCP-SendGrid (SMTPSender) paths
route through sendMultipartVia. Subjects stay verbatim with the
existing flavours so no Inbox-rule regression is introduced.

Tests in template_renderers_test.go assert that each HTML output
contains the CTA button text, the absolute URL in the href, the
fallback "paste this URL" copy with the same absolute URL, and the
salutation that pulls in the recipient's email.

Closes #355 (combined with the URL-construction guards in the
previous commit on this branch, ebbddea).
@cristim

cristim commented May 13, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 13, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working effort/s Hours impact/many Affects most users priority/p1 Next up; this sprint severity/high Significant harm triaged Item has been triaged type/bug Defect urgency/this-sprint Within the current sprint

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant