package services import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "sync" "time" "github.com/jackc/pgx/v5/pgxpool" 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 pool *pgxpool.Pool token string tokenExpires time.Time mu sync.Mutex } func NewEmailService(cfg *config.Config, pool *pgxpool.Pool) *EmailService { return &EmailService{config: cfg, pool: pool} } type dbTemplate struct { Subject string Title string Header string Content string ButtonText string ButtonURL string ButtonColor string Footer string TextBody string Enabled bool } func (s *EmailService) getTemplate(slug string) *dbTemplate { if s.pool == nil { return nil } var t dbTemplate err := s.pool.QueryRow(context.Background(), `SELECT subject, title, header, content, button_text, button_url, button_color, footer, text_body, enabled FROM email_templates WHERE slug = $1`, slug). Scan(&t.Subject, &t.Title, &t.Header, &t.Content, &t.ButtonText, &t.ButtonURL, &t.ButtonColor, &t.Footer, &t.TextBody, &t.Enabled) if err != nil { log.Debug().Str("slug", slug).Err(err).Msg("Email template not found in DB, using hardcoded fallback") return nil } if !t.Enabled { log.Info().Str("slug", slug).Msg("Email template disabled, skipping send") return &t } return &t } func (s *EmailService) sendFromTemplate(slug string, replacements map[string]string, toEmail, toName string, fallback func() error) error { t := s.getTemplate(slug) if t == nil { return fallback() } if !t.Enabled { return nil } r := func(s string) string { for k, v := range replacements { s = strings.ReplaceAll(s, k, v) } return s } subject := r(t.Subject) title := r(t.Title) header := r(t.Header) content := r(t.Content) buttonText := r(t.ButtonText) buttonURL := r(t.ButtonURL) footer := r(t.Footer) textBody := r(t.TextBody) buttonColor := t.ButtonColor if buttonColor == "" { buttonColor = "#4338CA" } htmlBody := s.BuildHTMLEmailWithColor(title, header, content, buttonURL, buttonText, footer, buttonColor) return s.sendEmail(toEmail, toName, subject, htmlBody, textBody) } // 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 { apiBase := strings.TrimSuffix(s.config.APIBaseURL, "/api/v1") verifyURL := fmt.Sprintf("%s/api/v1/auth/verify?token=%s", apiBase, url.QueryEscape(token)) name := toName if name == "" { name = "there" } return s.sendFromTemplate("verification", map[string]string{ "{{name}}": name, "{{verify_url}}": verifyURL, }, toEmail, toName, func() error { subject := "Verify your Sojorn account" title := "Email Verification" header := fmt.Sprintf("Hey %s!", name) content := `
Welcome to Sojorn — your vibrant new social space. We're thrilled to have you join our community!
To get started in the app, please verify your email address by clicking the button below:
` footer := fmt.Sprintf(`|
If the button above doesn't work, copy and paste this link into your browser: %s |
Hey %s,
You requested a password reset for your Sojorn account. Click the button below to set a new password:
`, toName) footer := fmt.Sprintf(`|
If the button doesn't work, copy and paste this link: %s |
This link expires in 1 hour. If you did not request this, you can safely ignore this email.
`, resetURL, resetURL) htmlBody := s.buildHTMLEmail(title, header, content, resetURL, "Reset Password", footer) 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) }) } 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, ) // SMTP Fallback - Send HTML email err := emailSender.SendHTMLEmail( "Sojorn", // from name []string{toEmail}, // recipients nil, // cc subject, // subject htmlBody, // html body nil, // attachments ) 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@sojorn.net" } 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) SendBanNotificationEmail(toEmail, toName, reason string) error { return s.sendFromTemplate("ban_notification", map[string]string{ "{{name}}": toName, "{{reason}}": reason, }, toEmail, toName, func() error { subject := "Your Sojorn account has been suspended" title := "Account Suspended" header := "Your account has been suspended" content := fmt.Sprintf(`Hi %s,
After a review of your recent activity, your Sojorn account has been permanently suspended for violating our Community Guidelines.
Reason: %s
If you believe this action was taken in error, you can submit an appeal.
`, toName, reason) appealURL := "mailto:appeals@sojorn.net?subject=Account%%20Appeal" footer := `You can also reply directly to this email to submit your appeal.
` htmlBody := s.buildHTMLEmail(title, header, content, appealURL, "Submit an Appeal", footer) textBody := fmt.Sprintf("Hi %s,\n\nYour Sojorn account has been permanently suspended.\n\nReason: %s\n\nIf you believe this was in error, reply to this email or contact appeals@sojorn.net.\n\n— The Sojorn Team", toName, reason) return s.sendEmail(toEmail, toName, subject, htmlBody, textBody) }) } func (s *EmailService) SendSuspensionNotificationEmail(toEmail, toName, reason, duration string) error { return s.sendFromTemplate("suspension_notification", map[string]string{ "{{name}}": toName, "{{reason}}": reason, "{{duration}}": duration, }, toEmail, toName, func() error { subject := "Your Sojorn account has been temporarily suspended" title := "Account Temporarily Suspended" header := "Your account has been temporarily suspended" content := fmt.Sprintf(`Hi %s,
Your Sojorn account has been temporarily suspended for %s due to a violation of our Community Guidelines.
Reason: %s
Your account will be automatically restored after the suspension period.
`, toName, duration, reason) htmlBody := s.buildHTMLEmail(title, header, content, "https://mp.ls/terms", "Review Community Guidelines", "") textBody := fmt.Sprintf("Hi %s,\n\nYour Sojorn account has been temporarily suspended for %s.\n\nReason: %s\n\nYour account will be restored after the suspension period.\n\n— The Sojorn Team", toName, duration, reason) return s.sendEmail(toEmail, toName, subject, htmlBody, textBody) }) } func (s *EmailService) SendAccountRestoredEmail(toEmail, toName, reason string) error { return s.sendFromTemplate("account_restored", map[string]string{ "{{name}}": toName, "{{reason}}": reason, }, toEmail, toName, func() error { subject := "Your Sojorn account has been restored" title := "Account Restored" header := "Welcome back!" content := fmt.Sprintf(`Hi %s,
Great news — your Sojorn account has been restored and is fully active again.
Note: %s
All of your previous posts and comments have been restored and are visible again.
`, toName, reason) htmlBody := s.buildHTMLEmail(title, header, content, "https://mp.ls/sojorn", "Open Sojorn", "") textBody := fmt.Sprintf("Hi %s,\n\nYour Sojorn account has been restored and is fully active again.\n\nNote: %s\n\nAll of your posts and comments are visible again.\n\n— The Sojorn Team", toName, reason) return s.sendEmail(toEmail, toName, subject, htmlBody, textBody) }) } func (s *EmailService) SendDeactivationEmail(toEmail, toName string) error { return s.sendFromTemplate("deactivation", map[string]string{ "{{name}}": toName, }, toEmail, toName, func() error { subject := "Your Sojorn account has been deactivated" title := "Account Deactivated" header := "Your account has been deactivated" content := fmt.Sprintf(`Hey %s,
Your Sojorn account has been deactivated. Your profile is now hidden from other users.
Hey %s,
Your Sojorn account has been scheduled for permanent deletion on %s.
Changed your mind? Simply log back in before %s to cancel.
`, toName, deletionDate, deletionDate, deletionDate) htmlBody := s.buildHTMLEmail(title, header, content, "https://mp.ls/sojorn", "Log In to Cancel Deletion", "") textBody := fmt.Sprintf("Hey %s,\n\nYour Sojorn account has been scheduled for permanent deletion on %s.\n\nLog back in before %s to cancel.\n\n— The Sojorn Team", toName, deletionDate, deletionDate) return s.sendEmail(toEmail, toName, subject, htmlBody, textBody) }) } func (s *EmailService) SendDestroyConfirmationEmail(toEmail, toName, confirmURL string) error { return s.sendFromTemplate("destroy_confirmation", map[string]string{ "{{name}}": toName, "{{confirm_url}}": confirmURL, }, toEmail, toName, func() error { subject := "FINAL WARNING: Confirm Permanent Account Destruction" title := "Account Destruction" header := "Confirm account destruction" content := fmt.Sprintf(`Hey %s,
You requested immediate and permanent destruction of your Sojorn account.
THIS ACTION IS IRREVERSIBLE
If you are absolutely certain, click the button below.
`, toName) footer := `This link expires in 1 hour. If you did not request this, ignore this email.
` htmlBody := s.BuildHTMLEmailWithColor(title, header, content, confirmURL, "PERMANENTLY DESTROY MY ACCOUNT", footer, "#DC2626") textBody := fmt.Sprintf("FINAL WARNING\n\nHey %s,\n\nYou requested IMMEDIATE AND PERMANENT DESTRUCTION of your Sojorn account.\n\nTo confirm, visit:\n%s\n\nThis link expires in 1 hour.\n\n— The Sojorn Team", toName, confirmURL) return s.sendEmail(toEmail, toName, subject, htmlBody, textBody) }) } func (s *EmailService) SendContentRemovalEmail(toEmail, toName, contentType, reason string, strikeCount int) error { strikeWarning := "" if strikeCount >= 5 { strikeWarning = `Warning: You are close to a permanent suspension.
` } else if strikeCount >= 3 { strikeWarning = `Warning: Continued violations may result in suspension.
` } return s.sendFromTemplate("content_removal", map[string]string{ "{{name}}": toName, "{{content_type}}": contentType, "{{reason}}": reason, "{{strike_count}}": fmt.Sprintf("%d", strikeCount), "{{strike_warning}}": strikeWarning, }, toEmail, toName, func() error { subject := fmt.Sprintf("Your %s on Sojorn was removed", contentType) title := "Content Removed" header := fmt.Sprintf("Your %s has been removed", contentType) content := fmt.Sprintf(`Hi %s,
One of your %ss has been removed by our moderation team for violating our Community Guidelines.
Reason: %s
This is strike %d on your account.
%sIf you believe this was in error, you can reply to this email.
`, toName, contentType, reason, strikeCount, strikeWarning) htmlBody := s.buildHTMLEmail(title, header, content, "https://mp.ls/terms", "Review Community Guidelines", "") textBody := fmt.Sprintf("Hi %s,\n\nYour %s has been removed.\n\nReason: %s\nStrike %d on your account.\n\n— The Sojorn Team", toName, contentType, reason, strikeCount) return s.sendEmail(toEmail, toName, subject, htmlBody, textBody) }) } // SendGenericEmail sends an email with pre-built HTML and text bodies func (s *EmailService) SendGenericEmail(toEmail, toName, subject, htmlBody, textBody string) error { return s.sendEmail(toEmail, toName, subject, htmlBody, textBody) } func (s *EmailService) AddSubscriber(email, name string) { } func (s *EmailService) buildHTMLEmail(title, header, content, buttonURL, buttonText, footer string) string { return s.BuildHTMLEmailWithColor(title, header, content, buttonURL, buttonText, footer, "#4338CA") } func (s *EmailService) BuildHTMLEmailWithColor(title, header, content, buttonURL, buttonText, footer, buttonColor string) string { tpl := `