Fix email verification: table-based HTML template, URL-encode tokens, remove base64 padding

This commit is contained in:
Patrick Britton 2026-02-06 11:26:51 -06:00
parent 66fe4bd60e
commit a87fcb60b6
2 changed files with 74 additions and 50 deletions

View file

@ -395,7 +395,7 @@ func generateRandomString(n int) (string, error) {
if err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(b), nil
return base64.RawURLEncoding.EncodeToString(b), nil
}
func (h *AuthHandler) ForgotPassword(c *gin.Context) {

View file

@ -6,6 +6,7 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
@ -52,39 +53,40 @@ type sendPulseIdentity struct {
func (s *EmailService) SendVerificationEmail(toEmail, toName, token string) error {
subject := "Verify your Sojorn account"
// Ensure we don't double up on /api/v1 if it's already in the config
apiBase := strings.TrimSuffix(s.config.APIBaseURL, "/api/v1")
verifyURL := fmt.Sprintf("%s/api/v1/auth/verify?token=%s", apiBase, token)
verifyURL := fmt.Sprintf("%s/api/v1/auth/verify?token=%s", apiBase, url.QueryEscape(token))
title := "Email Verification"
header := fmt.Sprintf("Hey %s! 👋", toName)
header := fmt.Sprintf("Hey %s!", toName)
if toName == "" {
header = "Hey there! 👋"
header = "Hey there!"
}
content := `
<p>Welcome to Sojorn your vibrant new social space. We're thrilled to have you join our community!</p>
<p>Welcome to Sojorn &mdash; your vibrant new social space. We're thrilled to have you join our community!</p>
<p>To get started in the app, please verify your email address by clicking the button below:</p>
`
footer := `
<div style="background: #F9FAFB; border-radius: 12px; padding: 20px; margin-top: 24px; text-align: left;">
<p style="font-size: 13px; color: #9CA3AF; margin-bottom: 8px;">If the button doesn't work, copy and paste this link into your browser:</p>
<a href="%s" style="color: #4338CA; text-decoration: underline; word-break: break-all; font-size: 12px; font-weight: 500;">%s</a>
</div>
<table role="presentation" width="100%%" cellpadding="0" cellspacing="0" style="margin-top: 24px;">
<tr><td style="background: #F9FAFB; border-radius: 8px; padding: 16px;">
<p style="font-size: 13px; color: #9CA3AF; margin: 0 0 8px 0;">If the button above doesn't work, copy and paste this link into your browser:</p>
<a href="%s" style="color: #4338CA; text-decoration: underline; word-break: break-all; font-size: 13px;">%s</a>
</td></tr>
</table>
`
footer = fmt.Sprintf(footer, verifyURL, verifyURL)
htmlBody := s.buildHTMLEmail(title, header, content, verifyURL, "Verify My Email", footer)
textBody := fmt.Sprintf("Welcome to Sojorn! Please verify your email by clicking here: %s", verifyURL)
textBody := fmt.Sprintf("Welcome to Sojorn!\n\nPlease verify your email by visiting this link:\n\n%s\n\nIf you did not create an account, you can ignore this email.", verifyURL)
return s.sendEmail(toEmail, toName, subject, htmlBody, textBody)
}
func (s *EmailService) SendPasswordResetEmail(toEmail, toName, token string) error {
subject := "Reset your Sojorn password"
resetURL := fmt.Sprintf("%s/reset-password?token=%s", s.config.AppBaseURL, token)
resetURL := fmt.Sprintf("%s/reset-password?token=%s", s.config.AppBaseURL, url.QueryEscape(token))
title := "Password Reset"
header := "Reset your password"
@ -94,11 +96,18 @@ func (s *EmailService) SendPasswordResetEmail(toEmail, toName, token string) err
`, toName)
footer := `
<p style="color: #9CA3AF; font-size: 13px; margin-top: 24px;">This link expires in 1 hour. If you did not request this, you can safely ignore this email.</p>
<table role="presentation" width="100%%" cellpadding="0" cellspacing="0" style="margin-top: 24px;">
<tr><td style="background: #F9FAFB; border-radius: 8px; padding: 16px;">
<p style="font-size: 13px; color: #9CA3AF; margin: 0 0 8px 0;">If the button doesn't work, copy and paste this link:</p>
<a href="%s" style="color: #4338CA; text-decoration: underline; word-break: break-all; font-size: 13px;">%s</a>
</td></tr>
</table>
<p style="color: #9CA3AF; font-size: 12px; margin-top: 16px;">This link expires in 1 hour. If you did not request this, you can safely ignore this email.</p>
`
footer = fmt.Sprintf(footer, resetURL, resetURL)
htmlBody := s.buildHTMLEmail(title, header, content, resetURL, "Reset Password", footer)
textBody := fmt.Sprintf("Reset your Sojorn password: %s", resetURL)
textBody := fmt.Sprintf("Reset your Sojorn password by visiting this link:\n\n%s\n\nThis link expires in 1 hour.", resetURL)
return s.sendEmail(toEmail, toName, subject, htmlBody, textBody)
}
@ -257,49 +266,64 @@ func (s *EmailService) AddSubscriber(email, name string) {
}
func (s *EmailService) buildHTMLEmail(title, header, content, buttonURL, buttonText, footer string) string {
return fmt.Sprintf(`
<!DOCTYPE html>
<html lang="en">
return fmt.Sprintf(`<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="x-apple-disable-message-reformatting">
<title>%s</title>
<!--[if mso]>
<noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript>
<![endif]-->
<style>
body, table, td, a { -webkit-text-size-adjust: 100%%; -ms-text-size-adjust: 100%%; }
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
img { -ms-interpolation-mode: bicubic; border: 0; height: auto; line-height: 100%%; outline: none; text-decoration: none; }
body { margin: 0; padding: 0; width: 100%% !important; }
a[x-apple-data-detectors] { color: inherit !important; text-decoration: none !important; font-size: inherit !important; font-family: inherit !important; font-weight: inherit !important; line-height: inherit !important; }
</style>
</head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; margin: 0; padding: 0; background-color: #F3F4F6;">
<div style="padding: 40px 20px; background-color: #F3F4F6;">
<div style="max-width: 520px; margin: 0 auto; background-color: #ffffff; border-radius: 24px; overflow: hidden; box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);">
<!-- Header -->
<div style="background: linear-gradient(135deg, #4338CA 0%%, #6366F1 100%%); padding: 40px; text-align: center;">
<img src="https://sojorn.net/web.png" alt="Sojorn" style="width: 80px; height: 80px; border-radius: 20px; margin-bottom: 16px;">
<div style="color: #ffffff; font-size: 12px; font-weight: 600; letter-spacing: 1px; text-transform: uppercase; opacity: 0.9;">%s</div>
</div>
<!-- Content -->
<div style="padding: 40px; text-align: center; color: #374151;">
<h1 style="color: #1F2937; font-size: 24px; font-weight: 700; margin-bottom: 16px;">%s</h1>
<div style="font-size: 16px; line-height: 1.6; color: #4B5563; margin-bottom: 32px;">
%s
</div>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; margin: 0; padding: 0; background-color: #F3F4F6; width: 100%%;">
<table role="presentation" width="100%%" cellpadding="0" cellspacing="0" style="background-color: #F3F4F6;">
<tr><td style="padding: 40px 20px;">
<table role="presentation" width="520" cellpadding="0" cellspacing="0" align="center" style="max-width: 520px; margin: 0 auto; background-color: #ffffff; border-radius: 16px; overflow: hidden;">
<!-- Header -->
<tr><td style="background-color: #4338CA; padding: 40px; text-align: center;">
<img src="https://sojorn.net/web.png" alt="Sojorn" width="80" height="80" style="width: 80px; height: 80px; border-radius: 20px; margin-bottom: 16px; display: block; margin-left: auto; margin-right: auto;">
<p style="color: #ffffff; font-size: 12px; font-weight: 600; letter-spacing: 1px; text-transform: uppercase; margin: 0;">%s</p>
</td></tr>
<a href="%s" style="display: inline-block; padding: 16px 40px; background-color: #4338CA; color: #ffffff; text-decoration: none; border-radius: 12px; font-weight: 600; font-size: 16px; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);">
<!-- Content -->
<tr><td style="padding: 40px; text-align: center; color: #374151;">
<h1 style="color: #1F2937; font-size: 24px; font-weight: 700; margin: 0 0 16px 0;">%s</h1>
<div style="font-size: 16px; line-height: 1.6; color: #4B5563; margin-bottom: 32px; text-align: left;">
%s
</div>
<!-- Button (table-based for Outlook) -->
<table role="presentation" cellpadding="0" cellspacing="0" align="center" style="margin: 0 auto;">
<tr><td style="background-color: #4338CA; border-radius: 12px; text-align: center;">
<a href="%s" target="_blank" style="display: block; padding: 16px 40px; color: #ffffff; text-decoration: none; font-weight: 600; font-size: 16px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;">%s</a>
</td></tr>
</table>
%s
</a>
</td></tr>
%s
</div>
<!-- Footer -->
<div style="padding: 32px; text-align: center; background-color: #F9FAFB; border-top: 1px solid #E5E7EB;">
<p style="font-size: 12px; color: #9CA3AF; margin-bottom: 8px;">© 2026 Sojorn by MPLS LLC. All rights reserved.</p>
<div style="font-size: 12px; color: #9CA3AF;">
<a href="https://sojorn.net" style="color: #9CA3AF; text-decoration: none; margin: 0 8px;">Website</a>
<a href="https://sojorn.net/privacy" style="color: #9CA3AF; text-decoration: none; margin: 0 8px;">Privacy</a>
<a href="https://sojorn.net/terms" style="color: #9CA3AF; text-decoration: none; margin: 0 8px;">Terms</a>
</div>
</div>
</div>
</div>
<!-- Footer -->
<tr><td style="padding: 32px; text-align: center; background-color: #F9FAFB; border-top: 1px solid #E5E7EB;">
<p style="font-size: 12px; color: #9CA3AF; margin: 0 0 8px 0;">&copy; 2026 Sojorn by MPLS LLC. All rights reserved.</p>
<p style="font-size: 12px; color: #9CA3AF; margin: 0;">
<a href="https://sojorn.net" style="color: #9CA3AF; text-decoration: none;">Website</a> &bull;
<a href="https://sojorn.net/privacy" style="color: #9CA3AF; text-decoration: none;">Privacy</a> &bull;
<a href="https://sojorn.net/terms" style="color: #9CA3AF; text-decoration: none;">Terms</a>
</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
`, title, title, header, content, buttonURL, buttonText, footer)
</html>`, title, title, header, content, buttonURL, buttonText, footer)
}