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 { if err != nil {
return "", err return "", err
} }
return base64.URLEncoding.EncodeToString(b), nil return base64.RawURLEncoding.EncodeToString(b), nil
} }
func (h *AuthHandler) ForgotPassword(c *gin.Context) { func (h *AuthHandler) ForgotPassword(c *gin.Context) {

View file

@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/url"
"strings" "strings"
"sync" "sync"
"time" "time"
@ -52,39 +53,40 @@ type sendPulseIdentity struct {
func (s *EmailService) SendVerificationEmail(toEmail, toName, token string) error { func (s *EmailService) SendVerificationEmail(toEmail, toName, token string) error {
subject := "Verify your Sojorn account" 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") 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" title := "Email Verification"
header := fmt.Sprintf("Hey %s! 👋", toName) header := fmt.Sprintf("Hey %s!", toName)
if toName == "" { if toName == "" {
header = "Hey there! 👋" header = "Hey there!"
} }
content := ` 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> <p>To get started in the app, please verify your email address by clicking the button below:</p>
` `
footer := ` footer := `
<div style="background: #F9FAFB; border-radius: 12px; padding: 20px; margin-top: 24px; text-align: left;"> <table role="presentation" width="100%%" cellpadding="0" cellspacing="0" style="margin-top: 24px;">
<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> <tr><td style="background: #F9FAFB; border-radius: 8px; padding: 16px;">
<a href="%s" style="color: #4338CA; text-decoration: underline; word-break: break-all; font-size: 12px; font-weight: 500;">%s</a> <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>
</div> <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) footer = fmt.Sprintf(footer, verifyURL, verifyURL)
htmlBody := s.buildHTMLEmail(title, header, content, verifyURL, "Verify My Email", footer) 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) return s.sendEmail(toEmail, toName, subject, htmlBody, textBody)
} }
func (s *EmailService) SendPasswordResetEmail(toEmail, toName, token string) error { func (s *EmailService) SendPasswordResetEmail(toEmail, toName, token string) error {
subject := "Reset your Sojorn password" 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" title := "Password Reset"
header := "Reset your password" header := "Reset your password"
@ -94,11 +96,18 @@ func (s *EmailService) SendPasswordResetEmail(toEmail, toName, token string) err
`, toName) `, toName)
footer := ` 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) 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) 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 { func (s *EmailService) buildHTMLEmail(title, header, content, buttonURL, buttonText, footer string) string {
return fmt.Sprintf(` return fmt.Sprintf(`<!DOCTYPE html>
<!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">
<html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <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> <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> </head>
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; margin: 0; padding: 0; background-color: #F3F4F6;"> <body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; margin: 0; padding: 0; background-color: #F3F4F6; width: 100%%;">
<div style="padding: 40px 20px; background-color: #F3F4F6;"> <table role="presentation" width="100%%" cellpadding="0" cellspacing="0" style="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);"> <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 --> <!-- Header -->
<div style="background: linear-gradient(135deg, #4338CA 0%%, #6366F1 100%%); padding: 40px; text-align: center;"> <tr><td style="background-color: #4338CA; 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;"> <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;">
<div style="color: #ffffff; font-size: 12px; font-weight: 600; letter-spacing: 1px; text-transform: uppercase; opacity: 0.9;">%s</div> <p style="color: #ffffff; font-size: 12px; font-weight: 600; letter-spacing: 1px; text-transform: uppercase; margin: 0;">%s</p>
</div> </td></tr>
<!-- Content --> <!-- Content -->
<div style="padding: 40px; text-align: center; color: #374151;"> <tr><td style="padding: 40px; text-align: center; color: #374151;">
<h1 style="color: #1F2937; font-size: 24px; font-weight: 700; margin-bottom: 16px;">%s</h1> <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;"> <div style="font-size: 16px; line-height: 1.6; color: #4B5563; margin-bottom: 32px; text-align: left;">
%s %s
</div> </div>
<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);"> <!-- Button (table-based for Outlook) -->
%s <table role="presentation" cellpadding="0" cellspacing="0" align="center" style="margin: 0 auto;">
</a> <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 %s
</div> </td></tr>
<!-- Footer --> <!-- Footer -->
<div style="padding: 32px; text-align: center; background-color: #F9FAFB; border-top: 1px solid #E5E7EB;"> <tr><td 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> <p style="font-size: 12px; color: #9CA3AF; margin: 0 0 8px 0;">&copy; 2026 Sojorn by MPLS LLC. All rights reserved.</p>
<div style="font-size: 12px; color: #9CA3AF;"> <p style="font-size: 12px; color: #9CA3AF; margin: 0;">
<a href="https://sojorn.net" style="color: #9CA3AF; text-decoration: none; margin: 0 8px;">Website</a> <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; margin: 0 8px;">Privacy</a> <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; margin: 0 8px;">Terms</a> <a href="https://sojorn.net/terms" style="color: #9CA3AF; text-decoration: none;">Terms</a>
</div> </p>
</div> </td></tr>
</div> </table>
</div> </td></tr>
</table>
</body> </body>
</html> </html>`, title, title, header, content, buttonURL, buttonText, footer)
`, title, title, header, content, buttonURL, buttonText, footer)
} }