Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions backend/internal/handlers/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -846,3 +846,50 @@ func (h *AuthHandler) ChangeTeam(c echo.Context) error {
"team_id": invitation.TeamID,
})
}

// UnsubscribeUser handles both GET and POST requests for unsubscribing users.
// Follows instructions from:
// https://resend.com/docs/dashboard/emails/add-unsubscribe-to-transactional-emails
func (h *AuthHandler) UnsubscribeUser(c echo.Context) error {
token := c.Param("token")
if token == "" {
return echo.NewHTTPError(http.StatusBadRequest, "Token is required")
}

// Find user by "unsubscribe" token
var user models.User
result := h.DB.Where("unsubscribe_id = ?", token).First(&user)
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return echo.NewHTTPError(http.StatusNotFound, "User not found")
}
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to retrieve user details, cannot unsubscribe")
}

// Handle POST request (one-click unsubscribe)
if c.Request().Method == http.MethodPost {
// Unsubscribe user from all emails
if err := user.UnsubscribeFromAllEmails(h.DB); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to unsubscribe")
}

return c.String(http.StatusOK, "You are now unsubscribed from all marketing emails 🥲")
}

// Handle GET request (show unsubscribe page)
if c.Request().Method == http.MethodGet {
// Check if already unsubscribed
if user.EmailSubscriptions.UnsubscribedAt != nil {
return c.Render(http.StatusOK, "unsubscribe-success.html", nil)
}

// Show unsubscribe form
data := map[string]interface{}{
"Email": user.Email,
"Token": token,
}
return c.Render(http.StatusOK, "unsubscribe-form.html", data)
}

return echo.NewHTTPError(http.StatusMethodNotAllowed, "Method not allowed")
}
45 changes: 45 additions & 0 deletions backend/internal/models/user.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package models

import (
"database/sql/driver"
"encoding/json"
"errors"
"fmt"
"time"
Expand All @@ -10,6 +12,26 @@ import (
"gorm.io/gorm"
)

// EmailSubscriptions tracks user's email subscription preferences
type EmailSubscriptions struct {
MarketingEmails bool `gorm:"default:false" json:"marketing_emails"`
UnsubscribedAt *time.Time `json:"unsubscribed_at,omitempty"`
}

// If we get more JSON values fields, we can use a Generic
// to avoid copy-paste
func (es EmailSubscriptions) Value() (driver.Value, error) {
return json.Marshal(es)
}

func (es *EmailSubscriptions) Scan(value interface{}) error {
b, ok := value.([]byte)
if !ok {
return errors.New("type assertion to []byte failed")
}
return json.Unmarshal(b, &es)
}

type User struct {
ID string `json:"id" gorm:"unique;not null"` // Standard field for the primary key
FirstName string `gorm:"not null" json:"first_name" validate:"required"`
Expand All @@ -27,6 +49,10 @@ type User struct {
SocialMetadata map[string]interface{} `gorm:"serializer:json" json:"social_metadata,omitempty"`
// General user metadata for onboarding, preferences, etc.
Metadata map[string]interface{} `gorm:"serializer:json" json:"metadata"`
// Email subscription preferences
EmailSubscriptions EmailSubscriptions `gorm:"type:json" json:"email_subscriptions"`
// Email unsubscribe token - Different from user ID to avoid bad actors unsubscribing others by their public ID
UnsubscribeID string `json:"unsubscribe_id" gorm:"unique;not null"`
}

func (u *User) BeforeCreate(tx *gorm.DB) (err error) {
Expand All @@ -38,6 +64,16 @@ func (u *User) BeforeCreate(tx *gorm.DB) (err error) {
}
u.ID = uuidV7.String()

// Generate a unique unsubscribe ID
unsubUUID, err := uuid.NewRandom()
if err != nil {
return err
}
u.UnsubscribeID = unsubUUID.String()

u.EmailSubscriptions.MarketingEmails = true
u.EmailSubscriptions.UnsubscribedAt = nil

// Hash password if it's set
if u.Password != "" {
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(u.Password), bcrypt.DefaultCost)
Expand Down Expand Up @@ -128,3 +164,12 @@ func (u *User) GetDisplayName() string {
}
return fmt.Sprintf("%s %s", u.FirstName, u.LastName)
}

// UnsubscribeFromAllEmails unsubscribes user from all emails
func (u *User) UnsubscribeFromAllEmails(db *gorm.DB) error {
now := time.Now()
u.EmailSubscriptions.UnsubscribedAt = &now
u.EmailSubscriptions.MarketingEmails = false

return db.Save(u).Error
}
4 changes: 4 additions & 0 deletions backend/internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,10 @@ func (s *Server) setupRoutes() {
// Add invitation details endpoint
api.GET("/invitation-details/:uuid", auth.GetInvitationDetails)

// Unsubscribe endpoints
api.GET("/unsubscribe/:token", auth.UnsubscribeUser)
api.POST("/unsubscribe/:token", auth.UnsubscribeUser)

// Authentication endpoints
api.GET("/auth/social/:provider", auth.SocialLogin)
api.GET("/auth/social/:provider/callback", auth.SocialLoginCallback)
Expand Down
72 changes: 72 additions & 0 deletions backend/web/unsubscribe-form.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<!doctype html>
<html>

<head>
<title>Unsubscribe - Hopp</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
margin: 0;
padding: 40px;
background: #f5f5f5;
}

.container {
max-width: 500px;
margin: 0 auto;
background: white;
padding: 40px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
text-align: center;
}

h1 {
color: #333;
margin-bottom: 20px;
}

p {
color: #666;
line-height: 1.6;
margin-bottom: 30px;
}

.btn {
background: #dc3545;
color: white;
padding: 12px 24px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 16px;
text-decoration: none;
display: inline-block;
}

.btn:hover {
background: #c82333;
}

.email {
font-weight: bold;
color: #333;
}
</style>
</head>

<body>
<div class="container">
<h1>Unsubscribe from Hopp Emails</h1>
<p>Are you sure you want to unsubscribe <span class="email">{{.Email}}</span> from all Hopp emails?</p>
<img src="https://dlh49gjxx49i3.cloudfront.net/emails/sponge-bob-patrick.gif" style="margin-bottom: 24px;border-radius: 6px" />

<form method="POST" action="/api/unsubscribe/{{.Token}}">
<button type="submit" class="btn">Unsubscribe</button>
</form>
</div>
</body>

</html>
40 changes: 40 additions & 0 deletions backend/web/unsubscribe-success.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<!doctype html>
<html>
<head>
<title>Already Unsubscribed - Hopp</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
margin: 0;
padding: 40px;
background: #f5f5f5;
}
.container {
max-width: 500px;
margin: 0 auto;
background: white;
padding: 40px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
text-align: center;
}
h1 {
color: #333;
margin-bottom: 20px;
}
p {
color: #666;
line-height: 1.6;
}
</style>
</head>
<body>
<div class="container">
<h1>Already Unsubscribed</h1>
<p>You have already been unsubscribed from all Hopp emails.</p>
<p>If you continue to receive emails, please contact our support team.</p>
Copy link
Contributor

Choose a reason for hiding this comment

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

Our "support team" 😆

</div>
</body>
</html>