sojorn/go-backend/internal/services/email_service.go
Patrick Britton 3c4680bdd7 Initial commit: Complete threaded conversation system with inline replies
**Major Features Added:**
- **Inline Reply System**: Replace compose screen with inline reply boxes
- **Thread Navigation**: Parent/child navigation with jump functionality
- **Chain Flow UI**: Reply counts, expand/collapse animations, visual hierarchy
- **Enhanced Animations**: Smooth transitions, hover effects, micro-interactions

 **Frontend Changes:**
- **ThreadedCommentWidget**: Complete rewrite with animations and navigation
- **ThreadNode Model**: Added parent references and descendant counting
- **ThreadedConversationScreen**: Integrated navigation handlers
- **PostDetailScreen**: Replaced with threaded conversation view
- **ComposeScreen**: Added reply indicators and context
- **PostActions**: Fixed visibility checks for chain buttons

 **Backend Changes:**
- **API Route**: Added /posts/:id/thread endpoint
- **Post Repository**: Include allow_chain and visibility fields in feed
- **Thread Handler**: Support for fetching post chains

 **UI/UX Improvements:**
- **Reply Context**: Clear indication when replying to specific posts
- **Character Counting**: 500 character limit with live counter
- **Visual Hierarchy**: Depth-based indentation and styling
- **Smooth Animations**: SizeTransition, FadeTransition, hover states
- **Chain Navigation**: Parent/child buttons with visual feedback

 **Technical Enhancements:**
- **Animation Controllers**: Proper lifecycle management
- **State Management**: Clean separation of concerns
- **Navigation Callbacks**: Reusable navigation system
- **Error Handling**: Graceful fallbacks and user feedback

This creates a Reddit-style threaded conversation experience with smooth
animations, inline replies, and intuitive navigation between posts in a chain.
2026-01-30 07:40:19 -06:00

259 lines
8.1 KiB
Go

package services
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"sync"
"time"
sender "github.com/koddr/go-email-sender"
"github.com/patbritton/sojorn-backend/internal/config"
"github.com/rs/zerolog/log"
)
type EmailService struct {
config *config.Config
token string
tokenExpires time.Time
mu sync.Mutex
}
func NewEmailService(cfg *config.Config) *EmailService {
return &EmailService{config: cfg}
}
// SendPulse API Structs
type sendPulseAuthResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
}
type sendPulseEmailRequest struct {
Email sendPulseEmailData `json:"email"`
}
type sendPulseEmailData struct {
HTML string `json:"html"`
Text string `json:"text"`
Subject string `json:"subject"`
From sendPulseIdentity `json:"from"`
To []sendPulseIdentity `json:"to"`
}
type sendPulseIdentity struct {
Name string `json:"name"`
Email string `json:"email"`
}
func (s *EmailService) SendVerificationEmail(toEmail, toName, token string) error {
subject := "Verify your Sojorn account"
verifyURL := fmt.Sprintf("https://api.gosojorn.com/api/v1/auth/verify?token=%s", token)
body := fmt.Sprintf(`
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: 'Outfit', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; margin: 0; padding: 0; background-color: #f4f4f5; }
.wrapper { padding: 40px 20px; }
.container { max-width: 500px; margin: 0 auto; background: #ffffff; border-radius: 16px; overflow: hidden; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1); }
.header { background: #09090b; padding: 30px; text-align: center; }
.content { padding: 40px; text-align: center; color: #3f3f46; line-height: 1.6; }
.logo { width: 120px; height: auto; margin-bottom: 10px; }
h1 { color: #18181b; font-size: 24px; font-weight: 700; margin-bottom: 16px; }
p { margin-bottom: 24px; font-size: 16px; }
.button { display: inline-block; padding: 14px 32px; background-color: #10B981; color: #ffffff !important; text-decoration: none; border-radius: 12px; font-weight: 600; font-size: 16px; transition: background-color 0.2s; }
.footer { padding: 24px; text-align: center; font-size: 14px; color: #71717a; border-top: 1px solid #f4f4f5; }
.link { color: #10B981; text-decoration: none; word-break: break-all; font-size: 12px; }
</style>
</head>
<body>
<div class="wrapper">
<div class="container">
<div class="header">
<img src="https://gosojorn.com/web.png" alt="Sojorn" class="logo">
</div>
<div class="content">
<h1>Welcome to Sojorn, %s</h1>
<p>Thanks for signing up! To get started, please verify your email address by clicking the button below.</p>
<a href="%s" class="button">Verify Email Address</a>
<p style="margin-top: 32px; font-size: 14px; color: #a1a1aa;">If the button doesn't work, copy and paste this link into your browser:</p>
<a href="%s" class="link">%s</a>
</div>
<div class="footer">
<p>If you didn't create an account, you can safely ignore this email.</p>
<p>&copy; 2026 Sojorn. All rights reserved.</p>
</div>
</div>
</div>
</body>
</html>
`, toName, verifyURL, verifyURL, verifyURL)
return s.sendEmail(toEmail, toName, subject, body, "Verify your Sojorn account: "+verifyURL)
}
func (s *EmailService) SendPasswordResetEmail(toEmail, toName, token string) error {
subject := "Reset your Sojorn password"
resetURL := fmt.Sprintf("https://sojorn.com/reset-password?token=%s", token)
body := fmt.Sprintf(`
<h2>Reset Password for %s</h2>
<p>You requested a password reset. Click the link below to set a new password:</p>
<p><a href="%s" style="padding: 10px 20px; background-color: #dc3545; color: white; text-decoration: none; border-radius: 5px;">Reset Password</a></p>
<p>This link expires in 1 hour.</p>
<p>If you did not request this, please ignore this email.</p>
`, toName, resetURL)
return s.sendEmail(toEmail, toName, subject, body, "Reset your password: "+resetURL)
}
func (s *EmailService) sendEmail(toEmail, toName, subject, htmlBody, textBody string) error {
// Prefer SendPulse API
if s.config.SendPulseID != "" && s.config.SendPulseSecret != "" {
return s.sendViaSendPulse(toEmail, toName, subject, htmlBody, textBody)
}
// Fallback to Sender.net API (if configured)
if s.config.SenderAPIToken != "" {
log.Warn().Msg("Using deprecated Sender.net API token, consider migrating to SendPulse")
// Implementation omitted/deprecated to simplify
}
if s.config.SMTPHost == "" || s.config.SMTPUser == "" {
log.Warn().Msg("SMTP not configured, skipping email send")
return nil
}
// SMTP Fallback
emailSender := sender.NewEmailSender(
s.config.SMTPUser,
s.config.SMTPPass,
s.config.SMTPHost,
s.config.SMTPPort,
)
err := emailSender.SendPlainEmail([]string{toEmail}, nil, subject, htmlBody, nil)
if err != nil {
log.Error().Err(err).Msg("Failed to send email via SMTP")
return err
}
log.Info().Msgf("Email sent to %s via SMTP", toEmail)
return nil
}
func (s *EmailService) getSendPulseToken() (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.token != "" && time.Now().Before(s.tokenExpires) {
return s.token, nil
}
url := "https://api.sendpulse.com/oauth/access_token"
payload := map[string]string{
"grant_type": "client_credentials",
"client_id": s.config.SendPulseID,
"client_secret": s.config.SendPulseSecret,
}
jsonData, _ := json.Marshal(payload)
resp, err := http.Post(url, "application/json", bytes.NewBuffer(jsonData))
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
log.Error().Str("body", string(body)).Int("status", resp.StatusCode).Msg("Failed to get SendPulse Token")
return "", fmt.Errorf("failed to auth sendpulse: %d", resp.StatusCode)
}
var authResp sendPulseAuthResponse
if err := json.NewDecoder(resp.Body).Decode(&authResp); err != nil {
return "", err
}
s.token = authResp.AccessToken
s.tokenExpires = time.Now().Add(time.Duration(authResp.ExpiresIn-60) * time.Second) // Buffer 60s
log.Info().Msg("Authenticated with SendPulse")
return s.token, nil
}
func (s *EmailService) sendViaSendPulse(toEmail, toName, subject, htmlBody, textBody string) error {
token, err := s.getSendPulseToken()
if err != nil {
return err
}
url := "https://api.sendpulse.com/smtp/emails"
// Determine correct FROM email
fromEmail := s.config.SMTPFrom
if fromEmail == "" {
fromEmail = "no-reply@gosojorn.com"
}
reqBody := sendPulseEmailRequest{
Email: sendPulseEmailData{
HTML: htmlBody,
Text: textBody,
Subject: subject,
From: sendPulseIdentity{
Name: "Sojorn",
Email: fromEmail,
},
To: []sendPulseIdentity{
{Name: toName, Email: toEmail},
},
},
}
jsonData, _ := json.Marshal(reqBody)
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
log.Error().Err(err).Msg("Failed to call SendPulse API")
return err
}
defer resp.Body.Close()
bodyBytes, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
log.Error().Int("status", resp.StatusCode).Str("body", string(bodyBytes)).Msg("SendPulse API Error")
// If 401, maybe token expired? Reset token and retry once?
if resp.StatusCode == 401 {
s.mu.Lock()
s.token = ""
s.mu.Unlock()
// Simple retry logic could be added here
}
return fmt.Errorf("sendpulse error: %s", string(bodyBytes))
}
log.Info().Msgf("Email sent to %s via SendPulse", toEmail)
return nil
}
func (s *EmailService) AddSubscriber(email, name string) {
// SendPulse Addressbook API implementation omitted for brevity, focusing on email first
// Endpoint: POST /addressbooks/{id}/emails
}